profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM

Commit

Initial commit: SplitApp REST API, Modular Monolith architecture

commit 5549f85

418 changed files with +32138 and −0

Jump to a changed file
  1. .dockerignore +20 −0
  2. .gitignore +87 −0
  3. .gitlab-ci.yml +11 −0
  4. Dockerfile +40 −0
  5. LICENSE +21 −0
  6. README.md +215 −0
  7. SplitApp.Modular/Directory.Build.props +9 −0
  8. SplitApp.Modular/README.md +96 −0
  9. SplitApp.Modular/SplitApp.sln +326 −0
  10. SplitApp.Modular/docs/ARCHITECTURE.md +191 −0
  11. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor +3 −0
  12. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor.css +6 −0
  13. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/CurrenciesController.cs +39 −0
  14. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/ExpensesController.cs +278 −0
  15. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SettlementsController.cs +290 −0
  16. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SplitPresetsController.cs +208 −0
  17. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/CurrencyDto.cs +9 −0
  18. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/ExpenseDto.cs +49 −0
  19. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SettlementDto.cs +37 −0
  20. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SplitPresetDto.cs +35 −0
  21. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/ExampleJsInterop.cs +31 −0
  22. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/SplitApp.Modules.Expenses.Api.csproj +26 −0
  23. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/_Imports.razor +1 −0
  24. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/background.png +0 −0
  25. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/exampleJsInterop.js +6 −0
  26. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/Contracts/IExpensesUnitOfWork.cs +15 −0
  27. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/CurrencyConverter.cs +22 −0
  28. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/ExpensesModuleMarker.cs +4 −0
  29. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/SplitApp.Modules.Expenses.Application.csproj +19 −0
  30. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Currency.cs +16 −0
  31. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Expense.cs +39 −0
  32. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/ExpenseSplit.cs +19 −0
  33. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPayment.cs +30 −0
  34. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPlan.cs +27 −0
  35. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPreset.cs +27 −0
  36. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPresetMember.cs +19 −0
  37. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/EPaymentStatus.cs +8 −0
  38. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESettlementStatus.cs +8 −0
  39. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESplitMethod.cs +9 −0
  40. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/SplitApp.Modules.Expenses.Domain.csproj +14 −0
  41. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/ExpensesModuleExtensions.cs +64 −0
  42. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/CalculateSettlementHandler.cs +113 −0
  43. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetBudgetCategorySpentHandler.cs +30 −0
  44. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetCurrenciesByIdsHandler.cs +28 −0
  45. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetTripExpenseTotalsHandler.cs +34 −0
  46. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/RemoveSettlementPlanHandler.cs +37 −0
  47. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/TripDeletedHandler.cs +55 −0
  48. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/UserDeletedHandler.cs +27 −0
  49. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesDbContext.cs +78 −0
  50. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesUnitOfWork.cs +33 −0
  51. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.Designer.cs +330 −0
  52. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.cs +235 −0
  53. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/ExpensesDbContextModelSnapshot.cs +327 −0
  54. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Repositories/ExpensesBaseRepository.cs +29 −0
  55. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
  56. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj +30 −0
  57. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor +3 −0
  58. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor.css +6 −0
  59. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/BudgetCategoriesController.cs +146 −0
  60. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/InvitationsController.cs +198 −0
  61. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/PollsController.cs +255 −0
  62. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/TripsController.cs +328 −0
  63. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/WishlistController.cs +242 −0
  64. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/BudgetCategoryDto.cs +21 −0
  65. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/InvitationDto.cs +17 −0
  66. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/PollDto.cs +36 −0
  67. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/TripDto.cs +53 −0
  68. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/WishlistItemDto.cs +32 −0
  69. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/ExampleJsInterop.cs +31 −0
  70. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/SplitApp.Modules.Trips.Api.csproj +26 −0
  71. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/_Imports.razor +1 −0
  72. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/background.png +0 −0
  73. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/exampleJsInterop.js +6 −0
  74. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Contracts/ITripsUnitOfWork.cs +18 −0
  75. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Handlers/GetTripByIdHandler.cs +23 −0
  76. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/SplitApp.Modules.Trips.Application.csproj +19 −0
  77. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/TripsModuleMarker.cs +4 −0
  78. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/BudgetCategory.cs +25 −0
  79. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/Trip.cs +56 −0
  80. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripInvitation.cs +24 −0
  81. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripParticipant.cs +27 −0
  82. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPoll.cs +26 −0
  83. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollOption.cs +17 −0
  84. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollVote.cs +14 −0
  85. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistItem.cs +40 −0
  86. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistVote.cs +16 −0
  87. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EInvitationStatus.cs +10 −0
  88. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EParticipantRole.cs +7 −0
  89. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/ETripStatus.cs +9 −0
  90. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistCategory.cs +9 −0
  91. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistPriority.cs +8 −0
  92. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/SplitApp.Modules.Trips.Domain.csproj +15 −0
  93. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetBudgetCategoryNamesByIdsHandler.cs +30 −0
  94. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetTripParticipantsHandler.cs +27 −0
  95. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/IsTripParticipantHandler.cs +25 −0
  96. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/SettlementPlanCompletedHandler.cs +32 −0
  97. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/UserDeletedHandler.cs +35 −0
  98. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.Designer.cs +495 −0
  99. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.cs +350 −0
  100. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/TripsDbContextModelSnapshot.cs +492 −0
  101. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Repositories/TripsBaseRepository.cs +29 −0
  102. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsDbContext.cs +96 −0
  103. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsUnitOfWork.cs +38 −0
  104. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
  105. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj +30 −0
  106. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/TripsModuleExtensions.cs +40 −0
  107. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor +3 −0
  108. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor.css +6 −0
  109. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AccountController.cs +145 −0
  110. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/JWTResponse.cs +9 −0
  111. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LoginInfo.cs +7 −0
  112. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LogoutInfo.cs +6 −0
  113. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RegisterInfo.cs +9 −0
  114. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RestApiErrorResponse.cs +9 −0
  115. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/TokenRefreshInfo.cs +7 −0
  116. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/ExampleJsInterop.cs +31 −0
  117. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/SplitApp.Modules.Users.Api.csproj +27 −0
  118. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/_Imports.razor +1 −0
  119. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/background.png +0 −0
  120. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/exampleJsInterop.js +6 −0
  121. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IRefreshTokenRepository.cs +12 −0
  122. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUserRepository.cs +12 −0
  123. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUsersUnitOfWork.cs +9 −0
  124. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUserByIdHandler.cs +26 −0
  125. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUsersByIdsHandler.cs +25 −0
  126. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IIdentityService.cs +9 −0
  127. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityService.cs +271 −0
  128. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityServiceModels.cs +63 −0
  129. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/SplitApp.Modules.Users.Application.csproj +23 −0
  130. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/UsersModuleMarker.cs +4 −0
  131. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRefreshToken.cs +20 −0
  132. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRole.cs +8 −0
  133. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppUser.cs +16 −0
  134. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/SplitApp.Modules.Users.Domain.csproj +17 −0
  135. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.Designer.cs +359 −0
  136. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.cs +310 −0
  137. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs +356 −0
  138. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/BaseRepository.cs +29 −0
  139. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs +42 −0
  140. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/UserRepository.cs +40 −0
  141. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersDbContext.cs +66 −0
  142. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersUnitOfWork.cs +23 −0
  143. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
  144. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj +34 −0
  145. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/UsersModuleExtensions.cs +141 −0
  146. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/CalculateSettlementCommand.cs +11 −0
  147. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/RemoveSettlementPlanCommand.cs +8 −0
  148. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/CurrencyDto.cs +7 −0
  149. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/ExpenseSettledEvent.cs +5 −0
  150. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/SettlementPlanCompletedEvent.cs +10 −0
  151. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetBudgetCategorySpentQuery.cs +6 −0
  152. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetCurrenciesByIdsQuery.cs +6 −0
  153. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetTripExpenseTotalsQuery.cs +5 −0
  154. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/TripExpenseTotalsDto.cs +7 −0
  155. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/SplitApp.Shared.Contracts.csproj +17 −0
  156. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/BudgetCategoryNameDto.cs +6 −0
  157. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Events/TripDeletedEvent.cs +5 −0
  158. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetBudgetCategoryNamesByIdsQuery.cs +6 −0
  159. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripByIdQuery.cs +5 −0
  160. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripParticipantsQuery.cs +5 −0
  161. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/IsTripParticipantQuery.cs +5 −0
  162. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripParticipantDto.cs +11 −0
  163. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripSummaryDto.cs +8 −0
  164. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Events/UserDeletedEvent.cs +5 −0
  165. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUserByIdQuery.cs +5 −0
  166. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUsersByIdsQuery.cs +5 −0
  167. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/UserDto.cs +7 −0
  168. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Auth/IdentityHelpers.cs +59 −0
  169. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/BaseEntity.cs +8 −0
  170. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/IBaseEntity.cs +6 −0
  171. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Localization/LangStr.cs +73 −0
  172. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IBaseRepository.cs +13 −0
  173. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IUnitOfWork.cs +6 −0
  174. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/SplitApp.Shared.Kernel.csproj +14 −0
  175. SplitApp.Modular/src/SplitApp.WebApp/Application/Contracts/IAppUnitOfWork.cs +100 −0
  176. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/AppUserBllDto.cs +11 −0
  177. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BalanceBllDto.cs +10 −0
  178. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BudgetCategoryBllDto.cs +22 −0
  179. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/CurrencyBllDto.cs +15 −0
  180. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseBllDto.cs +38 −0
  181. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseSplitBllDto.cs +19 −0
  182. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPaymentBllDto.cs +31 −0
  183. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPlanBllDto.cs +30 −0
  184. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SplitPresetBllDto.cs +42 −0
  185. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripBllDto.cs +39 −0
  186. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripInvitationBllDto.cs +30 −0
  187. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripParticipantBllDto.cs +30 −0
  188. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollBllDto.cs +26 −0
  189. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollOptionBllDto.cs +18 −0
  190. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripWishlistItemBllDto.cs +38 −0
  191. SplitApp.Modular/src/SplitApp.WebApp/Application/Helpers/CurrencyConverter.cs +30 −0
  192. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/AppUserBllDtoFactory.cs +15 −0
  193. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/BudgetCategoryBllDtoFactory.cs +42 −0
  194. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/CurrencyBllDtoFactory.cs +32 −0
  195. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/ExpenseBllDtoFactory.cs +71 −0
  196. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/InvitationBllDtoFactory.cs +36 −0
  197. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/PollBllDtoFactory.cs +63 −0
  198. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SettlementBllDtoFactory.cs +73 −0
  199. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SplitPresetBllDtoFactory.cs +61 −0
  200. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/TripBllDtoFactory.cs +86 −0
  201. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/WishlistBllDtoFactory.cs +54 −0
  202. SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs +653 −0
  203. SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs +179 −0
  204. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminDashboardData.cs +66 −0
  205. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminStatsService.cs +203 −0
  206. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/BudgetCategoryAdminService.cs +81 −0
  207. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/CurrencyAdminService.cs +73 −0
  208. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ExpenseAdminService.cs +99 −0
  209. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IAdminStatsService.cs +6 −0
  210. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
  211. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ICurrencyAdminService.cs +13 −0
  212. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IExpenseAdminService.cs +18 −0
  213. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IInvitationAdminService.cs +15 −0
  214. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IPollAdminService.cs +16 −0
  215. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
  216. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPlanAdminService.cs +16 −0
  217. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISplitPresetAdminService.cs +14 −0
  218. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripAdminService.cs +14 −0
  219. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripParticipantAdminService.cs +16 −0
  220. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IWishlistAdminService.cs +16 −0
  221. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/InvitationAdminService.cs +77 −0
  222. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/PollAdminService.cs +77 −0
  223. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPaymentAdminService.cs +78 −0
  224. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPlanAdminService.cs +76 −0
  225. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SplitPresetAdminService.cs +61 −0
  226. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripAdminService.cs +71 −0
  227. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripParticipantAdminService.cs +86 −0
  228. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/WishlistAdminService.cs +83 −0
  229. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/BudgetCategoryService.cs +86 −0
  230. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ExpenseService.cs +348 −0
  231. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IBudgetCategoryService.cs +14 −0
  232. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IExpenseService.cs +44 −0
  233. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IInvitationService.cs +30 −0
  234. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IPollService.cs +27 −0
  235. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISettlementService.cs +46 −0
  236. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISplitPresetService.cs +20 −0
  237. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ITripService.cs +38 −0
  238. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IWishlistService.cs +16 −0
  239. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IIdentityService.cs +9 −0
  240. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityService.cs +266 −0
  241. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityServiceResult.cs +63 −0
  242. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/InvitationService.cs +193 −0
  243. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/PollService.cs +207 −0
  244. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SettlementService.cs +340 −0
  245. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SplitPresetService.cs +124 −0
  246. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/TripService.cs +240 −0
  247. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/WishlistService.cs +149 −0
  248. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +147 −0
  249. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/CurrenciesController.cs +134 −0
  250. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
  251. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/ExpensesController.cs +149 −0
  252. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/InvitationsController.cs +158 −0
  253. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/PollsController.cs +129 −0
  254. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +153 −0
  255. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +187 −0
  256. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +115 −0
  257. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +188 −0
  258. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripsController.cs +140 −0
  259. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
  260. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/WishlistController.cs +129 −0
  261. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
  262. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
  263. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +25 −0
  264. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +28 −0
  265. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
  266. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +76 −0
  267. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
  268. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +25 −0
  269. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Details.cshtml +22 −0
  270. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
  271. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Index.cshtml +65 −0
  272. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
  273. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
  274. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +31 −0
  275. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Details.cshtml +37 −0
  276. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
  277. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Index.cshtml +80 −0
  278. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
  279. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +39 −0
  280. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Details.cshtml +40 −0
  281. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
  282. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Index.cshtml +65 −0
  283. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
  284. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Delete.cshtml +13 −0
  285. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Details.cshtml +37 −0
  286. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
  287. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Index.cshtml +73 −0
  288. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
  289. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +39 −0
  290. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +43 −0
  291. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
  292. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +67 −0
  293. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
  294. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +28 −0
  295. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +28 −0
  296. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
  297. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +73 −0
  298. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +126 −0
  299. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
  300. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +28 −0
  301. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +50 −0
  302. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +64 −0
  303. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
  304. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +28 −0
  305. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +34 −0
  306. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
  307. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +78 −0
  308. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
  309. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Delete.cshtml +28 −0
  310. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Details.cshtml +37 −0
  311. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
  312. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Index.cshtml +69 −0
  313. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
  314. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
  315. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
  316. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
  317. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
  318. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
  319. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +13 −0
  320. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +35 −0
  321. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
  322. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +71 −0
  323. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewImports.cshtml +16 −0
  324. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
  325. SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml +59 −0
  326. SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
  327. SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
  328. SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
  329. SplitApp.Modular/src/SplitApp.WebApp/Controllers/BudgetController.cs +213 −0
  330. SplitApp.Modular/src/SplitApp.WebApp/Controllers/ExpensesController.cs +270 −0
  331. SplitApp.Modular/src/SplitApp.WebApp/Controllers/HomeController.cs +35 −0
  332. SplitApp.Modular/src/SplitApp.WebApp/Controllers/MembersController.cs +181 −0
  333. SplitApp.Modular/src/SplitApp.WebApp/Controllers/PollsClientController.cs +148 −0
  334. SplitApp.Modular/src/SplitApp.WebApp/Controllers/SettlementController.cs +194 −0
  335. SplitApp.Modular/src/SplitApp.WebApp/Controllers/TripsController.cs +264 −0
  336. SplitApp.Modular/src/SplitApp.WebApp/Controllers/WishlistClientController.cs +281 −0
  337. SplitApp.Modular/src/SplitApp.WebApp/Hosting/AppDataInit.cs +363 −0
  338. SplitApp.Modular/src/SplitApp.WebApp/Hosting/ConfigureSwaggerOptions.cs +61 −0
  339. SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/CurrencyConverter.cs +30 −0
  340. SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/EnumHelper.cs +18 −0
  341. SplitApp.Modular/src/SplitApp.WebApp/Hosting/InvariantDecimalModelBinderProvider.cs +55 −0
  342. SplitApp.Modular/src/SplitApp.WebApp/Hosting/PassthroughStringLocalizer.cs +28 −0
  343. SplitApp.Modular/src/SplitApp.WebApp/Models/ErrorViewModel.cs +8 −0
  344. SplitApp.Modular/src/SplitApp.WebApp/Program.cs +191 −0
  345. SplitApp.Modular/src/SplitApp.WebApp/Properties/launchSettings.json +23 −0
  346. SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.cs +8 −0
  347. SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.et.resx +70 −0
  348. SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.resx +70 −0
  349. SplitApp.Modular/src/SplitApp.WebApp/Resources/Shared.cs +7 −0
  350. SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.et.resx +513 −0
  351. SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.resx +514 −0
  352. SplitApp.Modular/src/SplitApp.WebApp/SplitApp.WebApp.csproj +45 −0
  353. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/CreateCategory.cshtml +71 −0
  354. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
  355. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/EditCategory.cshtml +72 −0
  356. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/Index.cshtml +155 −0
  357. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Create.cshtml +365 −0
  358. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Delete.cshtml +51 −0
  359. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Edit.cshtml +84 −0
  360. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Index.cshtml +111 −0
  361. SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Index.cshtml +116 −0
  362. SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Privacy.cshtml +6 −0
  363. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
  364. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Index.cshtml +125 −0
  365. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
  366. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Invite.cshtml +34 −0
  367. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InviteGenerated.cshtml +40 −0
  368. SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Create.cshtml +83 −0
  369. SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Details.cshtml +126 −0
  370. SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Index.cshtml +99 −0
  371. SplitApp.Modular/src/SplitApp.WebApp/Views/Settlement/Index.cshtml +232 −0
  372. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/Error.cshtml +29 −0
  373. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
  374. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml +116 −0
  375. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml.css +48 −0
  376. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LoginPartial.cshtml +50 −0
  377. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
  378. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Create.cshtml +82 −0
  379. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Delete.cshtml +56 −0
  380. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Details.cshtml +291 −0
  381. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Edit.cshtml +87 −0
  382. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Index.cshtml +104 −0
  383. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Create.cshtml +85 −0
  384. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Delete.cshtml +45 −0
  385. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Edit.cshtml +77 −0
  386. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Index.cshtml +147 −0
  387. SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewImports.cshtml +15 −0
  388. SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewStart.cshtml +3 −0
  389. SplitApp.Modular/src/SplitApp.WebApp/appsettings.json +27 −0
  390. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/admin.css +439 −0
  391. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/site.css +31 −0
  392. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/splitapp-design.css +1605 −0
  393. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/favicon.ico +0 −0
  394. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/site.js +4 −0
  395. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/splitapp.js +323 −0
  396. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
  397. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
  398. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
  399. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
  400. SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/CurrencyConverterTests.cs +61 −0
  401. SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/SplitApp.Modules.Expenses.Tests.csproj +25 −0
  402. SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/LangStrTests.cs +86 −0
  403. SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/SplitApp.Modules.Trips.Tests.csproj +25 −0
  404. SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/IdentityHelpersTests.cs +105 −0
  405. SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/SplitApp.Modules.Users.Tests.csproj +25 −0
  406. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/CrossModuleNavigationTests.cs +90 −0
  407. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/DbContextSchemaIsolationTests.cs +70 −0
  408. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs +87 −0
  409. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs +59 −0
  410. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostFeatureTests.cs +85 −0
  411. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/SplitApp.WebApp.IntegrationTests.csproj +29 −0
  412. architecture.md +221 −0
  413. arhitektuur.md +230 −0
  414. docker-compose.yml +32 −0
  415. docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
  416. docs/grouptravel.png +0 −0
  417. explanation.md +313 −0
  418. modularmonolith.md +328 −0
added .dockerignore +20 −0
@@ -0,0 +1,20 @@
1 +**/.git
2 +**/.gitignore
3 +**/.vs
4 +**/.vscode
5 +**/.idea
6 +**/bin
7 +**/obj
8 +**/node_modules
9 +**/out
10 +**/.env
11 +**/.env.*
12 +**/appsettings.Development.json
13 +**/*.user
14 +**/*.suo
15 +**/docker-compose*.yml
16 +**/Dockerfile*
17 +**/.dockerignore
18 +**/README.md
19 +**/LICENSE
20 +**/*.md
added .gitignore +87 −0
@@ -0,0 +1,87 @@
1 +## Credentials
2 +credentials.md
3 +**/credentials.md
4 +
5 +## .NET
6 +bin/
7 +obj/
8 +*.user
9 +*.suo
10 +*.userosscache
11 +*.sln.docstates
12 +*.userprefs
13 +
14 +## Visual Studio
15 +.vs/
16 +*.rsuser
17 +[Dd]ebug/
18 +[Rr]elease/
19 +x64/
20 +x86/
21 +[Bb]uild/
22 +bld/
23 +
24 +## JetBrains Rider
25 +.idea/
26 +*.sln.iml
27 +
28 +## VS Code
29 +.vscode/
30 +
31 +## NuGet
32 +*.nupkg
33 +**/[Pp]ackages/*
34 +!**/[Pp]ackages/build/
35 +
36 +## Build results
37 +[Dd]ebugPublic/
38 +[Rr]eleases/
39 +[Aa]rtifacts/
40 +*_i.c
41 +*_p.c
42 +*_h.h
43 +*.ilk
44 +*.meta
45 +*.obj
46 +*.iobj
47 +*.pch
48 +*.pdb
49 +*.ipdb
50 +*.pgc
51 +*.pgd
52 +*.rsp
53 +*.sbr
54 +*.tlb
55 +*.tli
56 +*.tlh
57 +*.tmp
58 +*.tmp_proj
59 +*_wpftmp.csproj
60 +*.log
61 +*.vspscc
62 +*.vssscc
63 +.builds
64 +*.pidb
65 +*.svclog
66 +*.scc
67 +
68 +## OS files
69 +Thumbs.db
70 +ehthumbs.db
71 +Desktop.ini
72 +.DS_Store
73 +
74 +## Environment
75 +.env
76 +.env.*
77 +appsettings.Development.json
78 +
79 +## Docker
80 +docker-compose.override.yml
81 +
82 +## Node (if applicable)
83 +node_modules/
84 +npm-debug.log*
85 +
86 +## Publish output
87 +publish/
added .gitlab-ci.yml +11 −0
@@ -0,0 +1,11 @@
1 +stages:
2 + - deploy
3 +
4 +deploy:
5 + stage: deploy
6 + only:
7 + - main
8 + tags:
9 + - shared
10 + script:
11 + - docker compose -p splitapp-phase3 up --build --remove-orphans --detach
added Dockerfile +40 −0
@@ -0,0 +1,40 @@
1 +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2 +WORKDIR /src
3 +
4 +# Copy solution + Directory.Build.props
5 +COPY SplitApp.Modular/SplitApp.sln .
6 +COPY SplitApp.Modular/Directory.Build.props .
7 +
8 +# Copy all csproj files first for restore-layer caching
9 +COPY SplitApp.Modular/src/SplitApp.WebApp/*.csproj src/SplitApp.WebApp/
10 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/*.csproj src/Shared/SplitApp.Shared.Kernel/
11 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/*.csproj src/Shared/SplitApp.Shared.Contracts/
12 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/*.csproj src/Modules/Users/SplitApp.Modules.Users.Domain/
13 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/*.csproj src/Modules/Users/SplitApp.Modules.Users.Application/
14 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/*.csproj src/Modules/Users/SplitApp.Modules.Users.Infrastructure/
15 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/*.csproj src/Modules/Users/SplitApp.Modules.Users.Api/
16 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Domain/
17 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Application/
18 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/
19 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Api/
20 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/
21 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Application/
22 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/
23 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Api/
24 +COPY SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/*.csproj tests/SplitApp.Modules.Users.Tests/
25 +COPY SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/*.csproj tests/SplitApp.Modules.Trips.Tests/
26 +COPY SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/*.csproj tests/SplitApp.Modules.Expenses.Tests/
27 +COPY SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/*.csproj tests/SplitApp.WebApp.IntegrationTests/
28 +
29 +RUN dotnet restore SplitApp.sln
30 +
31 +# Copy everything else and publish the host
32 +COPY SplitApp.Modular/ .
33 +RUN dotnet publish src/SplitApp.WebApp/SplitApp.WebApp.csproj -c Release -o /app/publish
34 +
35 +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
36 +WORKDIR /app
37 +COPY --from=build /app/publish .
38 +ENV ASPNETCORE_URLS=http://+:8080
39 +EXPOSE 8080
40 +ENTRYPOINT ["dotnet", "SplitApp.WebApp.dll"]
added LICENSE +21 −0
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Rasmus Jürgenson
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
added README.md +215 −0
@@ -0,0 +1,215 @@
1 +# SplitApp — Trip Expense Management (Phase 3 — Modular Monolith)
2 +
3 +URL: https://travel.rasmusj.com/
4 +
5 +SplitApp is an ASP.NET Core 10.0 web application for managing group trips and splitting expenses. Users create trips, invite friends, track costs with flexible splitting (equal / equal-subset / exact / percentage), manage budgets, run polls, maintain a wishlist, and settle debts via an optimized algorithm.
6 +
7 +This repo is the **Phase 3 — Modular Monolith** refactor of the project. The whole product lives under [`SplitApp.Modular/`](SplitApp.Modular/): one deployable, three internally isolated modules (**Users**, **Trips**, **Expenses**), MediatR for cross-module communication, schema-per-module Postgres isolation.
8 +
9 +Built for the TalTech "Web Applications with C#" course **Personal Project — Phase 3**.
10 +
11 +---
12 +
13 +## Run
14 +
15 +```bash
16 +docker compose up --build
17 +```
18 +
19 +Brings up two containers:
20 +
21 +| Service | Container | Port | Notes |
22 +|---|---|---|---|
23 +| `phase3` | `phase3` | http://localhost:90 | The web app |
24 +| `db` | `phase3-db` | (internal only) | PostgreSQL 16, schemas `users` / `trips` / `expenses` — not exposed to host |
25 +
26 +Module migrations run automatically on host startup. Sample data is seeded if `DataInitialization:SeedData=true` (set in `appsettings.json`).
27 +
28 +Test login (after seed):
29 +- `admin@taltech.ee` / `Foo.Bar.1` — `admin` role, full Admin area access
30 +- `alice@taltech.ee`, `bob@taltech.ee`, `charlie@taltech.ee`, `diana@taltech.ee` / `Foo.Bar.1` — regular users with sample trips
31 +
32 +Stop:
33 +
34 +```bash
35 +docker compose down
36 +```
37 +
38 +---
39 +
40 +## Architecture at a glance
41 +
42 +```
43 + ┌────────────────────────────────────────────────┐
44 + │ SplitApp.WebApp (host) │
45 + │ Program.cs · Controllers · Areas/Admin │
46 + │ Application/{Services, DTO, Mappers, │
47 + │ Persistence} │
48 + └────────────────────────────────────────────────┘
49 + │ │ │
50 + ▼ ▼ ▼
51 + ┌──────────┐ ┌──────────┐ ┌──────────┐
52 + │ Users │ │ Trips │ │ Expenses │
53 + │ Domain │ │ Domain │ │ Domain │
54 + │ App │ │ App │ │ App │
55 + │ Infra │ │ Infra │ │ Infra │
56 + │ Api │ │ Api │ │ Api │
57 + │ schema: │ │ schema: │ │ schema: │
58 + │ users │ │ trips │ │ expenses │
59 + └──────────┘ └──────────┘ └──────────┘
60 + ▲ ▲ ▲
61 + └─MediatR───┴─MediatR───┘
62 + ┌──────────────────────┐ ┌──────────────────────┐
63 + │ Shared.Contracts │ │ Shared.Kernel │
64 + │ IRequest / INotification│ │ BaseEntity, LangStr │
65 + └──────────────────────┘ └──────────────────────┘
66 +```
67 +
68 +**Reference rules** (compiler-enforced + verified by `tests/SplitApp.WebApp.IntegrationTests/Architecture/`):
69 +
70 +- A module's `Application` / `Infrastructure` / `Api` can reference: same-module projects + `Shared.Kernel` + `Shared.Contracts`. Nothing else.
71 +- Inter-module function calls go through **MediatR only**.
72 +- `Shared.*` may not reference any module.
73 +- `WebApp` is the only project that references all three modules' `Api` and `Infrastructure`.
74 +
75 +**One caveat at the Domain level only:** to keep view-rendering parity from phase 2 (`Trip.CreatedBy.Email`, `Expense.PaidByUser.FirstName`, etc.), the entity classes still declare cross-module navigation properties — annotated `[NotMapped]` so EF never crosses Postgres schemas. The `CrossModuleNavigationTests` invariant rejects any *mapped* cross-module nav. Application/Infrastructure/Api remain isolated; only entity *types* are shared at the Domain level.
76 +
77 +See [explanation.md](explanation.md) (Estonian, full walkthrough), [arhitektuur.md](arhitektuur.md) (Estonian, diagrams + reference rules), [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) (English, deep-dive).
78 +
79 +---
80 +
81 +## Solution layout
82 +
83 +```
84 +SplitApp.Modular/
85 +├── SplitApp.sln
86 +├── Directory.Build.props
87 +├── src/
88 +│ ├── SplitApp.WebApp/ ← composition root, host
89 +│ │ ├── Program.cs ← AddXxxModule(...) wiring
90 +│ │ ├── Application/ ← lifted phase-2 BLL
91 +│ │ │ ├── Services/ (+ Admin/, Identity/)
92 +│ │ │ ├── DTO/
93 +│ │ │ ├── Mappers/
94 +│ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts
95 +│ │ │ └── Persistence/CrossModuleNavigationLoader.cs
96 +│ │ ├── Areas/Admin/
97 +│ │ ├── Areas/Identity/
98 +│ │ ├── Controllers/
99 +│ │ ├── Views/
100 +│ │ └── Resources/ ← i18n .resx (EN + ET)
101 +│ ├── Shared/
102 +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
103 +│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification
104 +│ └── Modules/
105 +│ ├── Users/ ← AppUser, AppRole, AppRefreshToken; JWT
106 +│ ├── Trips/ ← Trip, Participant, Invitation, Poll, Wishlist, BudgetCategory
107 +│ └── Expenses/ ← Expense, ExpenseSplit, SettlementPlan/Payment, Currency, SplitPreset
108 +└── tests/
109 + ├── SplitApp.Modules.Users.Tests/
110 + ├── SplitApp.Modules.Trips.Tests/
111 + ├── SplitApp.Modules.Expenses.Tests/
112 + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke
113 +```
114 +
115 +Each module = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` for REST). Each module owns its `DbContext` scoped to its own Postgres schema.
116 +
117 +---
118 +
119 +## URL map
120 +
121 +| URL | Purpose |
122 +|---|---|
123 +| `/` | Landing page (MVC) |
124 +| `/Trips`, `/Trips/{Create,Details/{id},Edit/{id},Delete/{id}}` | Trip CRUD |
125 +| `/Members?tripId={id}` and `/Members/AcceptInvitation/{token}` | Trip participants + invitation flow |
126 +| `/Expenses?tripId={id}` (with Create/Edit/Delete) | Trip expenses |
127 +| `/Budget?tripId={id}` (CreateCategory/EditCategory/DeleteCategory) | Budget categories |
128 +| `/Settlement?tripId={id}` | Balances + settlement plans |
129 +| `/PollsClient?tripId={id}` (Create/Details) | Trip polls |
130 +| `/WishlistClient?tripId={id}` | Trip wishlist |
131 +| `/Identity/Account/Register` | Cookie register |
132 +| `/Admin/Dashboard` | Admin home (`admin` role) |
133 +| `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD per entity |
134 +| `/swagger` | Swagger UI listing every module's REST endpoints |
135 +
136 +### REST API
137 +
138 +| Module | Endpoints |
139 +|---|---|
140 +| Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` |
141 +| Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` |
142 +| Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` |
143 +
144 +All API endpoints require JWT bearer auth except `account/register` and `account/login`.
145 +
146 +---
147 +
148 +## Inter-module communication
149 +
150 +All cross-module calls go through MediatR. Contracts in `Shared.Contracts/<Module>/{Queries|Events|Commands}/`; handlers in the **owning** module.
151 +
152 +| Contract | Owner | Purpose |
153 +|---|---|---|
154 +| `GetUserByIdQuery → UserDto?` | Users | Display name lookup |
155 +| `GetUsersByIdsQuery → IReadOnlyList<UserDto>` | Users | Batch lookup |
156 +| `UserDeletedEvent` | Users | Trips + Expenses subscribe to clean up rows |
157 +| `GetTripByIdQuery → TripSummaryDto?` | Trips | Cross-module trip lookup |
158 +| `GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto>` | Trips | |
159 +| `IsTripParticipantQuery → bool` | Trips | **IDOR guard** in `ExpensesController` |
160 +| `TripDeletedEvent` | Trips | Expenses subscribes to delete dependent expenses/settlements |
161 +| `GetTripExpenseTotalsQuery → TripExpenseTotalsDto` | Expenses | |
162 +| `GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal>` | Expenses | Per-budget-category spent totals |
163 +| `ExpenseSettledEvent` | Expenses | Reserved |
164 +| `SettlementPlanCompletedEvent` | Expenses | Trips advances "Finalizing" trips → "Settled" once every payment is confirmed |
165 +
166 +---
167 +
168 +## Tests
169 +
170 +```bash
171 +cd SplitApp.Modular
172 +dotnet test
173 +```
174 +
175 +**25 tests** across four projects:
176 +
177 +- Per-module unit tests — `CurrencyConverter` (5), `LangStr` (7), `IdentityHelpers` JWT round-trips (4)
178 +- Architecture invariants — `ModuleBoundaryTests`, `DbContextSchemaIsolationTests`, `CrossModuleNavigationTests`
179 +- Smoke — `WebApplicationFactory<Program>` boots the host, hits `/`, `/Home/Index`, expects 401 on unauthenticated API
180 +
181 +A failing architecture test = someone violated the modular monolith invariant.
182 +
183 +---
184 +
185 +## Phase 3 ↔ Phase 2 mapping
186 +
187 +Phase 3 lifts most of phase 2 unchanged:
188 +
189 +| Phase 2 | Phase 3 destination |
190 +|---|---|
191 +| `Base.Domain`, `Base.Contracts` | `Shared.Kernel` |
192 +| `Base.Helpers` (IdentityHelpers) | `Shared.Kernel.Auth` |
193 +| `App.Domain.Identity.*` | `Modules/Users/Domain/Entities/` |
194 +| `App.Domain.{Trip, TripParticipant, ...}` | `Modules/Trips/Domain/Entities/` |
195 +| `App.Domain.{Expense, SettlementPlan, ..., Currency}` | `Modules/Expenses/Domain/Entities/` |
196 +| `App.DAL.EF.AppDbContext` | split into 3 per-module `DbContext`s |
197 +| `App.BLL.Services.Identity.*` | `Modules/Users/Application/Services/` |
198 +| `App.BLL.Services.*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/` (composition-root facade over 3 module UoWs) |
199 +| `App.BLL.{DTO, Mappers}` | `WebApp/Application/{DTO, Mappers}` |
200 +| `WebApp.ApiControllers.{Identity, Trips, Expenses, ...}` | split per module → `Modules/X/Api/Controllers/` |
201 +| `WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` | preserved structurally; namespace re-rooted to `SplitApp.WebApp.*` |
202 +
203 +The phase-2 BLL is "lifted" into `WebApp/Application/` rather than rewritten — this keeps the full UX surface (101 Razor views, 21 MVC + Admin controllers, 10 REST controllers, Identity Razor pages) byte-identical to phase 2 while the inter-module boundaries are enforced cleanly via MediatR + per-module DbContexts.
204 +
205 +---
206 +
207 +## Files & docs
208 +
209 +- [explanation.md](explanation.md) — Phase 3 walkthrough (Estonian)
210 +- [arhitektuur.md](arhitektuur.md) — Phase 3 architecture diagrams + rules (Estonian)
211 +- [architecture.md](architecture.md) — Phase 3 architecture deep-dive (English)
212 +- [modularmonolith.md](modularmonolith.md) — Course material on the modular monolith pattern
213 +- [phase3.md](phase3.md) — The original assignment text
214 +- [SplitApp.Modular/README.md](SplitApp.Modular/README.md) — Module-level README
215 +- [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) — Per-module architecture details
added SplitApp.Modular/Directory.Build.props +9 −0
@@ -0,0 +1,9 @@
1 +<Project>
2 + <PropertyGroup>
3 + <TargetFramework>net10.0</TargetFramework>
4 + <LangVersion>latest</LangVersion>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + <WarningsAsErrors>CS8600,CS8602,CS8603,CS8613,CS8618,CS8625</WarningsAsErrors>
8 + </PropertyGroup>
9 +</Project>
added SplitApp.Modular/README.md +96 −0
@@ -0,0 +1,96 @@
1 +# SplitApp — Phase 3 Modular Monolith
2 +
3 +ASP.NET Core 10 modular monolith for splitting trip expenses among participants. Three modules (Users, Trips, Expenses) communicating via MediatR; one deployable; per-module Postgres schemas. Full phase-2 UI surface preserved (101 Razor views, 21 MVC + Admin controllers, identity Razor pages, 10 REST API controllers).
4 +
5 +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for layout, reference rules, contracts, per-module migration commands, and the `[NotMapped]` cross-module navigation caveat.
6 +
7 +## Run
8 +
9 +### With Docker (recommended)
10 +
11 +From the repo root (one level up from this directory):
12 +
13 +```bash
14 +docker compose up --build
15 +```
16 +
17 +**Production deployment:** https://travel.rasmusj.com/
18 +
19 +Locally, phase 3 listens on **http://localhost:90** (host port `90` → container port `8080`). Each module's migrations are applied automatically on startup. The container is named `phase3`. Postgres uses dedicated schemas `users` / `trips` / `expenses` inside the same `splitapp` database (DB is internal-only — not exposed to host).
20 +
21 +The single `Dockerfile` lives at the repo root and copies from `SplitApp.Modular/`. There is no separate Dockerfile inside this directory — the root one is canonical.
22 +
23 +### Without Docker
24 +
25 +```bash
26 +docker compose up -d db
27 +dotnet run --project src/SplitApp.WebApp
28 +```
29 +
30 +By default `dotnet run` uses `https://localhost:7133` / `http://localhost:5297` (see `Properties/launchSettings.json`).
31 +
32 +## URL map
33 +
34 +| URL | Purpose |
35 +|-----|---------|
36 +| `/` | Landing page (MVC) |
37 +| `/Trips`, `/Trips/Create`, `/Trips/Details/{id}`, `/Trips/Edit/{id}`, `/Trips/Delete/{id}` | Trip CRUD |
38 +| `/Members?tripId={id}` and `/Members/AcceptInvitation/{token}` | Trip participants + invitation flow |
39 +| `/Expenses?tripId={id}` (with Create/Edit/Delete) | Trip expenses |
40 +| `/Budget?tripId={id}` (with CreateCategory/EditCategory/DeleteCategory) | Budget categories per trip |
41 +| `/Settlement?tripId={id}` | Balances + settlement plans |
42 +| `/PollsClient?tripId={id}` (Create/Details) | Trip polls |
43 +| `/WishlistClient?tripId={id}` (Create/Edit/Delete) | Trip wishlist |
44 +| `/Identity/Account/Register` | Cookie-based register |
45 +| `/Admin/Dashboard` | Admin home (`admin` role) |
46 +| `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD over each entity |
47 +| `/swagger` | Swagger UI listing every module's REST endpoints |
48 +
49 +## REST API
50 +
51 +| Module | Endpoints |
52 +|--------|-----------|
53 +| Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` |
54 +| Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` |
55 +| Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` |
56 +
57 +## Tests
58 +
59 +```bash
60 +dotnet test
61 +```
62 +
63 +**25 tests** across four projects:
64 +
65 +- **Per-module unit tests** — `CurrencyConverter` (5), `LangStr` (7), `IdentityHelpers` JWT round-trips (4)
66 +- **Architecture invariants** (in `tests/SplitApp.WebApp.IntegrationTests/Architecture/`):
67 + - `ModuleBoundaryTests` — no module's `Application`/`Infrastructure`/`Api` may `<ProjectReference>` another module
68 + - `DbContextSchemaIsolationTests` — every `DbContext` exposes `DbSet<T>` only for entities in its own `Domain` project
69 + - `CrossModuleNavigationTests` — cross-module navigation properties allowed only when `[NotMapped]`; the WebApp facade hydrates them in-memory after loading from the owning module's DbContext, so EF never crosses schemas
70 +- **Smoke** — `WebApplicationFactory<Program>` boots the full host in `Testing` env (skipping migrations) and serves `/`, `/Home/Index`, returns 401 on unauthenticated API hit
71 +
72 +A failing architecture test means a developer just violated the modular-monolith invariant.
73 +
74 +## Module owners
75 +
76 +| Module | Owns | Schema |
77 +|--------|------|--------|
78 +| Users | Identity, JWT issuance, refresh tokens, user profile | `users` |
79 +| Trips | Trips, participants, invitations, polls, wishlists, budget categories | `trips` |
80 +| Expenses | Expenses, splits, settlement plans, settlement payments, split presets, currencies | `expenses` |
81 +
82 +## Caveat — `[NotMapped]` cross-module navigation properties
83 +
84 +To preserve phase 2's view-rendering parity (BLL DTO factories that read `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.) the entity classes still declare those navigation properties — annotated `[NotMapped]` so EF never crosses schemas. The `CrossModuleNavigationLoader` in `src/SplitApp.WebApp/Application/Persistence/` populates them in C# after entity load by querying the appropriate module's DbContext.
85 +
86 +Keeping the property *types* on the entities required adding `<ProjectReference>` between Domain projects:
87 +
88 +```
89 +Modules/Trips/SplitApp.Modules.Trips.Domain
90 + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
91 + → Modules/Expenses/SplitApp.Modules.Expenses.Domain (for Currency refs)
92 +Modules/Expenses/SplitApp.Modules.Expenses.Domain
93 + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
94 +```
95 +
96 +This bends the strict "no direct references between modules" rule from `phase3.md` at the **Domain** level. **`Application`, `Infrastructure`, and `Api` projects remain isolated** — they never `<ProjectReference>` another module — and continue to use MediatR for actual function calls. Schema isolation, MediatR-only inter-module communication, and per-module DbContext ownership are all preserved at runtime; only the entity *type system* is shared.
added SplitApp.Modular/SplitApp.sln +326 −0
@@ -0,0 +1,326 @@
1 +
2 +Microsoft Visual Studio Solution File, Format Version 12.00
3 +# Visual Studio Version 17
4 +VisualStudioVersion = 17.0.31903.59
5 +MinimumVisualStudioVersion = 10.0.40219.1
6 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
7 +EndProject
8 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.WebApp", "src\SplitApp.WebApp\SplitApp.WebApp.csproj", "{634BAEEE-802C-4568-98D6-50FCD86372BE}"
9 +EndProject
10 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{C8E42992-5E42-0C2B-DBFE-AA848D06431C}"
11 +EndProject
12 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Shared.Kernel", "src\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj", "{22C9586A-C6D7-4102-A474-69279D977486}"
13 +EndProject
14 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Shared.Contracts", "src\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj", "{15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}"
15 +EndProject
16 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
17 +EndProject
18 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Users", "Users", "{A26F344D-182E-CE53-AD51-2154946AC6F3}"
19 +EndProject
20 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Domain", "src\Modules\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj", "{5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}"
21 +EndProject
22 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Application", "src\Modules\Users\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj", "{F356134B-BF21-4687-9BD8-342C40AD4B9D}"
23 +EndProject
24 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Infrastructure", "src\Modules\Users\SplitApp.Modules.Users.Infrastructure\SplitApp.Modules.Users.Infrastructure.csproj", "{E316CB96-ACE5-42EB-B8EC-95F22DDE2251}"
25 +EndProject
26 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Api", "src\Modules\Users\SplitApp.Modules.Users.Api\SplitApp.Modules.Users.Api.csproj", "{3B01763E-85E1-4F42-9A1C-6B0240810EA3}"
27 +EndProject
28 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
29 +EndProject
30 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Tests", "tests\SplitApp.Modules.Users.Tests\SplitApp.Modules.Users.Tests.csproj", "{62DDD903-B3D7-4140-B200-D489CE7A567D}"
31 +EndProject
32 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Trips", "Trips", "{869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}"
33 +EndProject
34 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Domain", "src\Modules\Trips\SplitApp.Modules.Trips.Domain\SplitApp.Modules.Trips.Domain.csproj", "{B2A31D85-D7B1-4476-819B-7A413A875DA6}"
35 +EndProject
36 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Application", "src\Modules\Trips\SplitApp.Modules.Trips.Application\SplitApp.Modules.Trips.Application.csproj", "{7E0445E0-A676-4892-89C6-85C1738C1A49}"
37 +EndProject
38 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Infrastructure", "src\Modules\Trips\SplitApp.Modules.Trips.Infrastructure\SplitApp.Modules.Trips.Infrastructure.csproj", "{271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}"
39 +EndProject
40 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Api", "src\Modules\Trips\SplitApp.Modules.Trips.Api\SplitApp.Modules.Trips.Api.csproj", "{E0619779-5956-4D74-B683-BFDECBC89710}"
41 +EndProject
42 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Tests", "tests\SplitApp.Modules.Trips.Tests\SplitApp.Modules.Trips.Tests.csproj", "{B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}"
43 +EndProject
44 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Expenses", "Expenses", "{69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}"
45 +EndProject
46 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Domain", "src\Modules\Expenses\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj", "{49DB325B-DE26-429F-AF4A-A0F6D56B854C}"
47 +EndProject
48 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Application", "src\Modules\Expenses\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj", "{0EF6B704-D7B3-4839-B811-753BAD35568F}"
49 +EndProject
50 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Infrastructure", "src\Modules\Expenses\SplitApp.Modules.Expenses.Infrastructure\SplitApp.Modules.Expenses.Infrastructure.csproj", "{8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}"
51 +EndProject
52 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Api", "src\Modules\Expenses\SplitApp.Modules.Expenses.Api\SplitApp.Modules.Expenses.Api.csproj", "{540AF573-0915-42A5-B616-F6395ED13AFA}"
53 +EndProject
54 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Tests", "tests\SplitApp.Modules.Expenses.Tests\SplitApp.Modules.Expenses.Tests.csproj", "{037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}"
55 +EndProject
56 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.WebApp.IntegrationTests", "tests\SplitApp.WebApp.IntegrationTests\SplitApp.WebApp.IntegrationTests.csproj", "{4B0B55CE-A512-4C2F-BB69-A89A348B83AB}"
57 +EndProject
58 +Global
59 + GlobalSection(SolutionConfigurationPlatforms) = preSolution
60 + Debug|Any CPU = Debug|Any CPU
61 + Debug|x64 = Debug|x64
62 + Debug|x86 = Debug|x86
63 + Release|Any CPU = Release|Any CPU
64 + Release|x64 = Release|x64
65 + Release|x86 = Release|x86
66 + EndGlobalSection
67 + GlobalSection(ProjectConfigurationPlatforms) = postSolution
68 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
69 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
70 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x64.ActiveCfg = Debug|Any CPU
71 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x64.Build.0 = Debug|Any CPU
72 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x86.ActiveCfg = Debug|Any CPU
73 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x86.Build.0 = Debug|Any CPU
74 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
75 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|Any CPU.Build.0 = Release|Any CPU
76 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x64.ActiveCfg = Release|Any CPU
77 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x64.Build.0 = Release|Any CPU
78 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x86.ActiveCfg = Release|Any CPU
79 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x86.Build.0 = Release|Any CPU
80 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
81 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|Any CPU.Build.0 = Debug|Any CPU
82 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x64.ActiveCfg = Debug|Any CPU
83 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x64.Build.0 = Debug|Any CPU
84 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x86.ActiveCfg = Debug|Any CPU
85 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x86.Build.0 = Debug|Any CPU
86 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|Any CPU.ActiveCfg = Release|Any CPU
87 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|Any CPU.Build.0 = Release|Any CPU
88 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x64.ActiveCfg = Release|Any CPU
89 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x64.Build.0 = Release|Any CPU
90 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x86.ActiveCfg = Release|Any CPU
91 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x86.Build.0 = Release|Any CPU
92 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
93 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
94 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x64.ActiveCfg = Debug|Any CPU
95 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x64.Build.0 = Debug|Any CPU
96 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x86.ActiveCfg = Debug|Any CPU
97 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x86.Build.0 = Debug|Any CPU
98 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
99 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|Any CPU.Build.0 = Release|Any CPU
100 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x64.ActiveCfg = Release|Any CPU
101 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x64.Build.0 = Release|Any CPU
102 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x86.ActiveCfg = Release|Any CPU
103 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x86.Build.0 = Release|Any CPU
104 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
105 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
106 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x64.ActiveCfg = Debug|Any CPU
107 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x64.Build.0 = Debug|Any CPU
108 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x86.ActiveCfg = Debug|Any CPU
109 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x86.Build.0 = Debug|Any CPU
110 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
111 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|Any CPU.Build.0 = Release|Any CPU
112 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x64.ActiveCfg = Release|Any CPU
113 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x64.Build.0 = Release|Any CPU
114 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x86.ActiveCfg = Release|Any CPU
115 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x86.Build.0 = Release|Any CPU
116 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
117 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
118 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x64.ActiveCfg = Debug|Any CPU
119 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x64.Build.0 = Debug|Any CPU
120 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x86.ActiveCfg = Debug|Any CPU
121 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x86.Build.0 = Debug|Any CPU
122 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
123 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|Any CPU.Build.0 = Release|Any CPU
124 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x64.ActiveCfg = Release|Any CPU
125 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x64.Build.0 = Release|Any CPU
126 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x86.ActiveCfg = Release|Any CPU
127 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x86.Build.0 = Release|Any CPU
128 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
129 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|Any CPU.Build.0 = Debug|Any CPU
130 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x64.ActiveCfg = Debug|Any CPU
131 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x64.Build.0 = Debug|Any CPU
132 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x86.ActiveCfg = Debug|Any CPU
133 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x86.Build.0 = Debug|Any CPU
134 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|Any CPU.ActiveCfg = Release|Any CPU
135 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|Any CPU.Build.0 = Release|Any CPU
136 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x64.ActiveCfg = Release|Any CPU
137 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x64.Build.0 = Release|Any CPU
138 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x86.ActiveCfg = Release|Any CPU
139 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x86.Build.0 = Release|Any CPU
140 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
141 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
142 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x64.ActiveCfg = Debug|Any CPU
143 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x64.Build.0 = Debug|Any CPU
144 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x86.ActiveCfg = Debug|Any CPU
145 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x86.Build.0 = Debug|Any CPU
146 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
147 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|Any CPU.Build.0 = Release|Any CPU
148 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x64.ActiveCfg = Release|Any CPU
149 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x64.Build.0 = Release|Any CPU
150 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x86.ActiveCfg = Release|Any CPU
151 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x86.Build.0 = Release|Any CPU
152 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
153 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|Any CPU.Build.0 = Debug|Any CPU
154 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x64.ActiveCfg = Debug|Any CPU
155 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x64.Build.0 = Debug|Any CPU
156 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x86.ActiveCfg = Debug|Any CPU
157 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x86.Build.0 = Debug|Any CPU
158 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|Any CPU.ActiveCfg = Release|Any CPU
159 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|Any CPU.Build.0 = Release|Any CPU
160 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x64.ActiveCfg = Release|Any CPU
161 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x64.Build.0 = Release|Any CPU
162 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x86.ActiveCfg = Release|Any CPU
163 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x86.Build.0 = Release|Any CPU
164 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
165 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
166 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x64.ActiveCfg = Debug|Any CPU
167 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x64.Build.0 = Debug|Any CPU
168 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x86.ActiveCfg = Debug|Any CPU
169 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x86.Build.0 = Debug|Any CPU
170 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
171 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|Any CPU.Build.0 = Release|Any CPU
172 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x64.ActiveCfg = Release|Any CPU
173 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x64.Build.0 = Release|Any CPU
174 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x86.ActiveCfg = Release|Any CPU
175 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x86.Build.0 = Release|Any CPU
176 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
177 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|Any CPU.Build.0 = Debug|Any CPU
178 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x64.ActiveCfg = Debug|Any CPU
179 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x64.Build.0 = Debug|Any CPU
180 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x86.ActiveCfg = Debug|Any CPU
181 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x86.Build.0 = Debug|Any CPU
182 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|Any CPU.ActiveCfg = Release|Any CPU
183 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|Any CPU.Build.0 = Release|Any CPU
184 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x64.ActiveCfg = Release|Any CPU
185 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x64.Build.0 = Release|Any CPU
186 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x86.ActiveCfg = Release|Any CPU
187 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x86.Build.0 = Release|Any CPU
188 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
189 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
190 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x64.ActiveCfg = Debug|Any CPU
191 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x64.Build.0 = Debug|Any CPU
192 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x86.ActiveCfg = Debug|Any CPU
193 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x86.Build.0 = Debug|Any CPU
194 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
195 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|Any CPU.Build.0 = Release|Any CPU
196 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x64.ActiveCfg = Release|Any CPU
197 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x64.Build.0 = Release|Any CPU
198 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x86.ActiveCfg = Release|Any CPU
199 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x86.Build.0 = Release|Any CPU
200 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
201 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|Any CPU.Build.0 = Debug|Any CPU
202 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x64.ActiveCfg = Debug|Any CPU
203 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x64.Build.0 = Debug|Any CPU
204 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x86.ActiveCfg = Debug|Any CPU
205 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x86.Build.0 = Debug|Any CPU
206 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|Any CPU.ActiveCfg = Release|Any CPU
207 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|Any CPU.Build.0 = Release|Any CPU
208 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x64.ActiveCfg = Release|Any CPU
209 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x64.Build.0 = Release|Any CPU
210 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x86.ActiveCfg = Release|Any CPU
211 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x86.Build.0 = Release|Any CPU
212 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
213 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
214 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x64.ActiveCfg = Debug|Any CPU
215 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x64.Build.0 = Debug|Any CPU
216 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x86.ActiveCfg = Debug|Any CPU
217 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x86.Build.0 = Debug|Any CPU
218 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
219 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|Any CPU.Build.0 = Release|Any CPU
220 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x64.ActiveCfg = Release|Any CPU
221 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x64.Build.0 = Release|Any CPU
222 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x86.ActiveCfg = Release|Any CPU
223 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x86.Build.0 = Release|Any CPU
224 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
225 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|Any CPU.Build.0 = Debug|Any CPU
226 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x64.ActiveCfg = Debug|Any CPU
227 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x64.Build.0 = Debug|Any CPU
228 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x86.ActiveCfg = Debug|Any CPU
229 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x86.Build.0 = Debug|Any CPU
230 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|Any CPU.ActiveCfg = Release|Any CPU
231 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|Any CPU.Build.0 = Release|Any CPU
232 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x64.ActiveCfg = Release|Any CPU
233 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x64.Build.0 = Release|Any CPU
234 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x86.ActiveCfg = Release|Any CPU
235 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x86.Build.0 = Release|Any CPU
236 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
237 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|Any CPU.Build.0 = Debug|Any CPU
238 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x64.ActiveCfg = Debug|Any CPU
239 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x64.Build.0 = Debug|Any CPU
240 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x86.ActiveCfg = Debug|Any CPU
241 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x86.Build.0 = Debug|Any CPU
242 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|Any CPU.ActiveCfg = Release|Any CPU
243 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|Any CPU.Build.0 = Release|Any CPU
244 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x64.ActiveCfg = Release|Any CPU
245 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x64.Build.0 = Release|Any CPU
246 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x86.ActiveCfg = Release|Any CPU
247 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x86.Build.0 = Release|Any CPU
248 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
249 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
250 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x64.ActiveCfg = Debug|Any CPU
251 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x64.Build.0 = Debug|Any CPU
252 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x86.ActiveCfg = Debug|Any CPU
253 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x86.Build.0 = Debug|Any CPU
254 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
255 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|Any CPU.Build.0 = Release|Any CPU
256 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x64.ActiveCfg = Release|Any CPU
257 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x64.Build.0 = Release|Any CPU
258 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x86.ActiveCfg = Release|Any CPU
259 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x86.Build.0 = Release|Any CPU
260 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
261 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|Any CPU.Build.0 = Debug|Any CPU
262 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x64.ActiveCfg = Debug|Any CPU
263 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x64.Build.0 = Debug|Any CPU
264 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x86.ActiveCfg = Debug|Any CPU
265 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x86.Build.0 = Debug|Any CPU
266 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|Any CPU.ActiveCfg = Release|Any CPU
267 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|Any CPU.Build.0 = Release|Any CPU
268 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x64.ActiveCfg = Release|Any CPU
269 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x64.Build.0 = Release|Any CPU
270 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x86.ActiveCfg = Release|Any CPU
271 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x86.Build.0 = Release|Any CPU
272 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
273 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
274 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x64.ActiveCfg = Debug|Any CPU
275 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x64.Build.0 = Debug|Any CPU
276 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x86.ActiveCfg = Debug|Any CPU
277 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x86.Build.0 = Debug|Any CPU
278 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
279 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|Any CPU.Build.0 = Release|Any CPU
280 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x64.ActiveCfg = Release|Any CPU
281 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x64.Build.0 = Release|Any CPU
282 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x86.ActiveCfg = Release|Any CPU
283 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x86.Build.0 = Release|Any CPU
284 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
285 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
286 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x64.ActiveCfg = Debug|Any CPU
287 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x64.Build.0 = Debug|Any CPU
288 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x86.ActiveCfg = Debug|Any CPU
289 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x86.Build.0 = Debug|Any CPU
290 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
291 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|Any CPU.Build.0 = Release|Any CPU
292 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x64.ActiveCfg = Release|Any CPU
293 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x64.Build.0 = Release|Any CPU
294 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x86.ActiveCfg = Release|Any CPU
295 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x86.Build.0 = Release|Any CPU
296 + EndGlobalSection
297 + GlobalSection(SolutionProperties) = preSolution
298 + HideSolutionNode = FALSE
299 + EndGlobalSection
300 + GlobalSection(NestedProjects) = preSolution
301 + {634BAEEE-802C-4568-98D6-50FCD86372BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
302 + {C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
303 + {22C9586A-C6D7-4102-A474-69279D977486} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
304 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
305 + {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
306 + {A26F344D-182E-CE53-AD51-2154946AC6F3} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
307 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
308 + {F356134B-BF21-4687-9BD8-342C40AD4B9D} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
309 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
310 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
311 + {62DDD903-B3D7-4140-B200-D489CE7A567D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
312 + {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
313 + {B2A31D85-D7B1-4476-819B-7A413A875DA6} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
314 + {7E0445E0-A676-4892-89C6-85C1738C1A49} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
315 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
316 + {E0619779-5956-4D74-B683-BFDECBC89710} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
317 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
318 + {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
319 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
320 + {0EF6B704-D7B3-4839-B811-753BAD35568F} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
321 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
322 + {540AF573-0915-42A5-B616-F6395ED13AFA} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
323 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
324 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
325 + EndGlobalSection
326 +EndGlobal
added SplitApp.Modular/docs/ARCHITECTURE.md +191 −0
@@ -0,0 +1,191 @@
1 +# SplitApp — Modular Monolith (Phase 3)
2 +
3 +This is the phase-3 refactor of SplitApp from a Clean/Onion monolith (phase 2) to a modular monolith. One deployable, three internally isolated modules, MediatR for cross-module communication.
4 +
5 +## Deployment
6 +
7 +**Production:** https://travel.rasmusj.com/
8 +
9 +The repo's root `docker-compose.yml` + `Dockerfile` build phase 3 locally:
10 +
11 +| Service | Container | Builds from | Host port | Notes |
12 +|---|---|---|---|---|
13 +| `phase3` | `phase3` | `./Dockerfile` (root) | **`90`** | Phase 3 — modular monolith |
14 +| `db` | `phase3-db` | `postgres:16` | (internal only) | PostgreSQL with schemas `users` / `trips` / `expenses` — not exposed to host |
15 +
16 +Bring everything up:
17 +
18 +```bash
19 +docker compose up --build
20 +```
21 +
22 +The container exposes `http://localhost:90` (host port `90` → container port `8080`). Per-module migrations run automatically on startup.
23 +
24 +## Solution layout
25 +
26 +```
27 +SplitApp.sln
28 +├── src/
29 +│ ├── SplitApp.WebApp/ // composition root + admin Area + host
30 +│ ├── Shared/
31 +│ │ ├── SplitApp.Shared.Kernel/ // BaseEntity, IBaseRepository, IUnitOfWork, LangStr, IdentityHelpers
32 +│ │ └── SplitApp.Shared.Contracts/ // MediatR IRequest / INotification contracts
33 +│ └── Modules/
34 +│ ├── Users/
35 +│ │ ├── SplitApp.Modules.Users.Domain/ // AppUser, AppRole, AppRefreshToken
36 +│ │ ├── SplitApp.Modules.Users.Application/ // IIdentityService + JWT/refresh logic, MediatR handlers
37 +│ │ ├── SplitApp.Modules.Users.Infrastructure/ // UsersDbContext (schema "users"), repos, AddUsersModule
38 +│ │ └── SplitApp.Modules.Users.Api/ // /api/v1/identity/... controllers + DTOs
39 +│ ├── Trips/ // same 4-project layout, schema "trips"
40 +│ └── Expenses/ // same 4-project layout, schema "expenses"
41 +└── tests/
42 + ├── SplitApp.Modules.Users.Tests/
43 + ├── SplitApp.Modules.Trips.Tests/
44 + ├── SplitApp.Modules.Expenses.Tests/
45 + └── SplitApp.WebApp.IntegrationTests/ // architecture + integration tests
46 +```
47 +
48 +## The reference rules
49 +
50 +Compiler-enforced via `<ProjectReference>` graph and verified by `tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs`:
51 +
52 +- A module's `Application` / `Infrastructure` / `Api` project may reference: another project inside the **same** module, plus `Shared.Kernel` and `Shared.Contracts`. **Nothing else.**
53 +- Inter-module function calls go through **MediatR only** — never via a direct `<ProjectReference>` to another module's services or repositories.
54 +- `Shared.*` projects may not reference any module.
55 +- `WebApp` is the only project that references all three modules' `Api` and `Infrastructure` projects.
56 +
57 +**Caveat at the Domain level only:** to keep phase 2's view-rendering parity (mappers that read `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.), the entity classes still declare those navigation properties — annotated `[NotMapped]` so EF never crosses schemas. Keeping the property *types* on the entities required adding three Domain-to-Domain `<ProjectReference>`s:
58 +
59 +```
60 +Modules/Trips/SplitApp.Modules.Trips.Domain
61 + → Modules/Users/SplitApp.Modules.Users.Domain
62 + → Modules/Expenses/SplitApp.Modules.Expenses.Domain
63 +Modules/Expenses/SplitApp.Modules.Expenses.Domain
64 + → Modules/Users/SplitApp.Modules.Users.Domain
65 +```
66 +
67 +This bends the strict "no direct references between modules" rule from `phase3.md` at the Domain level. `Application`, `Infrastructure`, and `Api` projects remain isolated and use MediatR for actual function calls — only entity *types* are shared. The `CrossModuleNavigationTests` invariant enforces that any cross-module navigation is `[NotMapped]`; a plain mapped nav across schemas would fail the build.
68 +
69 +## Inter-module communication
70 +
71 +All cross-module calls go through MediatR. Contracts live in `SplitApp.Shared.Contracts/<Module>/{Queries|Events}/` and are records implementing `IRequest<T>` (sync queries/commands) or `INotification` (fan-out events). Handlers live in the **owning** module's `Application` or `Infrastructure` layer.
72 +
73 +Currently shipped contracts:
74 +
75 +| Contract | Owner module | Notes |
76 +|----------|--------------|-------|
77 +| `GetUserByIdQuery : IRequest<UserDto?>` | Users | Used by Trips/Expenses for display-name lookup |
78 +| `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Batch lookup |
79 +| `UserDeletedEvent : INotification` | Users | Trips + Expenses subscribe to clean up rows |
80 +| `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module trip lookup |
81 +| `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | |
82 +| `IsTripParticipantQuery : IRequest<bool>` | Trips | Used by `ExpensesController` for IDOR + payer validation |
83 +| `TripDeletedEvent : INotification` | Trips | Expenses subscribes to delete dependent expenses/settlements |
84 +| `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | |
85 +| `GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>>` | Expenses | Per-budget-category spent totals — used by Trips' BudgetCategoriesController |
86 +| `ExpenseSettledEvent : INotification` | Expenses | Reserved for future use |
87 +| `SettlementPlanCompletedEvent : INotification` | Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed |
88 +
89 +## Data isolation
90 +
91 +Each module owns its own `DbContext`:
92 +
93 +- `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users`
94 +- `TripsDbContext : DbContext` → schema `trips`
95 +- `ExpensesDbContext : DbContext` → schema `expenses`
96 +
97 +All three connect to the **same** physical Postgres database via the same `ConnectionStrings:DefaultConnection`. Schemas — not separate databases — provide isolation. **Cross-module SQL joins are forbidden**; cross-module data is composed at the application layer via MediatR.
98 +
99 +Cross-module entity references are bare `Guid` fields with **no** navigation properties and **no** EF foreign-key constraints (e.g. `Trip.CreatedById : Guid` references `users.AspNetUsers.Id` only conceptually, not via `FOREIGN KEY`). Referential integrity is maintained by:
100 +
101 +- Up-front MediatR validation queries (e.g. `IsTripParticipantQuery` before persisting an expense split).
102 +- Domain-event cleanup on delete (`UserDeletedEvent`, `TripDeletedEvent`).
103 +
104 +## Per-module migrations
105 +
106 +Each module ships its own EF migration history table inside its own schema. Generate new migrations against the module-specific DbContext + project, with `WebApp` as the startup project (so the connection string is read from `appsettings.json`):
107 +
108 +```bash
109 +# Users
110 +dotnet ef migrations add <Name> -c UsersDbContext \
111 + -p src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj \
112 + -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
113 + -o Persistence/Migrations
114 +
115 +# Trips
116 +dotnet ef migrations add <Name> -c TripsDbContext \
117 + -p src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj \
118 + -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
119 + -o Persistence/Migrations
120 +
121 +# Expenses
122 +dotnet ef migrations add <Name> -c ExpensesDbContext \
123 + -p src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj \
124 + -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
125 + -o Persistence/Migrations
126 +```
127 +
128 +Migrations are applied automatically on startup by each module's `UseXxxModule(IApplicationBuilder)` extension (called from `Program.cs`).
129 +
130 +## Composition root
131 +
132 +`SplitApp.WebApp/Program.cs` is the only place that sees all three modules. It:
133 +
134 +1. Configures cross-cutting host concerns (MVC, API versioning via `Asp.Versioning`, Swagger, authorization).
135 +2. Calls `services.AddUsersModule(...)`, `services.AddTripsModule(...)`, `services.AddExpensesModule(...)` — each module's extension wires its own `DbContext`, repositories, services, and MediatR handlers.
136 +3. Registers controllers from each module's `Api` assembly via `AddApplicationPart(...)`.
137 +4. Calls `app.UseUsersModule()`, `app.UseTripsModule()`, `app.UseExpensesModule()` — each applies its module's pending migrations.
138 +
139 +The composition root does **not** call `AddDbContext<...>` directly, does not register any module-internal repository/service, and does not scan module assemblies for MediatR. Each module owns its own registration.
140 +
141 +## Phase 2 → Phase 3 mapping
142 +
143 +| Phase 2 project | Phase 3 destination |
144 +|-----------------|---------------------|
145 +| `Base.Domain` (`BaseEntity`, `LangStr`) | `SplitApp.Shared.Kernel` |
146 +| `Base.Contracts` (`IBaseEntity`, `IBaseRepository`, `IUnitOfWork`) | `SplitApp.Shared.Kernel` |
147 +| `Base.Helpers` (`IdentityHelpers`) | `SplitApp.Shared.Kernel.Auth` |
148 +| `App.Domain.Identity.*` | `SplitApp.Modules.Users.Domain.Entities` |
149 +| `App.Domain.{Trip,TripParticipant,...}` | `SplitApp.Modules.Trips.Domain.Entities` |
150 +| `App.Domain.{Expense,SettlementPlan,...,Currency}` | `SplitApp.Modules.Expenses.Domain.Entities` |
151 +| `App.DAL.EF.AppDbContext` | Split into 3 `XxxDbContext` per module |
152 +| `App.BLL.Services.Identity.*` | `SplitApp.Modules.Users.Application.Services` |
153 +| `WebApp.ApiControllers.Identity.*` | `SplitApp.Modules.Users.Api.Controllers` |
154 +| `WebApp.ApiControllers.{TripsController,...}` | `SplitApp.Modules.Trips.Api.Controllers` |
155 +| `WebApp.ApiControllers.{ExpensesController,...}` | `SplitApp.Modules.Expenses.Api.Controllers` |
156 +
157 +## Composition root: MVC + Razor + Admin
158 +
159 +The WebApp hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, identity Razor pages). It is the **only** place that sees all three modules. Phase 2 is preserved by lifting the BLL layer into [src/SplitApp.WebApp/Application/](../src/SplitApp.WebApp/Application/) under three buckets:
160 +
161 +- **DTO** — phase 2 BLL DTOs unchanged (POCOs; views model-bind to these)
162 +- **Mappers** — entity ↔ DTO factories, unchanged structurally; cross-module nav fields populate from `[NotMapped]` properties
163 +- **Services + Services/Admin + Services/Identity** — phase 2 BLL services unchanged; they consume `IAppUnitOfWork`
164 +
165 +[`Application/Persistence/AppUnitOfWork.cs`](../src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs) is the composition-root facade: it injects all three module DbContexts (`UsersDbContext`, `TripsDbContext`, `ExpensesDbContext`) and exposes them through phase 2's `IAppUnitOfWork` interface. Each repository routes to its module's DbContext — schemas stay isolated, but the host can compose across them.
166 +
167 +[`Application/Persistence/CrossModuleNavigationLoader.cs`](../src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs) hydrates the `[NotMapped]` cross-module nav properties (`Trip.DefaultCurrency`, `TripParticipant.User`, `Expense.PaidByUser`, etc.) after entities are loaded. EF never crosses schemas; the loader pulls cross-module data via a separate query against the appropriate module's DbContext.
168 +
169 +### MVC client controllers
170 +
171 +`Controllers/{Home, Trips, Expenses, Budget, Members, Settlement, PollsClient, WishlistClient}Controller` — the user-facing site. Cookie-authenticated.
172 +
173 +### Admin area
174 +
175 +`Areas/Admin/Controllers/{Dashboard, Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPayments, SettlementPlans, SplitPresets, TripParticipants, Wishlist}Controller` — admin CRUD per entity, gated on the `admin` role.
176 +
177 +### Identity Razor page
178 +
179 +`Areas/Identity/Pages/Account/Register` — cookie-based registration. JWT flows are handled by `Modules.Users.Api.AccountController`.
180 +
181 +Migrations are skipped when `ASPNETCORE_ENVIRONMENT=Testing` so `WebApplicationFactory<Program>` can boot without a real Postgres instance — see [`tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs`](../tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs).
182 +
183 +## Architecture tests
184 +
185 +[`tests/SplitApp.WebApp.IntegrationTests/Architecture/`](../tests/SplitApp.WebApp.IntegrationTests/Architecture/) runs as part of `dotnet test` and asserts:
186 +
187 +1. **`ModuleBoundaryTests`** — no module project has a `<ProjectReference>` to another module's Application/Infrastructure/Api project; no `Shared.*` project references a module.
188 +2. **`DbContextSchemaIsolationTests`** — each `DbContext` only exposes `DbSet<T>` for entities that live in its own Domain project (plus a tiny framework allowlist).
189 +3. **`CrossModuleNavigationTests`** — Domain entities may declare navigation properties to another module's entity type **only** when annotated `[NotMapped]`. EF never loads these — the WebApp facade hydrates them in-memory after fetching from the appropriate module's DbContext.
190 +
191 +A failing test means a developer just violated the modular-monolith invariant.
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor +3 −0
@@ -0,0 +1,3 @@
1 +<div class="my-component">
2 + This component is defined in the <strong>SplitApp.Modules.Expenses.Api</strong> library.
3 +</div>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor.css +6 −0
@@ -0,0 +1,6 @@
1 +.my-component {
2 + border: 2px dashed red;
3 + padding: 1em;
4 + margin: 1em 0;
5 + background-image: url('background.png');
6 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/CurrenciesController.cs +39 −0
@@ -0,0 +1,39 @@
1 +using Asp.Versioning;
2 +using Microsoft.AspNetCore.Authentication.JwtBearer;
3 +using Microsoft.AspNetCore.Authorization;
4 +using Microsoft.AspNetCore.Mvc;
5 +using SplitApp.Modules.Expenses.Api.Dto.v1;
6 +using SplitApp.Modules.Expenses.Application.Contracts;
7 +using SplitApp.Modules.Expenses.Domain.Entities;
8 +
9 +namespace SplitApp.Modules.Expenses.Api.Controllers;
10 +
11 +[ApiVersion("1.0")]
12 +[ApiController]
13 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
14 +[Route("api/v{version:apiVersion}/[controller]")]
15 +public class CurrenciesController : ControllerBase
16 +{
17 + private readonly IExpensesUnitOfWork _uow;
18 +
19 + public CurrenciesController(IExpensesUnitOfWork uow)
20 + {
21 + _uow = uow;
22 + }
23 +
24 + [HttpGet]
25 + [AllowAnonymous]
26 + public async Task<ActionResult<List<CurrencyDto>>> List()
27 + {
28 + var all = await _uow.Currencies.GetAllAsync();
29 + return Ok(all.Select(MapToDto).ToList());
30 + }
31 +
32 + private static CurrencyDto MapToDto(Currency c) => new()
33 + {
34 + Id = c.Id,
35 + Code = c.Code,
36 + Name = c.Name.Translate() ?? c.Code,
37 + Symbol = c.Symbol,
38 + };
39 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/ExpensesController.cs +278 −0
@@ -0,0 +1,278 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Expenses.Api.Dto.v1;
8 +using SplitApp.Modules.Expenses.Application;
9 +using SplitApp.Modules.Expenses.Application.Contracts;
10 +using SplitApp.Modules.Expenses.Domain.Entities;
11 +using SplitApp.Modules.Expenses.Domain.Enums;
12 +using SplitApp.Shared.Contracts.Trips.Queries;
13 +using SplitApp.Shared.Contracts.Users.Queries;
14 +
15 +namespace SplitApp.Modules.Expenses.Api.Controllers;
16 +
17 +[ApiVersion("1.0")]
18 +[ApiController]
19 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
20 +[Route("api/v{version:apiVersion}/[controller]")]
21 +public class ExpensesController : ControllerBase
22 +{
23 + private readonly IExpensesUnitOfWork _uow;
24 + private readonly IMediator _mediator;
25 +
26 + public ExpensesController(IExpensesUnitOfWork uow, IMediator mediator)
27 + {
28 + _uow = uow;
29 + _mediator = mediator;
30 + }
31 +
32 + // Phase-2 frontend compatibility: path-based GET /api/v1/Expenses/trip/{tripId}.
33 + // Query-form GET /api/v1/Expenses?tripId=... also works.
34 + [HttpGet("trip/{tripId:guid}")]
35 + [HttpGet]
36 + public async Task<ActionResult<IEnumerable<ExpenseDto>>> List([FromRoute] Guid? tripId, [FromQuery] Guid? tripIdQuery = null)
37 + {
38 + var userId = CurrentUserId();
39 + if (userId == null) return Unauthorized();
40 +
41 + var resolvedTripId = tripId ?? tripIdQuery ?? Guid.Empty;
42 + if (resolvedTripId == Guid.Empty
43 + && Request.Query.TryGetValue("tripId", out var v) && Guid.TryParse(v, out var parsed))
44 + {
45 + resolvedTripId = parsed;
46 + }
47 + if (resolvedTripId == Guid.Empty) return BadRequest(new { error = "tripId is required." });
48 +
49 + if (!await _mediator.Send(new IsTripParticipantQuery(resolvedTripId, userId.Value))) return Forbid();
50 +
51 + var all = await _uow.Expenses.GetAllAsync();
52 + var forTrip = all.Where(e => e.TripId == resolvedTripId).ToList();
53 + var dtos = await BuildExpenseDtosAsync(forTrip, resolvedTripId, includeSplits: true);
54 + return Ok(dtos);
55 + }
56 +
57 + [HttpGet("{id:guid}")]
58 + public async Task<ActionResult<ExpenseDto>> Get(Guid id)
59 + {
60 + var userId = CurrentUserId();
61 + if (userId == null) return Unauthorized();
62 +
63 + var expense = await _uow.Expenses.GetByIdAsync(id);
64 + if (expense == null) return NotFound();
65 + if (!await _mediator.Send(new IsTripParticipantQuery(expense.TripId, userId.Value))) return Forbid();
66 +
67 + var dtos = await BuildExpenseDtosAsync(new[] { expense }, expense.TripId, includeSplits: true);
68 + return Ok(dtos[0]);
69 + }
70 +
71 + [HttpPost]
72 + public async Task<ActionResult<ExpenseDto>> Create([FromBody] ExpenseCreateDto dto)
73 + {
74 + var userId = CurrentUserId();
75 + if (userId == null) return Unauthorized();
76 +
77 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, userId.Value))) return Forbid();
78 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, dto.PaidByUserId)))
79 + return BadRequest(new { error = "Payer is not a participant of this trip." });
80 +
81 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var split))
82 + return BadRequest(new { error = $"Unknown split method '{dto.SplitMethod}'." });
83 +
84 + foreach (var s in dto.Splits)
85 + {
86 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, s.UserId)))
87 + return BadRequest(new { error = $"User {s.UserId} is not a participant of this trip." });
88 + }
89 +
90 + var expense = new Expense
91 + {
92 + TripId = dto.TripId,
93 + PaidByUserId = dto.PaidByUserId,
94 + CurrencyId = dto.CurrencyId,
95 + BudgetCategoryId = dto.BudgetCategoryId,
96 + Amount = dto.Amount,
97 + Description = dto.Description,
98 + ExpenseDate = dto.ExpenseDate,
99 + SplitMethod = split,
100 + };
101 + _uow.Expenses.Add(expense);
102 +
103 + foreach (var s in dto.Splits)
104 + {
105 + _uow.ExpenseSplits.Add(new ExpenseSplit
106 + {
107 + ExpenseId = expense.Id,
108 + UserId = s.UserId,
109 + Amount = s.Amount,
110 + Percentage = s.Percentage,
111 + });
112 + }
113 + await _uow.SaveChangesAsync();
114 +
115 + var dtos = await BuildExpenseDtosAsync(new[] { expense }, expense.TripId, includeSplits: true);
116 + return CreatedAtAction(nameof(Get), new { id = expense.Id, version = "1.0" }, dtos[0]);
117 + }
118 +
119 + [HttpPut("{id:guid}")]
120 + public async Task<IActionResult> Update(Guid id, [FromBody] ExpenseCreateDto dto)
121 + {
122 + var userId = CurrentUserId();
123 + if (userId == null) return Unauthorized();
124 +
125 + var existing = await _uow.Expenses.GetByIdAsync(id);
126 + if (existing == null) return NotFound();
127 +
128 + if (!await _mediator.Send(new IsTripParticipantQuery(existing.TripId, userId.Value))) return Forbid();
129 +
130 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var split))
131 + return BadRequest(new { error = $"Unknown split method '{dto.SplitMethod}'." });
132 +
133 + existing.Amount = dto.Amount;
134 + existing.Description = dto.Description;
135 + existing.ExpenseDate = dto.ExpenseDate;
136 + existing.SplitMethod = split;
137 + existing.BudgetCategoryId = dto.BudgetCategoryId;
138 + existing.CurrencyId = dto.CurrencyId;
139 + existing.PaidByUserId = dto.PaidByUserId;
140 + _uow.Expenses.Update(existing);
141 +
142 + var oldSplits = await _uow.ExpenseSplits.GetAllAsync();
143 + foreach (var s in oldSplits.Where(x => x.ExpenseId == id).ToList())
144 + {
145 + await _uow.ExpenseSplits.RemoveAsync(s.Id);
146 + }
147 + foreach (var s in dto.Splits)
148 + {
149 + if (!await _mediator.Send(new IsTripParticipantQuery(existing.TripId, s.UserId)))
150 + return BadRequest(new { error = $"User {s.UserId} is not a participant of this trip." });
151 + _uow.ExpenseSplits.Add(new ExpenseSplit
152 + {
153 + ExpenseId = id,
154 + UserId = s.UserId,
155 + Amount = s.Amount,
156 + Percentage = s.Percentage,
157 + });
158 + }
159 + await _uow.SaveChangesAsync();
160 + return NoContent();
161 + }
162 +
163 + [HttpDelete("{id:guid}")]
164 + public async Task<IActionResult> Delete(Guid id)
165 + {
166 + var userId = CurrentUserId();
167 + if (userId == null) return Unauthorized();
168 +
169 + var expense = await _uow.Expenses.GetByIdAsync(id);
170 + if (expense == null) return NotFound();
171 + if (!await _mediator.Send(new IsTripParticipantQuery(expense.TripId, userId.Value))) return Forbid();
172 +
173 + var splits = (await _uow.ExpenseSplits.GetAllAsync()).Where(s => s.ExpenseId == id).ToList();
174 + foreach (var s in splits) await _uow.ExpenseSplits.RemoveAsync(s.Id);
175 +
176 + await _uow.Expenses.RemoveAsync(id);
177 + await _uow.SaveChangesAsync();
178 + return NoContent();
179 + }
180 +
181 + private Guid? CurrentUserId()
182 + {
183 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
184 + return Guid.TryParse(raw, out var id) ? id : null;
185 + }
186 +
187 + /// <summary>Hydrates Expense entities into Vue-shaped DTOs in a single batch:
188 + /// fetches user names, currency codes, and budget category names cross-module via MediatR
189 + /// (one query per type, not per row) and computes amountInTripCurrency.</summary>
190 + private async Task<List<ExpenseDto>> BuildExpenseDtosAsync(
191 + IEnumerable<Expense> expenses,
192 + Guid tripId,
193 + bool includeSplits)
194 + {
195 + var list = expenses as IList<Expense> ?? expenses.ToList();
196 + if (list.Count == 0) return new List<ExpenseDto>();
197 +
198 + // Trip + default currency for amountInTripCurrency conversion
199 + var trip = await _mediator.Send(new GetTripByIdQuery(tripId));
200 +
201 + var allSplits = includeSplits
202 + ? (await _uow.ExpenseSplits.GetAllAsync())
203 + .Where(s => list.Any(e => e.Id == s.ExpenseId))
204 + .ToList()
205 + : new List<ExpenseSplit>();
206 +
207 + // Batch lookups
208 + var userIds = list.Select(e => e.PaidByUserId)
209 + .Concat(allSplits.Select(s => s.UserId))
210 + .Distinct().ToList();
211 + var users = userIds.Count == 0
212 + ? new List<SplitApp.Shared.Contracts.Users.UserDto>()
213 + : (await _mediator.Send(new GetUsersByIdsQuery(userIds))).ToList();
214 + var userLookup = users.ToDictionary(u => u.Id);
215 +
216 + var currencyIds = list.Select(e => e.CurrencyId).Where(id => id.HasValue).Select(id => id!.Value).ToList();
217 + if (trip != null) currencyIds.Add(trip.DefaultCurrencyId);
218 + currencyIds = currencyIds.Distinct().ToList();
219 + var currencies = currencyIds.Count == 0
220 + ? new List<SplitApp.Shared.Contracts.Expenses.CurrencyDto>()
221 + : (await _mediator.Send(new SplitApp.Shared.Contracts.Expenses.Queries.GetCurrenciesByIdsQuery(currencyIds))).ToList();
222 + var currencyLookup = currencies.ToDictionary(c => c.Id);
223 + var tripDefaultCurrency = trip != null && currencyLookup.TryGetValue(trip.DefaultCurrencyId, out var dc) ? dc : null;
224 +
225 + var categoryIds = list.Select(e => e.BudgetCategoryId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().ToList();
226 + var categories = categoryIds.Count == 0
227 + ? new List<SplitApp.Shared.Contracts.Trips.BudgetCategoryNameDto>()
228 + : (await _mediator.Send(new SplitApp.Shared.Contracts.Trips.Queries.GetBudgetCategoryNamesByIdsQuery(categoryIds))).ToList();
229 + var categoryLookup = categories.ToDictionary(c => c.Id);
230 +
231 + return list.Select(e =>
232 + {
233 + var currency = e.CurrencyId.HasValue && currencyLookup.TryGetValue(e.CurrencyId.Value, out var c) ? c : null;
234 + decimal? converted = null;
235 + if (currency != null && tripDefaultCurrency != null && currency.Code != tripDefaultCurrency.Code)
236 + {
237 + converted = CurrencyConverter.Convert(e.Amount, currency.Code, tripDefaultCurrency.Code);
238 + }
239 + userLookup.TryGetValue(e.PaidByUserId, out var paidByUser);
240 +
241 + var dto = new ExpenseDto
242 + {
243 + Id = e.Id,
244 + TripId = e.TripId,
245 + PaidByUserId = e.PaidByUserId,
246 + PaidByUserName = paidByUser?.DisplayName,
247 + BudgetCategoryId = e.BudgetCategoryId,
248 + BudgetCategoryName = e.BudgetCategoryId.HasValue && categoryLookup.TryGetValue(e.BudgetCategoryId.Value, out var cat)
249 + ? cat.Name : null,
250 + CurrencyId = e.CurrencyId,
251 + CurrencyCode = currency?.Code,
252 + CurrencySymbol = currency?.Symbol,
253 + Amount = e.Amount,
254 + AmountInTripCurrency = converted,
255 + Description = e.Description,
256 + ExpenseDate = e.ExpenseDate,
257 + SplitMethod = e.SplitMethod.ToString(),
258 + };
259 +
260 + if (includeSplits)
261 + {
262 + dto.Splits = allSplits.Where(s => s.ExpenseId == e.Id).Select(s =>
263 + {
264 + userLookup.TryGetValue(s.UserId, out var splitUser);
265 + return new ExpenseSplitDto
266 + {
267 + Id = s.Id,
268 + UserId = s.UserId,
269 + UserName = splitUser?.DisplayName,
270 + Amount = s.Amount,
271 + Percentage = s.Percentage,
272 + };
273 + }).ToList();
274 + }
275 + return dto;
276 + }).ToList();
277 + }
278 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SettlementsController.cs +290 −0
@@ -0,0 +1,290 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Expenses.Api.Dto.v1;
8 +using SplitApp.Modules.Expenses.Application;
9 +using SplitApp.Modules.Expenses.Application.Contracts;
10 +using SplitApp.Modules.Expenses.Domain.Entities;
11 +using SplitApp.Modules.Expenses.Domain.Enums;
12 +using SplitApp.Shared.Contracts.Trips.Queries;
13 +using SplitApp.Shared.Contracts.Users.Queries;
14 +
15 +namespace SplitApp.Modules.Expenses.Api.Controllers;
16 +
17 +[ApiVersion("1.0")]
18 +[ApiController]
19 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
20 +[Route("api/v{version:apiVersion}/[controller]")]
21 +public class SettlementsController : ControllerBase
22 +{
23 + private readonly IExpensesUnitOfWork _uow;
24 + private readonly IMediator _mediator;
25 +
26 + public SettlementsController(IExpensesUnitOfWork uow, IMediator mediator)
27 + {
28 + _uow = uow;
29 + _mediator = mediator;
30 + }
31 +
32 + [HttpGet("trip/{tripId:guid}")]
33 + public async Task<ActionResult<SettlementPlanDto>> GetLatestPlan(Guid tripId)
34 + {
35 + var userId = CurrentUserId();
36 + if (userId == null) return Unauthorized();
37 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
38 +
39 + var plan = await GetLatestPlanForTripAsync(tripId);
40 + if (plan == null) return NotFound();
41 +
42 + return Ok(await BuildPlanDtoAsync(plan));
43 + }
44 +
45 + [HttpGet("trip/{tripId:guid}/balances")]
46 + public async Task<ActionResult<List<BalanceDto>>> GetBalances(Guid tripId)
47 + {
48 + var userId = CurrentUserId();
49 + if (userId == null) return Unauthorized();
50 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
51 +
52 + return Ok(await CalculateBalancesAsync(tripId));
53 + }
54 +
55 + [HttpGet("trip/{tripId:guid}/summary")]
56 + public async Task<ActionResult<SettlementSummaryDto>> GetSummary(Guid tripId)
57 + {
58 + var userId = CurrentUserId();
59 + if (userId == null) return Unauthorized();
60 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
61 +
62 + var balances = await CalculateBalancesAsync(tripId);
63 + var plan = await GetLatestPlanForTripAsync(tripId);
64 +
65 + return Ok(new SettlementSummaryDto
66 + {
67 + Balances = balances,
68 + LatestPlan = plan == null ? null : await BuildPlanDtoAsync(plan),
69 + });
70 + }
71 +
72 + [HttpPost("payments/{paymentId:guid}/mark-paid")]
73 + public async Task<IActionResult> MarkPaid(Guid paymentId)
74 + {
75 + var userId = CurrentUserId();
76 + if (userId == null) return Unauthorized();
77 +
78 + var payment = await _uow.SettlementPayments.GetByIdAsync(paymentId);
79 + if (payment == null) return NotFound();
80 + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
81 + if (plan == null) return NotFound();
82 +
83 + if (!await _mediator.Send(new IsTripParticipantQuery(plan.TripId, userId.Value))) return Forbid();
84 + if (payment.FromUserId != userId.Value) return Forbid();
85 +
86 + payment.Status = EPaymentStatus.MarkedPaid;
87 + payment.MarkedPaidAt = DateTime.UtcNow;
88 + _uow.SettlementPayments.Update(payment);
89 + await _uow.SaveChangesAsync();
90 + return Ok();
91 + }
92 +
93 + [HttpPost("payments/{paymentId:guid}/confirm")]
94 + public async Task<IActionResult> ConfirmPayment(Guid paymentId)
95 + {
96 + var userId = CurrentUserId();
97 + if (userId == null) return Unauthorized();
98 +
99 + var payment = await _uow.SettlementPayments.GetByIdAsync(paymentId);
100 + if (payment == null) return NotFound();
101 + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
102 + if (plan == null) return NotFound();
103 +
104 + if (!await _mediator.Send(new IsTripParticipantQuery(plan.TripId, userId.Value))) return Forbid();
105 + if (payment.ToUserId != userId.Value) return Forbid();
106 +
107 + payment.Status = EPaymentStatus.Confirmed;
108 + payment.ConfirmedAt = DateTime.UtcNow;
109 + _uow.SettlementPayments.Update(payment);
110 +
111 + var allPayments = (await _uow.SettlementPayments.GetAllAsync())
112 + .Where(p => p.SettlementPlanId == plan.Id)
113 + .ToList();
114 + var allConfirmed = allPayments.All(p => p.Id == paymentId || p.Status == EPaymentStatus.Confirmed);
115 +
116 + plan.Status = allConfirmed ? ESettlementStatus.Completed : ESettlementStatus.InProgress;
117 + if (allConfirmed) plan.CompletedAt = DateTime.UtcNow;
118 + _uow.SettlementPlans.Update(plan);
119 +
120 + await _uow.SaveChangesAsync();
121 +
122 + if (allConfirmed)
123 + {
124 + await _mediator.Publish(new SplitApp.Shared.Contracts.Expenses.Events.SettlementPlanCompletedEvent(plan.TripId, plan.Id));
125 + }
126 +
127 + return Ok();
128 + }
129 +
130 + [HttpPost("trip/{tripId:guid}/calculate")]
131 + public async Task<ActionResult<SettlementPlanDto>> CalculatePlan(Guid tripId)
132 + {
133 + var userId = CurrentUserId();
134 + if (userId == null) return Unauthorized();
135 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
136 +
137 + var balances = await CalculateBalancesAsync(tripId);
138 + var creditors = balances.Where(b => b.Balance > 0.01m)
139 + .Select(b => new { b.UserId, Amount = b.Balance })
140 + .OrderByDescending(c => c.Amount).ToList();
141 + var debtors = balances.Where(b => b.Balance < -0.01m)
142 + .Select(b => new { b.UserId, Amount = -b.Balance })
143 + .OrderByDescending(d => d.Amount).ToList();
144 +
145 + if (creditors.Count == 0 || debtors.Count == 0)
146 + return NotFound("No outstanding balances to settle.");
147 +
148 + var plan = new SettlementPlan
149 + {
150 + TripId = tripId,
151 + CreatedByUserId = userId.Value,
152 + TotalAmount = creditors.Sum(c => c.Amount),
153 + Status = ESettlementStatus.Pending,
154 + };
155 + _uow.SettlementPlans.Add(plan);
156 +
157 + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount);
158 + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount);
159 + var sortedCreditors = creditBalances.Keys.ToList();
160 + var sortedDebtors = debtBalances.Keys.ToList();
161 + var ci = 0;
162 + var di = 0;
163 +
164 + while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
165 + {
166 + var creditorId = sortedCreditors[ci];
167 + var debtorId = sortedDebtors[di];
168 + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]);
169 +
170 + if (amount > 0.01m)
171 + {
172 + _uow.SettlementPayments.Add(new SettlementPayment
173 + {
174 + SettlementPlanId = plan.Id,
175 + FromUserId = debtorId,
176 + ToUserId = creditorId,
177 + Amount = Math.Round(amount, 2),
178 + Status = EPaymentStatus.Pending,
179 + });
180 + }
181 +
182 + creditBalances[creditorId] -= amount;
183 + debtBalances[debtorId] -= amount;
184 + if (creditBalances[creditorId] < 0.01m) ci++;
185 + if (debtBalances[debtorId] < 0.01m) di++;
186 + }
187 +
188 + await _uow.SaveChangesAsync();
189 +
190 + return Ok(await BuildPlanDtoAsync(plan));
191 + }
192 +
193 + private Guid? CurrentUserId()
194 + {
195 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
196 + return Guid.TryParse(raw, out var id) ? id : null;
197 + }
198 +
199 + private async Task<SettlementPlan?> GetLatestPlanForTripAsync(Guid tripId)
200 + {
201 + var all = await _uow.SettlementPlans.GetAllAsync();
202 + return all.Where(p => p.TripId == tripId)
203 + .OrderByDescending(p => p.CreatedAt)
204 + .FirstOrDefault();
205 + }
206 +
207 + private async Task<List<BalanceDto>> CalculateBalancesAsync(Guid tripId)
208 + {
209 + var trip = await _mediator.Send(new GetTripByIdQuery(tripId));
210 + var participants = await _mediator.Send(new GetTripParticipantsQuery(tripId));
211 +
212 + if (trip == null || participants.Count == 0) return new List<BalanceDto>();
213 +
214 + var defaultCurrency = await _uow.Currencies.GetByIdAsync(trip.DefaultCurrencyId);
215 + var defaultCode = defaultCurrency?.Code ?? "EUR";
216 +
217 + var userIds = participants.Select(p => p.UserId).Distinct().ToList();
218 + var users = await _mediator.Send(new GetUsersByIdsQuery(userIds));
219 + var nameLookup = users.ToDictionary(u => u.Id, u => u.DisplayName);
220 +
221 + var balances = participants.ToDictionary(
222 + p => p.UserId,
223 + p => new BalanceDto
224 + {
225 + UserId = p.UserId,
226 + UserName = nameLookup.TryGetValue(p.UserId, out var name) ? name : null,
227 + Balance = 0,
228 + });
229 +
230 + var allExpenses = (await _uow.Expenses.GetAllAsync()).Where(e => e.TripId == tripId).ToList();
231 + var allSplits = (await _uow.ExpenseSplits.GetAllAsync()).ToList();
232 + var allCurrencies = (await _uow.Currencies.GetAllAsync()).ToDictionary(c => c.Id, c => c.Code);
233 +
234 + foreach (var expense in allExpenses)
235 + {
236 + var fromCode = expense.CurrencyId.HasValue
237 + && allCurrencies.TryGetValue(expense.CurrencyId.Value, out var code)
238 + ? code : defaultCode;
239 + var paidConverted = CurrencyConverter.Convert(expense.Amount, fromCode, defaultCode);
240 + if (balances.TryGetValue(expense.PaidByUserId, out var payerEntry))
241 + {
242 + payerEntry.Balance += paidConverted;
243 + }
244 +
245 + var splitsForExpense = allSplits.Where(s => s.ExpenseId == expense.Id);
246 + foreach (var split in splitsForExpense)
247 + {
248 + if (!balances.TryGetValue(split.UserId, out var splitEntry)) continue;
249 + var splitConverted = CurrencyConverter.Convert(split.Amount, fromCode, defaultCode);
250 + splitEntry.Balance -= splitConverted;
251 + }
252 + }
253 +
254 + return balances.Values.OrderByDescending(b => b.Balance).ToList();
255 + }
256 +
257 + private async Task<SettlementPlanDto> BuildPlanDtoAsync(SettlementPlan plan)
258 + {
259 + var allPayments = (await _uow.SettlementPayments.GetAllAsync())
260 + .Where(p => p.SettlementPlanId == plan.Id)
261 + .ToList();
262 +
263 + var userIds = allPayments.SelectMany(p => new[] { p.FromUserId, p.ToUserId }).Distinct().ToList();
264 + var users = userIds.Count == 0
265 + ? new List<SplitApp.Shared.Contracts.Users.UserDto>()
266 + : (await _mediator.Send(new GetUsersByIdsQuery(userIds))).ToList();
267 + var nameLookup = users.ToDictionary(u => u.Id, u => u.DisplayName);
268 +
269 + return new SettlementPlanDto
270 + {
271 + Id = plan.Id,
272 + TripId = plan.TripId,
273 + TotalAmount = plan.TotalAmount,
274 + Status = plan.Status.ToString(),
275 + CompletedAt = plan.CompletedAt,
276 + Payments = allPayments.Select(p => new SettlementPaymentDto
277 + {
278 + Id = p.Id,
279 + FromUserId = p.FromUserId,
280 + FromUserName = nameLookup.TryGetValue(p.FromUserId, out var fn) ? fn : null,
281 + ToUserId = p.ToUserId,
282 + ToUserName = nameLookup.TryGetValue(p.ToUserId, out var tn) ? tn : null,
283 + Amount = p.Amount,
284 + Status = p.Status.ToString(),
285 + MarkedPaidAt = p.MarkedPaidAt,
286 + ConfirmedAt = p.ConfirmedAt,
287 + }).ToList(),
288 + };
289 + }
290 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SplitPresetsController.cs +208 −0
@@ -0,0 +1,208 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Expenses.Api.Dto.v1;
8 +using SplitApp.Modules.Expenses.Application.Contracts;
9 +using SplitApp.Modules.Expenses.Domain.Entities;
10 +using SplitApp.Modules.Expenses.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Trips.Queries;
12 +using SplitApp.Shared.Contracts.Users.Queries;
13 +
14 +namespace SplitApp.Modules.Expenses.Api.Controllers;
15 +
16 +[ApiVersion("1.0")]
17 +[ApiController]
18 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
19 +[Route("api/v{version:apiVersion}/[controller]")]
20 +public class SplitPresetsController : ControllerBase
21 +{
22 + private readonly IExpensesUnitOfWork _uow;
23 + private readonly IMediator _mediator;
24 +
25 + public SplitPresetsController(IExpensesUnitOfWork uow, IMediator mediator)
26 + {
27 + _uow = uow;
28 + _mediator = mediator;
29 + }
30 +
31 + [HttpGet("trip/{tripId:guid}")]
32 + public async Task<ActionResult<List<SplitPresetDto>>> GetForTrip(Guid tripId)
33 + {
34 + var userId = CurrentUserId();
35 + if (userId == null) return Unauthorized();
36 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
37 +
38 + var allPresets = await _uow.SplitPresets.GetAllAsync();
39 + var presets = allPresets.Where(p => p.TripId == tripId).ToList();
40 + return Ok(await BuildDtosAsync(presets));
41 + }
42 +
43 + [HttpGet("{id:guid}")]
44 + public async Task<ActionResult<SplitPresetDto>> Get(Guid id)
45 + {
46 + var userId = CurrentUserId();
47 + if (userId == null) return Unauthorized();
48 +
49 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
50 + if (preset == null) return NotFound();
51 + if (!await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value))) return NotFound();
52 +
53 + var dtos = await BuildDtosAsync(new[] { preset });
54 + return Ok(dtos.Single());
55 + }
56 +
57 + [HttpPost]
58 + public async Task<ActionResult<SplitPresetDto>> Create([FromBody] SplitPresetCreateDto dto)
59 + {
60 + var userId = CurrentUserId();
61 + if (userId == null) return Unauthorized();
62 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, userId.Value))) return Forbid();
63 +
64 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var method))
65 + return BadRequest($"Unknown split method '{dto.SplitMethod}'.");
66 +
67 + var preset = new SplitPreset
68 + {
69 + TripId = dto.TripId,
70 + Name = dto.Name,
71 + SplitMethod = method,
72 + CreatedById = userId.Value,
73 + };
74 + _uow.SplitPresets.Add(preset);
75 +
76 + if (dto.Members != null)
77 + {
78 + foreach (var m in dto.Members)
79 + {
80 + _uow.SplitPresetMembers.Add(new SplitPresetMember
81 + {
82 + SplitPresetId = preset.Id,
83 + UserId = m.UserId,
84 + ShareWeight = m.ShareWeight,
85 + Percentage = m.Percentage,
86 + });
87 + }
88 + }
89 +
90 + await _uow.SaveChangesAsync();
91 +
92 + var dtos = await BuildDtosAsync(new[] { preset });
93 + return CreatedAtAction(nameof(Get), new { id = preset.Id }, dtos.Single());
94 + }
95 +
96 + [HttpPut("{id:guid}")]
97 + public async Task<IActionResult> Update(Guid id, [FromBody] SplitPresetCreateDto dto)
98 + {
99 + var userId = CurrentUserId();
100 + if (userId == null) return Unauthorized();
101 +
102 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
103 + if (preset == null) return NotFound();
104 +
105 + var canEdit = preset.CreatedById == userId.Value
106 + || await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value));
107 + if (!canEdit) return Forbid();
108 +
109 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var method))
110 + return BadRequest($"Unknown split method '{dto.SplitMethod}'.");
111 +
112 + preset.Name = dto.Name;
113 + preset.SplitMethod = method;
114 + _uow.SplitPresets.Update(preset);
115 +
116 + var allMembers = await _uow.SplitPresetMembers.GetAllAsync();
117 + foreach (var existing in allMembers.Where(m => m.SplitPresetId == id))
118 + {
119 + await _uow.SplitPresetMembers.RemoveAsync(existing.Id);
120 + }
121 +
122 + if (dto.Members != null)
123 + {
124 + foreach (var m in dto.Members)
125 + {
126 + _uow.SplitPresetMembers.Add(new SplitPresetMember
127 + {
128 + SplitPresetId = preset.Id,
129 + UserId = m.UserId,
130 + ShareWeight = m.ShareWeight,
131 + Percentage = m.Percentage,
132 + });
133 + }
134 + }
135 +
136 + await _uow.SaveChangesAsync();
137 + return NoContent();
138 + }
139 +
140 + [HttpDelete("{id:guid}")]
141 + public async Task<IActionResult> Delete(Guid id)
142 + {
143 + var userId = CurrentUserId();
144 + if (userId == null) return Unauthorized();
145 +
146 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
147 + if (preset == null) return NotFound();
148 +
149 + if (preset.CreatedById != userId.Value
150 + && !await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value)))
151 + {
152 + return Forbid();
153 + }
154 +
155 + var allMembers = await _uow.SplitPresetMembers.GetAllAsync();
156 + foreach (var m in allMembers.Where(m => m.SplitPresetId == id))
157 + {
158 + await _uow.SplitPresetMembers.RemoveAsync(m.Id);
159 + }
160 + await _uow.SplitPresets.RemoveAsync(id);
161 + await _uow.SaveChangesAsync();
162 + return NoContent();
163 + }
164 +
165 + private Guid? CurrentUserId()
166 + {
167 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
168 + return Guid.TryParse(raw, out var id) ? id : null;
169 + }
170 +
171 + private async Task<List<SplitPresetDto>> BuildDtosAsync(IEnumerable<SplitPreset> presets)
172 + {
173 + var presetList = presets.ToList();
174 + if (presetList.Count == 0) return new List<SplitPresetDto>();
175 +
176 + var presetIds = presetList.Select(p => p.Id).ToHashSet();
177 + var allMembers = (await _uow.SplitPresetMembers.GetAllAsync())
178 + .Where(m => presetIds.Contains(m.SplitPresetId))
179 + .ToList();
180 +
181 + var userIds = presetList.Select(p => p.CreatedById)
182 + .Concat(allMembers.Select(m => m.UserId))
183 + .Distinct()
184 + .ToList();
185 +
186 + var users = userIds.Count == 0
187 + ? new List<SplitApp.Shared.Contracts.Users.UserDto>()
188 + : (await _mediator.Send(new GetUsersByIdsQuery(userIds))).ToList();
189 + var nameLookup = users.ToDictionary(u => u.Id, u => u.DisplayName);
190 +
191 + return presetList.Select(p => new SplitPresetDto
192 + {
193 + Id = p.Id,
194 + TripId = p.TripId,
195 + Name = p.Name,
196 + SplitMethod = p.SplitMethod.ToString(),
197 + CreatedByUserName = nameLookup.TryGetValue(p.CreatedById, out var creator) ? creator : null,
198 + Members = allMembers.Where(m => m.SplitPresetId == p.Id).Select(m => new SplitPresetMemberDto
199 + {
200 + Id = m.Id,
201 + UserId = m.UserId,
202 + UserName = nameLookup.TryGetValue(m.UserId, out var name) ? name : null,
203 + ShareWeight = m.ShareWeight,
204 + Percentage = m.Percentage,
205 + }).ToList(),
206 + }).ToList();
207 + }
208 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/CurrencyDto.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class CurrencyDto
4 +{
5 + public Guid Id { get; set; }
6 + public string Code { get; set; } = default!;
7 + public string Name { get; set; } = default!;
8 + public string Symbol { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/ExpenseDto.cs +49 −0
@@ -0,0 +1,49 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class ExpenseDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public Guid PaidByUserId { get; set; }
8 + public string? PaidByUserName { get; set; }
9 + public Guid? BudgetCategoryId { get; set; }
10 + public string? BudgetCategoryName { get; set; }
11 + public Guid? CurrencyId { get; set; }
12 + public string? CurrencyCode { get; set; }
13 + public string? CurrencySymbol { get; set; }
14 + public decimal Amount { get; set; }
15 + public decimal? AmountInTripCurrency { get; set; }
16 + public string? Description { get; set; }
17 + public DateTime ExpenseDate { get; set; }
18 + public string SplitMethod { get; set; } = default!;
19 + public List<ExpenseSplitDto>? Splits { get; set; }
20 +}
21 +
22 +public class ExpenseSplitDto
23 +{
24 + public Guid Id { get; set; }
25 + public Guid UserId { get; set; }
26 + public string? UserName { get; set; }
27 + public decimal Amount { get; set; }
28 + public decimal? Percentage { get; set; }
29 +}
30 +
31 +public class ExpenseCreateDto
32 +{
33 + public Guid TripId { get; set; }
34 + public Guid PaidByUserId { get; set; }
35 + public Guid? CurrencyId { get; set; }
36 + public Guid? BudgetCategoryId { get; set; }
37 + public decimal Amount { get; set; }
38 + public string? Description { get; set; }
39 + public DateTime ExpenseDate { get; set; }
40 + public string SplitMethod { get; set; } = "EqualAll";
41 + public IList<ExpenseSplitCreateDto> Splits { get; set; } = new List<ExpenseSplitCreateDto>();
42 +}
43 +
44 +public class ExpenseSplitCreateDto
45 +{
46 + public Guid UserId { get; set; }
47 + public decimal Amount { get; set; }
48 + public decimal? Percentage { get; set; }
49 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SettlementDto.cs +37 −0
@@ -0,0 +1,37 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class SettlementPlanDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public decimal TotalAmount { get; set; }
8 + public string Status { get; set; } = default!;
9 + public DateTime? CompletedAt { get; set; }
10 + public List<SettlementPaymentDto>? Payments { get; set; }
11 +}
12 +
13 +public class SettlementPaymentDto
14 +{
15 + public Guid Id { get; set; }
16 + public Guid FromUserId { get; set; }
17 + public string? FromUserName { get; set; }
18 + public Guid ToUserId { get; set; }
19 + public string? ToUserName { get; set; }
20 + public decimal Amount { get; set; }
21 + public string Status { get; set; } = default!;
22 + public DateTime? MarkedPaidAt { get; set; }
23 + public DateTime? ConfirmedAt { get; set; }
24 +}
25 +
26 +public class BalanceDto
27 +{
28 + public Guid UserId { get; set; }
29 + public string? UserName { get; set; }
30 + public decimal Balance { get; set; }
31 +}
32 +
33 +public class SettlementSummaryDto
34 +{
35 + public List<BalanceDto> Balances { get; set; } = new();
36 + public SettlementPlanDto? LatestPlan { get; set; }
37 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SplitPresetDto.cs +35 −0
@@ -0,0 +1,35 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class SplitPresetDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public string Name { get; set; } = default!;
8 + public string SplitMethod { get; set; } = default!;
9 + public string? CreatedByUserName { get; set; }
10 + public List<SplitPresetMemberDto>? Members { get; set; }
11 +}
12 +
13 +public class SplitPresetMemberDto
14 +{
15 + public Guid Id { get; set; }
16 + public Guid UserId { get; set; }
17 + public string? UserName { get; set; }
18 + public decimal? ShareWeight { get; set; }
19 + public decimal? Percentage { get; set; }
20 +}
21 +
22 +public class SplitPresetCreateDto
23 +{
24 + public Guid TripId { get; set; }
25 + public string Name { get; set; } = default!;
26 + public string SplitMethod { get; set; } = default!;
27 + public List<SplitPresetMemberCreateDto>? Members { get; set; }
28 +}
29 +
30 +public class SplitPresetMemberCreateDto
31 +{
32 + public Guid UserId { get; set; }
33 + public decimal? ShareWeight { get; set; }
34 + public decimal? Percentage { get; set; }
35 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/ExampleJsInterop.cs +31 −0
@@ -0,0 +1,31 @@
1 +using Microsoft.JSInterop;
2 +
3 +namespace SplitApp.Modules.Expenses.Api;
4 +
5 +// This class provides an example of how JavaScript functionality can be wrapped
6 +// in a .NET class for easy consumption. The associated JavaScript module is
7 +// loaded on demand when first needed.
8 +//
9 +// This class can be registered as scoped DI service and then injected into Blazor
10 +// components for use.
11 +
12 +public class ExampleJsInterop(IJSRuntime jsRuntime) : IAsyncDisposable
13 +{
14 + private readonly Lazy<Task<IJSObjectReference>> moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
15 + "import", "./_content/SplitApp.Modules.Expenses.Api/exampleJsInterop.js").AsTask());
16 +
17 + public async ValueTask<string> Prompt(string message)
18 + {
19 + var module = await moduleTask.Value;
20 + return await module.InvokeAsync<string>("showPrompt", message);
21 + }
22 +
23 + public async ValueTask DisposeAsync()
24 + {
25 + if (moduleTask.IsValueCreated)
26 + {
27 + var module = await moduleTask.Value;
28 + await module.DisposeAsync();
29 + }
30 + }
31 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/SplitApp.Modules.Expenses.Api.csproj +26 −0
@@ -0,0 +1,26 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Razor">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + </PropertyGroup>
8 +
9 +
10 + <ItemGroup>
11 + <SupportedPlatform Include="browser" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
18 + </ItemGroup>
19 +
20 + <ItemGroup>
21 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj" />
22 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
23 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
24 + </ItemGroup>
25 +
26 +</Project>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/_Imports.razor +1 −0
@@ -0,0 +1 @@
1 +@using Microsoft.AspNetCore.Components.Web
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/background.png +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/exampleJsInterop.js +6 −0
@@ -0,0 +1,6 @@
1 +// This is a JavaScript module that is loaded on demand. It can export any number of
2 +// functions, and may import other JavaScript modules if required.
3 +
4 +export function showPrompt(message) {
5 + return prompt(message, 'Type anything here');
6 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/Contracts/IExpensesUnitOfWork.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Persistence;
3 +
4 +namespace SplitApp.Modules.Expenses.Application.Contracts;
5 +
6 +public interface IExpensesUnitOfWork : IUnitOfWork
7 +{
8 + IBaseRepository<Expense> Expenses { get; }
9 + IBaseRepository<ExpenseSplit> ExpenseSplits { get; }
10 + IBaseRepository<SettlementPlan> SettlementPlans { get; }
11 + IBaseRepository<SettlementPayment> SettlementPayments { get; }
12 + IBaseRepository<SplitPreset> SplitPresets { get; }
13 + IBaseRepository<SplitPresetMember> SplitPresetMembers { get; }
14 + IBaseRepository<Currency> Currencies { get; }
15 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/CurrencyConverter.cs +22 −0
@@ -0,0 +1,22 @@
1 +namespace SplitApp.Modules.Expenses.Application;
2 +
3 +public static class CurrencyConverter
4 +{
5 + private static readonly Dictionary<string, decimal> RatesToEur = new()
6 + {
7 + { "EUR", 1.0m },
8 + { "USD", 0.92m },
9 + { "GBP", 1.16m },
10 + { "SEK", 0.087m },
11 + { "NOK", 0.086m },
12 + };
13 +
14 + public static decimal Convert(decimal amount, string fromCurrencyCode, string toCurrencyCode)
15 + {
16 + if (fromCurrencyCode == toCurrencyCode) return amount;
17 + if (!RatesToEur.TryGetValue(fromCurrencyCode, out var fromRate)) return amount;
18 + if (!RatesToEur.TryGetValue(toCurrencyCode, out var toRate)) return amount;
19 + var amountInEur = amount * fromRate;
20 + return Math.Round(amountInEur / toRate, 2);
21 + }
22 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/ExpensesModuleMarker.cs +4 −0
@@ -0,0 +1,4 @@
1 +namespace SplitApp.Modules.Expenses.Application;
2 +
3 +/// <summary>Assembly marker for MediatR handler scanning. Must remain in the Application assembly.</summary>
4 +public sealed class ExpensesModuleMarker;
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/SplitApp.Modules.Expenses.Application.csproj +19 −0
@@ -0,0 +1,19 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="MediatR" Version="12.4.1" />
11 + </ItemGroup>
12 +
13 + <PropertyGroup>
14 + <TargetFramework>net10.0</TargetFramework>
15 + <ImplicitUsings>enable</ImplicitUsings>
16 + <Nullable>enable</Nullable>
17 + </PropertyGroup>
18 +
19 +</Project>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Currency.cs +16 −0
@@ -0,0 +1,16 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Localization;
4 +
5 +namespace SplitApp.Modules.Expenses.Domain.Entities;
6 +
7 +public class Currency : BaseEntity
8 +{
9 + [MaxLength(3)]
10 + public string Code { get; set; } = default!;
11 +
12 + public LangStr Name { get; set; } = new();
13 +
14 + [MaxLength(10)]
15 + public string Symbol { get; set; } = default!;
16 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Expense.cs +39 −0
@@ -0,0 +1,39 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Expenses.Domain.Entities;
8 +
9 +public class Expense : BaseEntity
10 +{
11 + /// <summary>FK to trips.Trip.</summary>
12 + public Guid TripId { get; set; }
13 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
14 + [NotMapped] public object? Trip { get; set; }
15 +
16 + /// <summary>FK to users.AspNetUsers.</summary>
17 + public Guid PaidByUserId { get; set; }
18 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
19 + [NotMapped] public AppUser? PaidByUser { get; set; }
20 +
21 + /// <summary>FK to trips.BudgetCategory.</summary>
22 + public Guid? BudgetCategoryId { get; set; }
23 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
24 + [NotMapped] public object? BudgetCategory { get; set; }
25 +
26 + public Guid? CurrencyId { get; set; }
27 + public Currency? Currency { get; set; }
28 +
29 + public decimal Amount { get; set; }
30 +
31 + [MaxLength(500)]
32 + public string? Description { get; set; }
33 +
34 + public DateTime ExpenseDate { get; set; }
35 +
36 + public ESplitMethod SplitMethod { get; set; }
37 +
38 + public ICollection<ExpenseSplit>? Splits { get; set; }
39 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/ExpenseSplit.cs +19 −0
@@ -0,0 +1,19 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Users.Domain.Entities;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Expenses.Domain.Entities;
6 +
7 +public class ExpenseSplit : BaseEntity
8 +{
9 + public Guid ExpenseId { get; set; }
10 + public Expense? Expense { get; set; }
11 +
12 + /// <summary>FK to users.AspNetUsers.</summary>
13 + public Guid UserId { get; set; }
14 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
15 + [NotMapped] public AppUser? User { get; set; }
16 +
17 + public decimal Amount { get; set; }
18 + public decimal? Percentage { get; set; }
19 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPayment.cs +30 −0
@@ -0,0 +1,30 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Expenses.Domain.Enums;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +using SplitApp.Shared.Kernel.Domain;
5 +
6 +namespace SplitApp.Modules.Expenses.Domain.Entities;
7 +
8 +public class SettlementPayment : BaseEntity
9 +{
10 + public Guid SettlementPlanId { get; set; }
11 + public SettlementPlan? SettlementPlan { get; set; }
12 +
13 + /// <summary>FK to users.AspNetUsers.</summary>
14 + public Guid FromUserId { get; set; }
15 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
16 + [NotMapped] public AppUser? FromUser { get; set; }
17 +
18 + /// <summary>FK to users.AspNetUsers.</summary>
19 + public Guid ToUserId { get; set; }
20 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
21 + [NotMapped] public AppUser? ToUser { get; set; }
22 +
23 + public decimal Amount { get; set; }
24 +
25 + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending;
26 +
27 + public DateTime? MarkedPaidAt { get; set; }
28 +
29 + public DateTime? ConfirmedAt { get; set; }
30 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPlan.cs +27 −0
@@ -0,0 +1,27 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Expenses.Domain.Enums;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +using SplitApp.Shared.Kernel.Domain;
5 +
6 +namespace SplitApp.Modules.Expenses.Domain.Entities;
7 +
8 +public class SettlementPlan : BaseEntity
9 +{
10 + /// <summary>FK to trips.Trip.</summary>
11 + public Guid TripId { get; set; }
12 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
13 + [NotMapped] public object? Trip { get; set; }
14 +
15 + /// <summary>FK to users.AspNetUsers.</summary>
16 + public Guid CreatedByUserId { get; set; }
17 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
18 + [NotMapped] public AppUser? CreatedByUser { get; set; }
19 +
20 + public decimal TotalAmount { get; set; }
21 +
22 + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending;
23 +
24 + public DateTime? CompletedAt { get; set; }
25 +
26 + public ICollection<SettlementPayment>? Payments { get; set; }
27 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPreset.cs +27 −0
@@ -0,0 +1,27 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Expenses.Domain.Entities;
8 +
9 +public class SplitPreset : BaseEntity
10 +{
11 + /// <summary>FK to trips.Trip.</summary>
12 + public Guid TripId { get; set; }
13 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
14 + [NotMapped] public object? Trip { get; set; }
15 +
16 + [MaxLength(200)]
17 + public string Name { get; set; } = default!;
18 +
19 + public ESplitMethod SplitMethod { get; set; }
20 +
21 + /// <summary>FK to users.AspNetUsers.</summary>
22 + public Guid CreatedById { get; set; }
23 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
24 + [NotMapped] public AppUser? CreatedBy { get; set; }
25 +
26 + public ICollection<SplitPresetMember>? Members { get; set; }
27 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPresetMember.cs +19 −0
@@ -0,0 +1,19 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Users.Domain.Entities;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Expenses.Domain.Entities;
6 +
7 +public class SplitPresetMember : BaseEntity
8 +{
9 + public Guid SplitPresetId { get; set; }
10 + public SplitPreset? SplitPreset { get; set; }
11 +
12 + /// <summary>FK to users.AspNetUsers.</summary>
13 + public Guid UserId { get; set; }
14 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
15 + [NotMapped] public AppUser? User { get; set; }
16 +
17 + public decimal? ShareWeight { get; set; }
18 + public decimal? Percentage { get; set; }
19 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/EPaymentStatus.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Modules.Expenses.Domain.Enums;
2 +
3 +public enum EPaymentStatus
4 +{
5 + Pending,
6 + MarkedPaid,
7 + Confirmed
8 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESettlementStatus.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Modules.Expenses.Domain.Enums;
2 +
3 +public enum ESettlementStatus
4 +{
5 + Pending,
6 + InProgress,
7 + Completed
8 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESplitMethod.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Expenses.Domain.Enums;
2 +
3 +public enum ESplitMethod
4 +{
5 + EqualAll,
6 + EqualSubset,
7 + ExactAmounts,
8 + Percentages
9 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/SplitApp.Modules.Expenses.Domain.csproj +14 −0
@@ -0,0 +1,14 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + <ProjectReference Include="..\..\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
6 + </ItemGroup>
7 +
8 + <PropertyGroup>
9 + <TargetFramework>net10.0</TargetFramework>
10 + <ImplicitUsings>enable</ImplicitUsings>
11 + <Nullable>enable</Nullable>
12 + </PropertyGroup>
13 +
14 +</Project>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/ExpensesModuleExtensions.cs +64 −0
@@ -0,0 +1,64 @@
1 +using Microsoft.AspNetCore.Builder;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.Extensions.Configuration;
4 +using Microsoft.Extensions.DependencyInjection;
5 +using SplitApp.Modules.Expenses.Application;
6 +using SplitApp.Modules.Expenses.Application.Contracts;
7 +using SplitApp.Modules.Expenses.Domain.Entities;
8 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
9 +using SplitApp.Shared.Kernel.Localization;
10 +
11 +namespace SplitApp.Modules.Expenses.Infrastructure;
12 +
13 +public static class ExpensesModuleExtensions
14 +{
15 + public static IServiceCollection AddExpensesModule(
16 + this IServiceCollection services,
17 + IConfiguration configuration)
18 + {
19 + services.AddDbContext<ExpensesDbContext>(opt =>
20 + {
21 + opt.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
22 + });
23 +
24 + services.AddScoped<IExpensesUnitOfWork, ExpensesUnitOfWork>();
25 +
26 + services.AddMediatR(cfg =>
27 + {
28 + cfg.RegisterServicesFromAssemblyContaining<ExpensesModuleMarker>();
29 + cfg.RegisterServicesFromAssembly(typeof(ExpensesModuleExtensions).Assembly);
30 + });
31 +
32 + return services;
33 + }
34 +
35 + public static IApplicationBuilder UseExpensesModule(this IApplicationBuilder app)
36 + {
37 + using var scope = app.ApplicationServices.CreateScope();
38 + var db = scope.ServiceProvider.GetRequiredService<ExpensesDbContext>();
39 + db.Database.Migrate();
40 +
41 + if (!db.Currencies.Any())
42 + {
43 + var seed = new (string Code, string NameEn, string NameEt, string Symbol)[]
44 + {
45 + ("EUR", "Euro", "Euro", "€"),
46 + ("USD", "US Dollar", "USA dollar", "$"),
47 + ("GBP", "British Pound", "Briti nael", "£"),
48 + ("SEK", "Swedish Krona", "Rootsi kroon", "kr"),
49 + ("NOK", "Norwegian Krone","Norra kroon", "kr"),
50 + };
51 +
52 + foreach (var c in seed)
53 + {
54 + var name = new LangStr(c.NameEn, "en");
55 + name["et"] = c.NameEt;
56 + db.Currencies.Add(new Currency { Code = c.Code, Name = name, Symbol = c.Symbol });
57 + }
58 +
59 + db.SaveChanges();
60 + }
61 +
62 + return app;
63 + }
64 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/CalculateSettlementHandler.cs +113 −0
@@ -0,0 +1,113 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Application;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
7 +using SplitApp.Shared.Contracts.Expenses.Commands;
8 +using SplitApp.Shared.Contracts.Trips.Queries;
9 +
10 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
11 +
12 +public class CalculateSettlementHandler : IRequestHandler<CalculateSettlementCommand, Guid?>
13 +{
14 + private readonly ExpensesDbContext _db;
15 + private readonly IMediator _mediator;
16 +
17 + public CalculateSettlementHandler(ExpensesDbContext db, IMediator mediator)
18 + {
19 + _db = db;
20 + _mediator = mediator;
21 + }
22 +
23 + public async Task<Guid?> Handle(CalculateSettlementCommand request, CancellationToken cancellationToken)
24 + {
25 + var trip = await _mediator.Send(new GetTripByIdQuery(request.TripId), cancellationToken);
26 + if (trip == null) return null;
27 +
28 + var participants = await _mediator.Send(new GetTripParticipantsQuery(request.TripId), cancellationToken);
29 + if (participants.Count == 0) return null;
30 +
31 + var defaultCurrency = await _db.Currencies.FirstOrDefaultAsync(c => c.Id == trip.DefaultCurrencyId, cancellationToken);
32 + var defaultCode = defaultCurrency?.Code ?? "EUR";
33 +
34 + var balances = participants.ToDictionary(p => p.UserId, _ => 0m);
35 +
36 + var allCurrencies = await _db.Currencies.ToDictionaryAsync(c => c.Id, c => c.Code, cancellationToken);
37 + var tripExpenses = await _db.Expenses.Where(e => e.TripId == request.TripId).ToListAsync(cancellationToken);
38 + var expenseIds = tripExpenses.Select(e => e.Id).ToList();
39 + var splits = await _db.ExpenseSplits.Where(s => expenseIds.Contains(s.ExpenseId)).ToListAsync(cancellationToken);
40 +
41 + foreach (var expense in tripExpenses)
42 + {
43 + var fromCode = expense.CurrencyId.HasValue && allCurrencies.TryGetValue(expense.CurrencyId.Value, out var code)
44 + ? code : defaultCode;
45 + var paidConverted = CurrencyConverter.Convert(expense.Amount, fromCode, defaultCode);
46 + if (balances.ContainsKey(expense.PaidByUserId))
47 + {
48 + balances[expense.PaidByUserId] += paidConverted;
49 + }
50 +
51 + foreach (var split in splits.Where(s => s.ExpenseId == expense.Id))
52 + {
53 + if (!balances.ContainsKey(split.UserId)) continue;
54 + var splitConverted = CurrencyConverter.Convert(split.Amount, fromCode, defaultCode);
55 + balances[split.UserId] -= splitConverted;
56 + }
57 + }
58 +
59 + var creditors = balances
60 + .Where(b => b.Value > 0.01m)
61 + .Select(b => new { UserId = b.Key, Amount = b.Value })
62 + .OrderByDescending(c => c.Amount).ToList();
63 + var debtors = balances
64 + .Where(b => b.Value < -0.01m)
65 + .Select(b => new { UserId = b.Key, Amount = -b.Value })
66 + .OrderByDescending(d => d.Amount).ToList();
67 +
68 + if (creditors.Count == 0 || debtors.Count == 0) return null;
69 +
70 + var plan = new SettlementPlan
71 + {
72 + TripId = request.TripId,
73 + CreatedByUserId = request.CreatedByUserId,
74 + TotalAmount = creditors.Sum(c => c.Amount),
75 + Status = ESettlementStatus.Pending,
76 + };
77 + _db.SettlementPlans.Add(plan);
78 +
79 + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount);
80 + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount);
81 + var sortedCreditors = creditBalances.Keys.ToList();
82 + var sortedDebtors = debtBalances.Keys.ToList();
83 + var ci = 0;
84 + var di = 0;
85 +
86 + while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
87 + {
88 + var creditorId = sortedCreditors[ci];
89 + var debtorId = sortedDebtors[di];
90 + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]);
91 +
92 + if (amount > 0.01m)
93 + {
94 + _db.SettlementPayments.Add(new SettlementPayment
95 + {
96 + SettlementPlanId = plan.Id,
97 + FromUserId = debtorId,
98 + ToUserId = creditorId,
99 + Amount = Math.Round(amount, 2),
100 + Status = EPaymentStatus.Pending,
101 + });
102 + }
103 +
104 + creditBalances[creditorId] -= amount;
105 + debtBalances[debtorId] -= amount;
106 + if (creditBalances[creditorId] < 0.01m) ci++;
107 + if (debtBalances[debtorId] < 0.01m) di++;
108 + }
109 +
110 + await _db.SaveChangesAsync(cancellationToken);
111 + return plan.Id;
112 + }
113 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetBudgetCategorySpentHandler.cs +30 −0
@@ -0,0 +1,30 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Expenses.Queries;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
7 +
8 +public class GetBudgetCategorySpentHandler
9 + : IRequestHandler<GetBudgetCategorySpentQuery, IReadOnlyDictionary<Guid, decimal>>
10 +{
11 + private readonly ExpensesDbContext _db;
12 +
13 + public GetBudgetCategorySpentHandler(ExpensesDbContext db)
14 + {
15 + _db = db;
16 + }
17 +
18 + public async Task<IReadOnlyDictionary<Guid, decimal>> Handle(
19 + GetBudgetCategorySpentQuery request,
20 + CancellationToken cancellationToken)
21 + {
22 + var rows = await _db.Expenses
23 + .Where(e => e.TripId == request.TripId && e.BudgetCategoryId != null)
24 + .GroupBy(e => e.BudgetCategoryId!.Value)
25 + .Select(g => new { CategoryId = g.Key, Total = g.Sum(e => e.Amount) })
26 + .ToListAsync(cancellationToken);
27 +
28 + return rows.ToDictionary(r => r.CategoryId, r => r.Total);
29 + }
30 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetCurrenciesByIdsHandler.cs +28 −0
@@ -0,0 +1,28 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Expenses;
5 +using SplitApp.Shared.Contracts.Expenses.Queries;
6 +
7 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
8 +
9 +public class GetCurrenciesByIdsHandler
10 + : IRequestHandler<GetCurrenciesByIdsQuery, IReadOnlyList<CurrencyDto>>
11 +{
12 + private readonly ExpensesDbContext _db;
13 +
14 + public GetCurrenciesByIdsHandler(ExpensesDbContext db) => _db = db;
15 +
16 + public async Task<IReadOnlyList<CurrencyDto>> Handle(
17 + GetCurrenciesByIdsQuery request,
18 + CancellationToken cancellationToken)
19 + {
20 + if (request.CurrencyIds.Count == 0) return Array.Empty<CurrencyDto>();
21 + var ids = request.CurrencyIds.Distinct().ToList();
22 + var rows = await _db.Currencies
23 + .Where(c => ids.Contains(c.Id))
24 + .Select(c => new CurrencyDto(c.Id, c.Code, c.Symbol))
25 + .ToListAsync(cancellationToken);
26 + return rows;
27 + }
28 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetTripExpenseTotalsHandler.cs +34 −0
@@ -0,0 +1,34 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Expenses;
5 +using SplitApp.Shared.Contracts.Expenses.Queries;
6 +
7 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
8 +
9 +public class GetTripExpenseTotalsHandler : IRequestHandler<GetTripExpenseTotalsQuery, TripExpenseTotalsDto>
10 +{
11 + private readonly ExpensesDbContext _db;
12 +
13 + public GetTripExpenseTotalsHandler(ExpensesDbContext db)
14 + {
15 + _db = db;
16 + }
17 +
18 + public async Task<TripExpenseTotalsDto> Handle(GetTripExpenseTotalsQuery request, CancellationToken cancellationToken)
19 + {
20 + var rows = await _db.Expenses
21 + .Where(e => e.TripId == request.TripId)
22 + .Join(_db.Currencies,
23 + e => e.CurrencyId,
24 + c => c.Id,
25 + (e, c) => new { c.Code, e.Amount })
26 + .ToListAsync(cancellationToken);
27 +
28 + var totals = rows
29 + .GroupBy(r => r.Code)
30 + .ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
31 +
32 + return new TripExpenseTotalsDto(request.TripId, totals, rows.Count);
33 + }
34 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/RemoveSettlementPlanHandler.cs +37 −0
@@ -0,0 +1,37 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
5 +using SplitApp.Shared.Contracts.Expenses.Commands;
6 +
7 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
8 +
9 +public class RemoveSettlementPlanHandler : IRequestHandler<RemoveSettlementPlanCommand, bool>
10 +{
11 + private readonly ExpensesDbContext _db;
12 +
13 + public RemoveSettlementPlanHandler(ExpensesDbContext db) => _db = db;
14 +
15 + public async Task<bool> Handle(RemoveSettlementPlanCommand request, CancellationToken cancellationToken)
16 + {
17 + var plan = await _db.SettlementPlans
18 + .Where(p => p.TripId == request.TripId)
19 + .OrderByDescending(p => p.CreatedAt)
20 + .FirstOrDefaultAsync(cancellationToken);
21 + if (plan == null) return true;
22 +
23 + var payments = await _db.SettlementPayments
24 + .Where(p => p.SettlementPlanId == plan.Id)
25 + .ToListAsync(cancellationToken);
26 +
27 + if (payments.Any(p => p.Status == EPaymentStatus.Confirmed))
28 + {
29 + return false;
30 + }
31 +
32 + _db.SettlementPayments.RemoveRange(payments);
33 + _db.SettlementPlans.Remove(plan);
34 + await _db.SaveChangesAsync(cancellationToken);
35 + return true;
36 + }
37 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/TripDeletedHandler.cs +55 −0
@@ -0,0 +1,55 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips.Events;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
7 +
8 +public class TripDeletedHandler : INotificationHandler<TripDeletedEvent>
9 +{
10 + private readonly ExpensesDbContext _db;
11 +
12 + public TripDeletedHandler(ExpensesDbContext db)
13 + {
14 + _db = db;
15 + }
16 +
17 + public async Task Handle(TripDeletedEvent notification, CancellationToken cancellationToken)
18 + {
19 + var expenseIds = await _db.Expenses
20 + .Where(e => e.TripId == notification.TripId)
21 + .Select(e => e.Id)
22 + .ToListAsync(cancellationToken);
23 +
24 + if (expenseIds.Count > 0)
25 + {
26 + await _db.ExpenseSplits
27 + .Where(s => expenseIds.Contains(s.ExpenseId))
28 + .ExecuteDeleteAsync(cancellationToken);
29 + }
30 +
31 + await _db.Expenses
32 + .Where(e => e.TripId == notification.TripId)
33 + .ExecuteDeleteAsync(cancellationToken);
34 +
35 + var planIds = await _db.SettlementPlans
36 + .Where(p => p.TripId == notification.TripId)
37 + .Select(p => p.Id)
38 + .ToListAsync(cancellationToken);
39 +
40 + if (planIds.Count > 0)
41 + {
42 + await _db.SettlementPayments
43 + .Where(p => planIds.Contains(p.SettlementPlanId))
44 + .ExecuteDeleteAsync(cancellationToken);
45 + }
46 +
47 + await _db.SettlementPlans
48 + .Where(p => p.TripId == notification.TripId)
49 + .ExecuteDeleteAsync(cancellationToken);
50 +
51 + await _db.SplitPresets
52 + .Where(p => p.TripId == notification.TripId)
53 + .ExecuteDeleteAsync(cancellationToken);
54 + }
55 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/UserDeletedHandler.cs +27 −0
@@ -0,0 +1,27 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Users.Events;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
7 +
8 +public class UserDeletedHandler : INotificationHandler<UserDeletedEvent>
9 +{
10 + private readonly ExpensesDbContext _db;
11 +
12 + public UserDeletedHandler(ExpensesDbContext db)
13 + {
14 + _db = db;
15 + }
16 +
17 + public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken)
18 + {
19 + await _db.ExpenseSplits
20 + .Where(s => s.UserId == notification.UserId)
21 + .ExecuteDeleteAsync(cancellationToken);
22 +
23 + await _db.SplitPresetMembers
24 + .Where(m => m.UserId == notification.UserId)
25 + .ExecuteDeleteAsync(cancellationToken);
26 + }
27 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesDbContext.cs +78 −0
@@ -0,0 +1,78 @@
1 +using System.Text.Json;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.EntityFrameworkCore.ChangeTracking;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence;
9 +
10 +public class ExpensesDbContext : DbContext
11 +{
12 + public DbSet<Expense> Expenses { get; set; } = default!;
13 + public DbSet<ExpenseSplit> ExpenseSplits { get; set; } = default!;
14 + public DbSet<SettlementPlan> SettlementPlans { get; set; } = default!;
15 + public DbSet<SettlementPayment> SettlementPayments { get; set; } = default!;
16 + public DbSet<SplitPreset> SplitPresets { get; set; } = default!;
17 + public DbSet<SplitPresetMember> SplitPresetMembers { get; set; } = default!;
18 + public DbSet<Currency> Currencies { get; set; } = default!;
19 +
20 + public ExpensesDbContext(DbContextOptions<ExpensesDbContext> options) : base(options)
21 + {
22 + }
23 +
24 + public override int SaveChanges()
25 + {
26 + UpdateTimestamps();
27 + return base.SaveChanges();
28 + }
29 +
30 + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
31 + {
32 + UpdateTimestamps();
33 + return base.SaveChangesAsync(cancellationToken);
34 + }
35 +
36 + private void UpdateTimestamps()
37 + {
38 + var entries = ChangeTracker.Entries<BaseEntity>();
39 + foreach (var entry in entries)
40 + {
41 + if (entry.State == EntityState.Modified)
42 + {
43 + entry.Entity.UpdatedAt = DateTime.UtcNow;
44 + }
45 + }
46 + }
47 +
48 + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
49 + {
50 + base.ConfigureConventions(configurationBuilder);
51 + configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
52 + }
53 +
54 + protected override void OnModelCreating(ModelBuilder builder)
55 + {
56 + base.OnModelCreating(builder);
57 +
58 + builder.HasDefaultSchema("expenses");
59 +
60 + foreach (var relationship in builder.Model
61 + .GetEntityTypes()
62 + .SelectMany(e => e.GetForeignKeys()))
63 + {
64 + relationship.DeleteBehavior = DeleteBehavior.Restrict;
65 + }
66 +
67 + builder.Entity<Currency>()
68 + .Property(c => c.Name)
69 + .HasConversion(
70 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
71 + v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null) ?? new LangStr())
72 + .HasMaxLength(1024)
73 + .Metadata.SetValueComparer(new ValueComparer<LangStr>(
74 + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
75 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
76 + v => JsonSerializer.Deserialize<LangStr>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!));
77 + }
78 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesUnitOfWork.cs +33 −0
@@ -0,0 +1,33 @@
1 +using SplitApp.Modules.Expenses.Application.Contracts;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence.Repositories;
4 +using SplitApp.Shared.Kernel.Persistence;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence;
7 +
8 +public class ExpensesUnitOfWork : IExpensesUnitOfWork
9 +{
10 + private readonly ExpensesDbContext _db;
11 +
12 + public ExpensesUnitOfWork(ExpensesDbContext db)
13 + {
14 + _db = db;
15 + Expenses = new ExpensesBaseRepository<Expense>(db);
16 + ExpenseSplits = new ExpensesBaseRepository<ExpenseSplit>(db);
17 + SettlementPlans = new ExpensesBaseRepository<SettlementPlan>(db);
18 + SettlementPayments = new ExpensesBaseRepository<SettlementPayment>(db);
19 + SplitPresets = new ExpensesBaseRepository<SplitPreset>(db);
20 + SplitPresetMembers = new ExpensesBaseRepository<SplitPresetMember>(db);
21 + Currencies = new ExpensesBaseRepository<Currency>(db);
22 + }
23 +
24 + public IBaseRepository<Expense> Expenses { get; }
25 + public IBaseRepository<ExpenseSplit> ExpenseSplits { get; }
26 + public IBaseRepository<SettlementPlan> SettlementPlans { get; }
27 + public IBaseRepository<SettlementPayment> SettlementPayments { get; }
28 + public IBaseRepository<SplitPreset> SplitPresets { get; }
29 + public IBaseRepository<SplitPresetMember> SplitPresetMembers { get; }
30 + public IBaseRepository<Currency> Currencies { get; }
31 +
32 + public Task<int> SaveChangesAsync() => _db.SaveChangesAsync();
33 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.Designer.cs +330 −0
@@ -0,0 +1,330 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Migrations;
6 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
8 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
9 +
10 +#nullable disable
11 +
12 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Migrations
13 +{
14 + [DbContext(typeof(ExpensesDbContext))]
15 + [Migration("20260430135020_Init")]
16 + partial class Init
17 + {
18 + /// <inheritdoc />
19 + protected override void BuildTargetModel(ModelBuilder modelBuilder)
20 + {
21 +#pragma warning disable 612, 618
22 + modelBuilder
23 + .HasDefaultSchema("expenses")
24 + .HasAnnotation("ProductVersion", "10.0.5")
25 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
26 +
27 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
28 +
29 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Currency", b =>
30 + {
31 + b.Property<Guid>("Id")
32 + .ValueGeneratedOnAdd()
33 + .HasColumnType("uuid");
34 +
35 + b.Property<string>("Code")
36 + .IsRequired()
37 + .HasMaxLength(3)
38 + .HasColumnType("character varying(3)");
39 +
40 + b.Property<DateTime>("CreatedAt")
41 + .HasColumnType("timestamp with time zone");
42 +
43 + b.Property<string>("Name")
44 + .IsRequired()
45 + .HasMaxLength(1024)
46 + .HasColumnType("character varying(1024)");
47 +
48 + b.Property<string>("Symbol")
49 + .IsRequired()
50 + .HasMaxLength(10)
51 + .HasColumnType("character varying(10)");
52 +
53 + b.Property<DateTime>("UpdatedAt")
54 + .HasColumnType("timestamp with time zone");
55 +
56 + b.HasKey("Id");
57 +
58 + b.ToTable("Currencies", "expenses");
59 + });
60 +
61 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
62 + {
63 + b.Property<Guid>("Id")
64 + .ValueGeneratedOnAdd()
65 + .HasColumnType("uuid");
66 +
67 + b.Property<decimal>("Amount")
68 + .HasColumnType("numeric");
69 +
70 + b.Property<Guid?>("BudgetCategoryId")
71 + .HasColumnType("uuid");
72 +
73 + b.Property<DateTime>("CreatedAt")
74 + .HasColumnType("timestamp with time zone");
75 +
76 + b.Property<Guid?>("CurrencyId")
77 + .HasColumnType("uuid");
78 +
79 + b.Property<string>("Description")
80 + .HasMaxLength(500)
81 + .HasColumnType("character varying(500)");
82 +
83 + b.Property<DateTime>("ExpenseDate")
84 + .HasColumnType("timestamp with time zone");
85 +
86 + b.Property<Guid>("PaidByUserId")
87 + .HasColumnType("uuid");
88 +
89 + b.Property<int>("SplitMethod")
90 + .HasColumnType("integer");
91 +
92 + b.Property<Guid>("TripId")
93 + .HasColumnType("uuid");
94 +
95 + b.Property<DateTime>("UpdatedAt")
96 + .HasColumnType("timestamp with time zone");
97 +
98 + b.HasKey("Id");
99 +
100 + b.HasIndex("CurrencyId");
101 +
102 + b.ToTable("Expenses", "expenses");
103 + });
104 +
105 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
106 + {
107 + b.Property<Guid>("Id")
108 + .ValueGeneratedOnAdd()
109 + .HasColumnType("uuid");
110 +
111 + b.Property<decimal>("Amount")
112 + .HasColumnType("numeric");
113 +
114 + b.Property<DateTime>("CreatedAt")
115 + .HasColumnType("timestamp with time zone");
116 +
117 + b.Property<Guid>("ExpenseId")
118 + .HasColumnType("uuid");
119 +
120 + b.Property<decimal?>("Percentage")
121 + .HasColumnType("numeric");
122 +
123 + b.Property<DateTime>("UpdatedAt")
124 + .HasColumnType("timestamp with time zone");
125 +
126 + b.Property<Guid>("UserId")
127 + .HasColumnType("uuid");
128 +
129 + b.HasKey("Id");
130 +
131 + b.HasIndex("ExpenseId");
132 +
133 + b.ToTable("ExpenseSplits", "expenses");
134 + });
135 +
136 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
137 + {
138 + b.Property<Guid>("Id")
139 + .ValueGeneratedOnAdd()
140 + .HasColumnType("uuid");
141 +
142 + b.Property<decimal>("Amount")
143 + .HasColumnType("numeric");
144 +
145 + b.Property<DateTime?>("ConfirmedAt")
146 + .HasColumnType("timestamp with time zone");
147 +
148 + b.Property<DateTime>("CreatedAt")
149 + .HasColumnType("timestamp with time zone");
150 +
151 + b.Property<Guid>("FromUserId")
152 + .HasColumnType("uuid");
153 +
154 + b.Property<DateTime?>("MarkedPaidAt")
155 + .HasColumnType("timestamp with time zone");
156 +
157 + b.Property<Guid>("SettlementPlanId")
158 + .HasColumnType("uuid");
159 +
160 + b.Property<int>("Status")
161 + .HasColumnType("integer");
162 +
163 + b.Property<Guid>("ToUserId")
164 + .HasColumnType("uuid");
165 +
166 + b.Property<DateTime>("UpdatedAt")
167 + .HasColumnType("timestamp with time zone");
168 +
169 + b.HasKey("Id");
170 +
171 + b.HasIndex("SettlementPlanId");
172 +
173 + b.ToTable("SettlementPayments", "expenses");
174 + });
175 +
176 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
177 + {
178 + b.Property<Guid>("Id")
179 + .ValueGeneratedOnAdd()
180 + .HasColumnType("uuid");
181 +
182 + b.Property<DateTime?>("CompletedAt")
183 + .HasColumnType("timestamp with time zone");
184 +
185 + b.Property<DateTime>("CreatedAt")
186 + .HasColumnType("timestamp with time zone");
187 +
188 + b.Property<Guid>("CreatedByUserId")
189 + .HasColumnType("uuid");
190 +
191 + b.Property<int>("Status")
192 + .HasColumnType("integer");
193 +
194 + b.Property<decimal>("TotalAmount")
195 + .HasColumnType("numeric");
196 +
197 + b.Property<Guid>("TripId")
198 + .HasColumnType("uuid");
199 +
200 + b.Property<DateTime>("UpdatedAt")
201 + .HasColumnType("timestamp with time zone");
202 +
203 + b.HasKey("Id");
204 +
205 + b.ToTable("SettlementPlans", "expenses");
206 + });
207 +
208 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
209 + {
210 + b.Property<Guid>("Id")
211 + .ValueGeneratedOnAdd()
212 + .HasColumnType("uuid");
213 +
214 + b.Property<DateTime>("CreatedAt")
215 + .HasColumnType("timestamp with time zone");
216 +
217 + b.Property<Guid>("CreatedById")
218 + .HasColumnType("uuid");
219 +
220 + b.Property<string>("Name")
221 + .IsRequired()
222 + .HasMaxLength(200)
223 + .HasColumnType("character varying(200)");
224 +
225 + b.Property<int>("SplitMethod")
226 + .HasColumnType("integer");
227 +
228 + b.Property<Guid>("TripId")
229 + .HasColumnType("uuid");
230 +
231 + b.Property<DateTime>("UpdatedAt")
232 + .HasColumnType("timestamp with time zone");
233 +
234 + b.HasKey("Id");
235 +
236 + b.ToTable("SplitPresets", "expenses");
237 + });
238 +
239 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
240 + {
241 + b.Property<Guid>("Id")
242 + .ValueGeneratedOnAdd()
243 + .HasColumnType("uuid");
244 +
245 + b.Property<DateTime>("CreatedAt")
246 + .HasColumnType("timestamp with time zone");
247 +
248 + b.Property<decimal?>("Percentage")
249 + .HasColumnType("numeric");
250 +
251 + b.Property<decimal?>("ShareWeight")
252 + .HasColumnType("numeric");
253 +
254 + b.Property<Guid>("SplitPresetId")
255 + .HasColumnType("uuid");
256 +
257 + b.Property<DateTime>("UpdatedAt")
258 + .HasColumnType("timestamp with time zone");
259 +
260 + b.Property<Guid>("UserId")
261 + .HasColumnType("uuid");
262 +
263 + b.HasKey("Id");
264 +
265 + b.HasIndex("SplitPresetId");
266 +
267 + b.ToTable("SplitPresetMembers", "expenses");
268 + });
269 +
270 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
271 + {
272 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Currency", "Currency")
273 + .WithMany()
274 + .HasForeignKey("CurrencyId")
275 + .OnDelete(DeleteBehavior.Restrict);
276 +
277 + b.Navigation("Currency");
278 + });
279 +
280 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
281 + {
282 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Expense", "Expense")
283 + .WithMany("Splits")
284 + .HasForeignKey("ExpenseId")
285 + .OnDelete(DeleteBehavior.Restrict)
286 + .IsRequired();
287 +
288 + b.Navigation("Expense");
289 + });
290 +
291 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
292 + {
293 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", "SettlementPlan")
294 + .WithMany("Payments")
295 + .HasForeignKey("SettlementPlanId")
296 + .OnDelete(DeleteBehavior.Restrict)
297 + .IsRequired();
298 +
299 + b.Navigation("SettlementPlan");
300 + });
301 +
302 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
303 + {
304 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", "SplitPreset")
305 + .WithMany("Members")
306 + .HasForeignKey("SplitPresetId")
307 + .OnDelete(DeleteBehavior.Restrict)
308 + .IsRequired();
309 +
310 + b.Navigation("SplitPreset");
311 + });
312 +
313 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
314 + {
315 + b.Navigation("Splits");
316 + });
317 +
318 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
319 + {
320 + b.Navigation("Payments");
321 + });
322 +
323 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
324 + {
325 + b.Navigation("Members");
326 + });
327 +#pragma warning restore 612, 618
328 + }
329 + }
330 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.cs +235 −0
@@ -0,0 +1,235 @@
1 +using System;
2 +using Microsoft.EntityFrameworkCore.Migrations;
3 +
4 +#nullable disable
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Migrations
7 +{
8 + /// <inheritdoc />
9 + public partial class Init : Migration
10 + {
11 + /// <inheritdoc />
12 + protected override void Up(MigrationBuilder migrationBuilder)
13 + {
14 + migrationBuilder.EnsureSchema(
15 + name: "expenses");
16 +
17 + migrationBuilder.CreateTable(
18 + name: "Currencies",
19 + schema: "expenses",
20 + columns: table => new
21 + {
22 + Id = table.Column<Guid>(type: "uuid", nullable: false),
23 + Code = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
24 + Name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
25 + Symbol = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
26 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
27 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
28 + },
29 + constraints: table =>
30 + {
31 + table.PrimaryKey("PK_Currencies", x => x.Id);
32 + });
33 +
34 + migrationBuilder.CreateTable(
35 + name: "SettlementPlans",
36 + schema: "expenses",
37 + columns: table => new
38 + {
39 + Id = table.Column<Guid>(type: "uuid", nullable: false),
40 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
41 + CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
42 + TotalAmount = table.Column<decimal>(type: "numeric", nullable: false),
43 + Status = table.Column<int>(type: "integer", nullable: false),
44 + CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
45 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
46 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
47 + },
48 + constraints: table =>
49 + {
50 + table.PrimaryKey("PK_SettlementPlans", x => x.Id);
51 + });
52 +
53 + migrationBuilder.CreateTable(
54 + name: "SplitPresets",
55 + schema: "expenses",
56 + columns: table => new
57 + {
58 + Id = table.Column<Guid>(type: "uuid", nullable: false),
59 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
60 + Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
61 + SplitMethod = table.Column<int>(type: "integer", nullable: false),
62 + CreatedById = table.Column<Guid>(type: "uuid", nullable: false),
63 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
64 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
65 + },
66 + constraints: table =>
67 + {
68 + table.PrimaryKey("PK_SplitPresets", x => x.Id);
69 + });
70 +
71 + migrationBuilder.CreateTable(
72 + name: "Expenses",
73 + schema: "expenses",
74 + columns: table => new
75 + {
76 + Id = table.Column<Guid>(type: "uuid", nullable: false),
77 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
78 + PaidByUserId = table.Column<Guid>(type: "uuid", nullable: false),
79 + BudgetCategoryId = table.Column<Guid>(type: "uuid", nullable: true),
80 + CurrencyId = table.Column<Guid>(type: "uuid", nullable: true),
81 + Amount = table.Column<decimal>(type: "numeric", nullable: false),
82 + Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
83 + ExpenseDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
84 + SplitMethod = table.Column<int>(type: "integer", nullable: false),
85 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
86 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
87 + },
88 + constraints: table =>
89 + {
90 + table.PrimaryKey("PK_Expenses", x => x.Id);
91 + table.ForeignKey(
92 + name: "FK_Expenses_Currencies_CurrencyId",
93 + column: x => x.CurrencyId,
94 + principalSchema: "expenses",
95 + principalTable: "Currencies",
96 + principalColumn: "Id",
97 + onDelete: ReferentialAction.Restrict);
98 + });
99 +
100 + migrationBuilder.CreateTable(
101 + name: "SettlementPayments",
102 + schema: "expenses",
103 + columns: table => new
104 + {
105 + Id = table.Column<Guid>(type: "uuid", nullable: false),
106 + SettlementPlanId = table.Column<Guid>(type: "uuid", nullable: false),
107 + FromUserId = table.Column<Guid>(type: "uuid", nullable: false),
108 + ToUserId = table.Column<Guid>(type: "uuid", nullable: false),
109 + Amount = table.Column<decimal>(type: "numeric", nullable: false),
110 + Status = table.Column<int>(type: "integer", nullable: false),
111 + MarkedPaidAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
112 + ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
113 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
114 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
115 + },
116 + constraints: table =>
117 + {
118 + table.PrimaryKey("PK_SettlementPayments", x => x.Id);
119 + table.ForeignKey(
120 + name: "FK_SettlementPayments_SettlementPlans_SettlementPlanId",
121 + column: x => x.SettlementPlanId,
122 + principalSchema: "expenses",
123 + principalTable: "SettlementPlans",
124 + principalColumn: "Id",
125 + onDelete: ReferentialAction.Restrict);
126 + });
127 +
128 + migrationBuilder.CreateTable(
129 + name: "SplitPresetMembers",
130 + schema: "expenses",
131 + columns: table => new
132 + {
133 + Id = table.Column<Guid>(type: "uuid", nullable: false),
134 + SplitPresetId = table.Column<Guid>(type: "uuid", nullable: false),
135 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
136 + ShareWeight = table.Column<decimal>(type: "numeric", nullable: true),
137 + Percentage = table.Column<decimal>(type: "numeric", nullable: true),
138 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
139 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
140 + },
141 + constraints: table =>
142 + {
143 + table.PrimaryKey("PK_SplitPresetMembers", x => x.Id);
144 + table.ForeignKey(
145 + name: "FK_SplitPresetMembers_SplitPresets_SplitPresetId",
146 + column: x => x.SplitPresetId,
147 + principalSchema: "expenses",
148 + principalTable: "SplitPresets",
149 + principalColumn: "Id",
150 + onDelete: ReferentialAction.Restrict);
151 + });
152 +
153 + migrationBuilder.CreateTable(
154 + name: "ExpenseSplits",
155 + schema: "expenses",
156 + columns: table => new
157 + {
158 + Id = table.Column<Guid>(type: "uuid", nullable: false),
159 + ExpenseId = table.Column<Guid>(type: "uuid", nullable: false),
160 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
161 + Amount = table.Column<decimal>(type: "numeric", nullable: false),
162 + Percentage = table.Column<decimal>(type: "numeric", nullable: true),
163 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
164 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
165 + },
166 + constraints: table =>
167 + {
168 + table.PrimaryKey("PK_ExpenseSplits", x => x.Id);
169 + table.ForeignKey(
170 + name: "FK_ExpenseSplits_Expenses_ExpenseId",
171 + column: x => x.ExpenseId,
172 + principalSchema: "expenses",
173 + principalTable: "Expenses",
174 + principalColumn: "Id",
175 + onDelete: ReferentialAction.Restrict);
176 + });
177 +
178 + migrationBuilder.CreateIndex(
179 + name: "IX_Expenses_CurrencyId",
180 + schema: "expenses",
181 + table: "Expenses",
182 + column: "CurrencyId");
183 +
184 + migrationBuilder.CreateIndex(
185 + name: "IX_ExpenseSplits_ExpenseId",
186 + schema: "expenses",
187 + table: "ExpenseSplits",
188 + column: "ExpenseId");
189 +
190 + migrationBuilder.CreateIndex(
191 + name: "IX_SettlementPayments_SettlementPlanId",
192 + schema: "expenses",
193 + table: "SettlementPayments",
194 + column: "SettlementPlanId");
195 +
196 + migrationBuilder.CreateIndex(
197 + name: "IX_SplitPresetMembers_SplitPresetId",
198 + schema: "expenses",
199 + table: "SplitPresetMembers",
200 + column: "SplitPresetId");
201 + }
202 +
203 + /// <inheritdoc />
204 + protected override void Down(MigrationBuilder migrationBuilder)
205 + {
206 + migrationBuilder.DropTable(
207 + name: "ExpenseSplits",
208 + schema: "expenses");
209 +
210 + migrationBuilder.DropTable(
211 + name: "SettlementPayments",
212 + schema: "expenses");
213 +
214 + migrationBuilder.DropTable(
215 + name: "SplitPresetMembers",
216 + schema: "expenses");
217 +
218 + migrationBuilder.DropTable(
219 + name: "Expenses",
220 + schema: "expenses");
221 +
222 + migrationBuilder.DropTable(
223 + name: "SettlementPlans",
224 + schema: "expenses");
225 +
226 + migrationBuilder.DropTable(
227 + name: "SplitPresets",
228 + schema: "expenses");
229 +
230 + migrationBuilder.DropTable(
231 + name: "Currencies",
232 + schema: "expenses");
233 + }
234 + }
235 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/ExpensesDbContextModelSnapshot.cs +327 −0
@@ -0,0 +1,327 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
6 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
7 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
8 +
9 +#nullable disable
10 +
11 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Migrations
12 +{
13 + [DbContext(typeof(ExpensesDbContext))]
14 + partial class ExpensesDbContextModelSnapshot : ModelSnapshot
15 + {
16 + protected override void BuildModel(ModelBuilder modelBuilder)
17 + {
18 +#pragma warning disable 612, 618
19 + modelBuilder
20 + .HasDefaultSchema("expenses")
21 + .HasAnnotation("ProductVersion", "10.0.5")
22 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
23 +
24 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
25 +
26 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Currency", b =>
27 + {
28 + b.Property<Guid>("Id")
29 + .ValueGeneratedOnAdd()
30 + .HasColumnType("uuid");
31 +
32 + b.Property<string>("Code")
33 + .IsRequired()
34 + .HasMaxLength(3)
35 + .HasColumnType("character varying(3)");
36 +
37 + b.Property<DateTime>("CreatedAt")
38 + .HasColumnType("timestamp with time zone");
39 +
40 + b.Property<string>("Name")
41 + .IsRequired()
42 + .HasMaxLength(1024)
43 + .HasColumnType("character varying(1024)");
44 +
45 + b.Property<string>("Symbol")
46 + .IsRequired()
47 + .HasMaxLength(10)
48 + .HasColumnType("character varying(10)");
49 +
50 + b.Property<DateTime>("UpdatedAt")
51 + .HasColumnType("timestamp with time zone");
52 +
53 + b.HasKey("Id");
54 +
55 + b.ToTable("Currencies", "expenses");
56 + });
57 +
58 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
59 + {
60 + b.Property<Guid>("Id")
61 + .ValueGeneratedOnAdd()
62 + .HasColumnType("uuid");
63 +
64 + b.Property<decimal>("Amount")
65 + .HasColumnType("numeric");
66 +
67 + b.Property<Guid?>("BudgetCategoryId")
68 + .HasColumnType("uuid");
69 +
70 + b.Property<DateTime>("CreatedAt")
71 + .HasColumnType("timestamp with time zone");
72 +
73 + b.Property<Guid?>("CurrencyId")
74 + .HasColumnType("uuid");
75 +
76 + b.Property<string>("Description")
77 + .HasMaxLength(500)
78 + .HasColumnType("character varying(500)");
79 +
80 + b.Property<DateTime>("ExpenseDate")
81 + .HasColumnType("timestamp with time zone");
82 +
83 + b.Property<Guid>("PaidByUserId")
84 + .HasColumnType("uuid");
85 +
86 + b.Property<int>("SplitMethod")
87 + .HasColumnType("integer");
88 +
89 + b.Property<Guid>("TripId")
90 + .HasColumnType("uuid");
91 +
92 + b.Property<DateTime>("UpdatedAt")
93 + .HasColumnType("timestamp with time zone");
94 +
95 + b.HasKey("Id");
96 +
97 + b.HasIndex("CurrencyId");
98 +
99 + b.ToTable("Expenses", "expenses");
100 + });
101 +
102 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
103 + {
104 + b.Property<Guid>("Id")
105 + .ValueGeneratedOnAdd()
106 + .HasColumnType("uuid");
107 +
108 + b.Property<decimal>("Amount")
109 + .HasColumnType("numeric");
110 +
111 + b.Property<DateTime>("CreatedAt")
112 + .HasColumnType("timestamp with time zone");
113 +
114 + b.Property<Guid>("ExpenseId")
115 + .HasColumnType("uuid");
116 +
117 + b.Property<decimal?>("Percentage")
118 + .HasColumnType("numeric");
119 +
120 + b.Property<DateTime>("UpdatedAt")
121 + .HasColumnType("timestamp with time zone");
122 +
123 + b.Property<Guid>("UserId")
124 + .HasColumnType("uuid");
125 +
126 + b.HasKey("Id");
127 +
128 + b.HasIndex("ExpenseId");
129 +
130 + b.ToTable("ExpenseSplits", "expenses");
131 + });
132 +
133 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
134 + {
135 + b.Property<Guid>("Id")
136 + .ValueGeneratedOnAdd()
137 + .HasColumnType("uuid");
138 +
139 + b.Property<decimal>("Amount")
140 + .HasColumnType("numeric");
141 +
142 + b.Property<DateTime?>("ConfirmedAt")
143 + .HasColumnType("timestamp with time zone");
144 +
145 + b.Property<DateTime>("CreatedAt")
146 + .HasColumnType("timestamp with time zone");
147 +
148 + b.Property<Guid>("FromUserId")
149 + .HasColumnType("uuid");
150 +
151 + b.Property<DateTime?>("MarkedPaidAt")
152 + .HasColumnType("timestamp with time zone");
153 +
154 + b.Property<Guid>("SettlementPlanId")
155 + .HasColumnType("uuid");
156 +
157 + b.Property<int>("Status")
158 + .HasColumnType("integer");
159 +
160 + b.Property<Guid>("ToUserId")
161 + .HasColumnType("uuid");
162 +
163 + b.Property<DateTime>("UpdatedAt")
164 + .HasColumnType("timestamp with time zone");
165 +
166 + b.HasKey("Id");
167 +
168 + b.HasIndex("SettlementPlanId");
169 +
170 + b.ToTable("SettlementPayments", "expenses");
171 + });
172 +
173 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
174 + {
175 + b.Property<Guid>("Id")
176 + .ValueGeneratedOnAdd()
177 + .HasColumnType("uuid");
178 +
179 + b.Property<DateTime?>("CompletedAt")
180 + .HasColumnType("timestamp with time zone");
181 +
182 + b.Property<DateTime>("CreatedAt")
183 + .HasColumnType("timestamp with time zone");
184 +
185 + b.Property<Guid>("CreatedByUserId")
186 + .HasColumnType("uuid");
187 +
188 + b.Property<int>("Status")
189 + .HasColumnType("integer");
190 +
191 + b.Property<decimal>("TotalAmount")
192 + .HasColumnType("numeric");
193 +
194 + b.Property<Guid>("TripId")
195 + .HasColumnType("uuid");
196 +
197 + b.Property<DateTime>("UpdatedAt")
198 + .HasColumnType("timestamp with time zone");
199 +
200 + b.HasKey("Id");
201 +
202 + b.ToTable("SettlementPlans", "expenses");
203 + });
204 +
205 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
206 + {
207 + b.Property<Guid>("Id")
208 + .ValueGeneratedOnAdd()
209 + .HasColumnType("uuid");
210 +
211 + b.Property<DateTime>("CreatedAt")
212 + .HasColumnType("timestamp with time zone");
213 +
214 + b.Property<Guid>("CreatedById")
215 + .HasColumnType("uuid");
216 +
217 + b.Property<string>("Name")
218 + .IsRequired()
219 + .HasMaxLength(200)
220 + .HasColumnType("character varying(200)");
221 +
222 + b.Property<int>("SplitMethod")
223 + .HasColumnType("integer");
224 +
225 + b.Property<Guid>("TripId")
226 + .HasColumnType("uuid");
227 +
228 + b.Property<DateTime>("UpdatedAt")
229 + .HasColumnType("timestamp with time zone");
230 +
231 + b.HasKey("Id");
232 +
233 + b.ToTable("SplitPresets", "expenses");
234 + });
235 +
236 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
237 + {
238 + b.Property<Guid>("Id")
239 + .ValueGeneratedOnAdd()
240 + .HasColumnType("uuid");
241 +
242 + b.Property<DateTime>("CreatedAt")
243 + .HasColumnType("timestamp with time zone");
244 +
245 + b.Property<decimal?>("Percentage")
246 + .HasColumnType("numeric");
247 +
248 + b.Property<decimal?>("ShareWeight")
249 + .HasColumnType("numeric");
250 +
251 + b.Property<Guid>("SplitPresetId")
252 + .HasColumnType("uuid");
253 +
254 + b.Property<DateTime>("UpdatedAt")
255 + .HasColumnType("timestamp with time zone");
256 +
257 + b.Property<Guid>("UserId")
258 + .HasColumnType("uuid");
259 +
260 + b.HasKey("Id");
261 +
262 + b.HasIndex("SplitPresetId");
263 +
264 + b.ToTable("SplitPresetMembers", "expenses");
265 + });
266 +
267 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
268 + {
269 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Currency", "Currency")
270 + .WithMany()
271 + .HasForeignKey("CurrencyId")
272 + .OnDelete(DeleteBehavior.Restrict);
273 +
274 + b.Navigation("Currency");
275 + });
276 +
277 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
278 + {
279 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Expense", "Expense")
280 + .WithMany("Splits")
281 + .HasForeignKey("ExpenseId")
282 + .OnDelete(DeleteBehavior.Restrict)
283 + .IsRequired();
284 +
285 + b.Navigation("Expense");
286 + });
287 +
288 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
289 + {
290 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", "SettlementPlan")
291 + .WithMany("Payments")
292 + .HasForeignKey("SettlementPlanId")
293 + .OnDelete(DeleteBehavior.Restrict)
294 + .IsRequired();
295 +
296 + b.Navigation("SettlementPlan");
297 + });
298 +
299 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
300 + {
301 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", "SplitPreset")
302 + .WithMany("Members")
303 + .HasForeignKey("SplitPresetId")
304 + .OnDelete(DeleteBehavior.Restrict)
305 + .IsRequired();
306 +
307 + b.Navigation("SplitPreset");
308 + });
309 +
310 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
311 + {
312 + b.Navigation("Splits");
313 + });
314 +
315 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
316 + {
317 + b.Navigation("Payments");
318 + });
319 +
320 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
321 + {
322 + b.Navigation("Members");
323 + });
324 +#pragma warning restore 612, 618
325 + }
326 + }
327 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Repositories/ExpensesBaseRepository.cs +29 −0
@@ -0,0 +1,29 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Repositories;
6 +
7 +public class ExpensesBaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity
8 +{
9 + protected readonly ExpensesDbContext DbContext;
10 + protected readonly DbSet<TEntity> DbSet;
11 +
12 + public ExpensesBaseRepository(ExpensesDbContext dbContext)
13 + {
14 + DbContext = dbContext;
15 + DbSet = dbContext.Set<TEntity>();
16 + }
17 +
18 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync();
19 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id);
20 + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity;
21 + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity;
22 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
23 + {
24 + var entity = await GetByIdAsync(id);
25 + if (entity == null) return null;
26 + return DbSet.Remove(entity).Entity;
27 + }
28 + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id);
29 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
@@ -0,0 +1,14 @@
1 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2 +
3 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +
5 +public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
6 +{
7 + public UtcDateTimeConverter() : base(
8 + v => v.Kind == DateTimeKind.Unspecified
9 + ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
10 + : v.ToUniversalTime(),
11 + v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
12 + {
13 + }
14 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj +30 −0
@@ -0,0 +1,30 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj" />
5 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
16 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
17 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
18 + <PrivateAssets>all</PrivateAssets>
19 + </PackageReference>
20 + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
21 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
22 + </ItemGroup>
23 +
24 + <PropertyGroup>
25 + <TargetFramework>net10.0</TargetFramework>
26 + <ImplicitUsings>enable</ImplicitUsings>
27 + <Nullable>enable</Nullable>
28 + </PropertyGroup>
29 +
30 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor +3 −0
@@ -0,0 +1,3 @@
1 +<div class="my-component">
2 + This component is defined in the <strong>SplitApp.Modules.Trips.Api</strong> library.
3 +</div>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor.css +6 −0
@@ -0,0 +1,6 @@
1 +.my-component {
2 + border: 2px dashed red;
3 + padding: 1em;
4 + margin: 1em 0;
5 + background-image: url('background.png');
6 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/BudgetCategoriesController.cs +146 −0
@@ -0,0 +1,146 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Expenses.Queries;
12 +using SplitApp.Shared.Kernel.Localization;
13 +
14 +namespace SplitApp.Modules.Trips.Api.Controllers;
15 +
16 +[ApiVersion("1.0")]
17 +[ApiController]
18 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
19 +[Route("api/v{version:apiVersion}/[controller]")]
20 +public class BudgetCategoriesController : ControllerBase
21 +{
22 + private readonly ITripsUnitOfWork _uow;
23 + private readonly IMediator _mediator;
24 +
25 + public BudgetCategoriesController(ITripsUnitOfWork uow, IMediator mediator)
26 + {
27 + _uow = uow;
28 + _mediator = mediator;
29 + }
30 +
31 + [HttpGet("trip/{tripId:guid}")]
32 + public async Task<ActionResult<List<BudgetCategoryDto>>> GetForTrip(Guid tripId)
33 + {
34 + var userId = CurrentUserId();
35 + if (userId == null) return Unauthorized();
36 +
37 + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid();
38 +
39 + var all = await _uow.BudgetCategories.GetAllAsync();
40 + var categories = all.Where(c => c.TripId == tripId).ToList();
41 +
42 + var spent = await _mediator.Send(new GetBudgetCategorySpentQuery(tripId));
43 +
44 + return Ok(categories.Select(c => MapToDto(c, spent)).ToList());
45 + }
46 +
47 + [HttpPost]
48 + public async Task<ActionResult<BudgetCategoryDto>> Create([FromBody] BudgetCategoryCreateDto dto)
49 + {
50 + var userId = CurrentUserId();
51 + if (userId == null) return Unauthorized();
52 +
53 + if (!await IsOrganizerAsync(dto.TripId, userId.Value)) return Forbid();
54 +
55 + var entity = new BudgetCategory
56 + {
57 + TripId = dto.TripId,
58 + Name = new LangStr(dto.Name),
59 + IconName = dto.IconName,
60 + PlannedAmount = dto.PlannedAmount,
61 + DisplayOrder = dto.DisplayOrder,
62 + };
63 + _uow.BudgetCategories.Add(entity);
64 + await _uow.SaveChangesAsync();
65 +
66 + return CreatedAtAction(null, new { id = entity.Id }, MapToDto(entity, null));
67 + }
68 +
69 + [HttpPut("{id:guid}")]
70 + public async Task<IActionResult> Update(Guid id, [FromBody] BudgetCategoryCreateDto dto)
71 + {
72 + var userId = CurrentUserId();
73 + if (userId == null) return Unauthorized();
74 +
75 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
76 + if (existing == null) return NotFound();
77 +
78 + if (!await IsOrganizerAsync(existing.TripId, userId.Value)) return Forbid();
79 +
80 + existing.Name = new LangStr(dto.Name);
81 + existing.IconName = dto.IconName;
82 + existing.PlannedAmount = dto.PlannedAmount;
83 + existing.DisplayOrder = dto.DisplayOrder;
84 +
85 + _uow.BudgetCategories.Update(existing);
86 + await _uow.SaveChangesAsync();
87 + return NoContent();
88 + }
89 +
90 + [HttpDelete("{id:guid}")]
91 + public async Task<IActionResult> Delete(Guid id)
92 + {
93 + var userId = CurrentUserId();
94 + if (userId == null) return Unauthorized();
95 +
96 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
97 + if (existing == null) return NotFound();
98 +
99 + if (!await IsOrganizerAsync(existing.TripId, userId.Value)) return Forbid();
100 +
101 + await _uow.BudgetCategories.RemoveAsync(id);
102 + await _uow.SaveChangesAsync();
103 + return NoContent();
104 + }
105 +
106 + private Guid? CurrentUserId()
107 + {
108 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
109 + return Guid.TryParse(raw, out var id) ? id : null;
110 + }
111 +
112 + private async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
113 + {
114 + var trip = await _uow.Trips.GetByIdAsync(tripId);
115 + if (trip == null) return false;
116 + if (trip.CreatedById == userId) return true;
117 + var all = await _uow.Participants.GetAllAsync();
118 + return all.Any(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
119 + }
120 +
121 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
122 + {
123 + var trip = await _uow.Trips.GetByIdAsync(tripId);
124 + if (trip == null) return false;
125 + if (trip.CreatedById == userId) return true;
126 + var all = await _uow.Participants.GetAllAsync();
127 + return all.Any(p => p.TripId == tripId
128 + && p.UserId == userId
129 + && p.IsActive
130 + && p.Role == EParticipantRole.Organizer);
131 + }
132 +
133 + private static BudgetCategoryDto MapToDto(BudgetCategory c, IReadOnlyDictionary<Guid, decimal>? spent)
134 + {
135 + return new BudgetCategoryDto
136 + {
137 + Id = c.Id,
138 + TripId = c.TripId,
139 + Name = c.Name.Translate() ?? string.Empty,
140 + IconName = c.IconName,
141 + PlannedAmount = c.PlannedAmount,
142 + SpentAmount = spent != null && spent.TryGetValue(c.Id, out var amt) ? amt : 0m,
143 + DisplayOrder = c.DisplayOrder,
144 + };
145 + }
146 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/InvitationsController.cs +198 −0
@@ -0,0 +1,198 @@
1 +using System.Security.Claims;
2 +using System.Security.Cryptography;
3 +using Asp.Versioning;
4 +using MediatR;
5 +using Microsoft.AspNetCore.Authentication.JwtBearer;
6 +using Microsoft.AspNetCore.Authorization;
7 +using Microsoft.AspNetCore.Mvc;
8 +using SplitApp.Modules.Trips.Api.Dto.v1;
9 +using SplitApp.Modules.Trips.Application.Contracts;
10 +using SplitApp.Modules.Trips.Domain.Entities;
11 +using SplitApp.Modules.Trips.Domain.Enums;
12 +using SplitApp.Shared.Contracts.Users.Queries;
13 +
14 +namespace SplitApp.Modules.Trips.Api.Controllers;
15 +
16 +[ApiVersion("1.0")]
17 +[ApiController]
18 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
19 +[Route("api/v{version:apiVersion}/[controller]")]
20 +public class InvitationsController : ControllerBase
21 +{
22 + private readonly ITripsUnitOfWork _uow;
23 + private readonly IMediator _mediator;
24 +
25 + public InvitationsController(ITripsUnitOfWork uow, IMediator mediator)
26 + {
27 + _uow = uow;
28 + _mediator = mediator;
29 + }
30 +
31 + [HttpPost]
32 + public async Task<ActionResult<InvitationDto>> Create([FromBody] InvitationCreateDto dto)
33 + {
34 + var userId = CurrentUserId();
35 + if (userId == null) return Unauthorized();
36 +
37 + if (!await IsOrganizerAsync(dto.TripId, userId.Value)) return Forbid();
38 +
39 + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
40 + .Replace("+", "-").Replace("/", "_").TrimEnd('=');
41 +
42 + var invitation = new TripInvitation
43 + {
44 + TripId = dto.TripId,
45 + InvitedByUserId = userId.Value,
46 + Token = token,
47 + Status = EInvitationStatus.Pending,
48 + ExpiresAt = DateTime.UtcNow.AddDays(7),
49 + };
50 +
51 + _uow.Invitations.Add(invitation);
52 + await _uow.SaveChangesAsync();
53 +
54 + return CreatedAtAction(nameof(GetByToken), new { token = invitation.Token }, await BuildDtoAsync(invitation));
55 + }
56 +
57 + [HttpGet("{token}")]
58 + [AllowAnonymous]
59 + public async Task<ActionResult<InvitationDto>> GetByToken(string token)
60 + {
61 + var invitation = await FindByTokenAsync(token);
62 + if (invitation == null) return NotFound();
63 +
64 + return Ok(await BuildDtoAsync(invitation));
65 + }
66 +
67 + [HttpPost("{token}/accept")]
68 + public async Task<IActionResult> Accept(string token)
69 + {
70 + var userId = CurrentUserId();
71 + if (userId == null) return Unauthorized();
72 +
73 + var invitation = await FindByTokenAsync(token);
74 + if (invitation == null) return NotFound();
75 +
76 + if (invitation.Status != EInvitationStatus.Pending)
77 + return BadRequest("Invitation is no longer pending.");
78 +
79 + if (invitation.ExpiresAt < DateTime.UtcNow)
80 + {
81 + invitation.Status = EInvitationStatus.Expired;
82 + _uow.Invitations.Update(invitation);
83 + await _uow.SaveChangesAsync();
84 + return BadRequest("Invitation has expired.");
85 + }
86 +
87 + var allParticipants = await _uow.Participants.GetAllAsync();
88 + var existingActive = allParticipants.FirstOrDefault(p =>
89 + p.TripId == invitation.TripId && p.UserId == userId.Value && p.IsActive);
90 + var existingInactive = allParticipants.FirstOrDefault(p =>
91 + p.TripId == invitation.TripId && p.UserId == userId.Value && !p.IsActive);
92 +
93 + if (existingActive != null)
94 + {
95 + return BadRequest("You are already a participant in this trip.");
96 + }
97 +
98 + if (existingInactive != null)
99 + {
100 + existingInactive.IsActive = true;
101 + existingInactive.LeftAt = null;
102 + _uow.Participants.Update(existingInactive);
103 + }
104 + else
105 + {
106 + _uow.Participants.Add(new TripParticipant
107 + {
108 + TripId = invitation.TripId,
109 + UserId = userId.Value,
110 + Role = EParticipantRole.Participant,
111 + JoinedAt = DateTime.UtcNow,
112 + IsActive = true,
113 + });
114 + }
115 +
116 + invitation.Status = EInvitationStatus.Accepted;
117 + invitation.RespondedAt = DateTime.UtcNow;
118 + _uow.Invitations.Update(invitation);
119 +
120 + await _uow.SaveChangesAsync();
121 + return Ok();
122 + }
123 +
124 + [HttpPost("{token}/decline")]
125 + public async Task<IActionResult> Decline(string token)
126 + {
127 + var invitation = await FindByTokenAsync(token);
128 + if (invitation == null) return NotFound();
129 +
130 + if (invitation.Status != EInvitationStatus.Pending)
131 + return BadRequest("Invitation is no longer pending.");
132 +
133 + invitation.Status = EInvitationStatus.Declined;
134 + invitation.RespondedAt = DateTime.UtcNow;
135 + _uow.Invitations.Update(invitation);
136 + await _uow.SaveChangesAsync();
137 + return Ok();
138 + }
139 +
140 + [HttpPost("{token}/revoke")]
141 + public async Task<IActionResult> Revoke(string token)
142 + {
143 + var userId = CurrentUserId();
144 + if (userId == null) return Unauthorized();
145 +
146 + var invitation = await FindByTokenAsync(token);
147 + if (invitation == null) return NotFound();
148 +
149 + if (!await IsOrganizerAsync(invitation.TripId, userId.Value)) return Forbid();
150 +
151 + invitation.Status = EInvitationStatus.Revoked;
152 + invitation.RespondedAt = DateTime.UtcNow;
153 + _uow.Invitations.Update(invitation);
154 + await _uow.SaveChangesAsync();
155 + return Ok();
156 + }
157 +
158 + private Guid? CurrentUserId()
159 + {
160 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
161 + return Guid.TryParse(raw, out var id) ? id : null;
162 + }
163 +
164 + private async Task<TripInvitation?> FindByTokenAsync(string token)
165 + {
166 + var all = await _uow.Invitations.GetAllAsync();
167 + return all.FirstOrDefault(i => i.Token == token);
168 + }
169 +
170 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
171 + {
172 + var trip = await _uow.Trips.GetByIdAsync(tripId);
173 + if (trip == null) return false;
174 + if (trip.CreatedById == userId) return true;
175 + var all = await _uow.Participants.GetAllAsync();
176 + return all.Any(p => p.TripId == tripId
177 + && p.UserId == userId
178 + && p.IsActive
179 + && p.Role == EParticipantRole.Organizer);
180 + }
181 +
182 + private async Task<InvitationDto> BuildDtoAsync(TripInvitation invitation)
183 + {
184 + var trip = await _uow.Trips.GetByIdAsync(invitation.TripId);
185 + var inviter = await _mediator.Send(new GetUserByIdQuery(invitation.InvitedByUserId));
186 +
187 + return new InvitationDto
188 + {
189 + Id = invitation.Id,
190 + TripId = invitation.TripId,
191 + TripName = trip?.Name,
192 + Token = invitation.Token,
193 + Status = invitation.Status.ToString(),
194 + ExpiresAt = invitation.ExpiresAt,
195 + InvitedByUserName = inviter?.DisplayName,
196 + };
197 + }
198 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/PollsController.cs +255 −0
@@ -0,0 +1,255 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +
12 +namespace SplitApp.Modules.Trips.Api.Controllers;
13 +
14 +[ApiVersion("1.0")]
15 +[ApiController]
16 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
17 +[Route("api/v{version:apiVersion}/[controller]")]
18 +public class PollsController : ControllerBase
19 +{
20 + private readonly ITripsUnitOfWork _uow;
21 +
22 + public PollsController(ITripsUnitOfWork uow)
23 + {
24 + _uow = uow;
25 + }
26 +
27 + [HttpGet("trip/{tripId:guid}")]
28 + public async Task<ActionResult<List<PollDto>>> GetForTrip(Guid tripId)
29 + {
30 + var userId = CurrentUserId();
31 + if (userId == null) return Unauthorized();
32 +
33 + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid();
34 +
35 + var allPolls = await _uow.Polls.GetAllAsync();
36 + var polls = allPolls.Where(p => p.TripId == tripId).ToList();
37 + var allOptions = await _uow.PollOptions.GetAllAsync();
38 + var allVotes = await _uow.PollVotes.GetAllAsync();
39 +
40 + return Ok(polls.Select(p => MapPoll(p, allOptions, allVotes, userId.Value)).ToList());
41 + }
42 +
43 + [HttpGet("{id:guid}")]
44 + public async Task<ActionResult<PollDto>> Get(Guid id)
45 + {
46 + var userId = CurrentUserId();
47 + if (userId == null) return Unauthorized();
48 +
49 + var poll = await _uow.Polls.GetByIdAsync(id);
50 + if (poll == null) return NotFound();
51 + if (!await IsParticipantAsync(poll.TripId, userId.Value)) return NotFound();
52 +
53 + var allOptions = await _uow.PollOptions.GetAllAsync();
54 + var allVotes = await _uow.PollVotes.GetAllAsync();
55 + return Ok(MapPoll(poll, allOptions, allVotes, userId.Value));
56 + }
57 +
58 + [HttpPost]
59 + public async Task<ActionResult<PollDto>> Create([FromBody] PollCreateDto dto)
60 + {
61 + var userId = CurrentUserId();
62 + if (userId == null) return Unauthorized();
63 +
64 + if (!await IsParticipantAsync(dto.TripId, userId.Value)) return Forbid();
65 +
66 + if (dto.Options == null || dto.Options.Count < 2)
67 + return BadRequest("Poll must have at least two options.");
68 +
69 + var poll = new TripPoll
70 + {
71 + TripId = dto.TripId,
72 + CreatedByUserId = userId.Value,
73 + Question = dto.Question,
74 + AllowMultipleVotes = dto.AllowMultipleVotes,
75 + IsAnonymous = dto.IsAnonymous,
76 + };
77 + _uow.Polls.Add(poll);
78 +
79 + var order = 0;
80 + foreach (var optionText in dto.Options)
81 + {
82 + _uow.PollOptions.Add(new TripPollOption
83 + {
84 + PollId = poll.Id,
85 + Text = optionText,
86 + DisplayOrder = order++,
87 + });
88 + }
89 +
90 + await _uow.SaveChangesAsync();
91 +
92 + var allOptions = await _uow.PollOptions.GetAllAsync();
93 + var allVotes = await _uow.PollVotes.GetAllAsync();
94 + var dtoResult = MapPoll(poll, allOptions, allVotes, userId.Value);
95 + return CreatedAtAction(nameof(Get), new { id = poll.Id }, dtoResult);
96 + }
97 +
98 + [HttpPost("{id:guid}/vote")]
99 + public async Task<IActionResult> Vote(Guid id, [FromBody] PollVoteRequest request)
100 + {
101 + var userId = CurrentUserId();
102 + if (userId == null) return Unauthorized();
103 +
104 + var poll = await _uow.Polls.GetByIdAsync(id);
105 + if (poll == null) return NotFound();
106 +
107 + if (poll.ClosedAt != null) return BadRequest("Poll is closed.");
108 +
109 + if (!await IsParticipantAsync(poll.TripId, userId.Value)) return Forbid();
110 +
111 + var option = await _uow.PollOptions.GetByIdAsync(request.OptionId);
112 + if (option == null || option.PollId != poll.Id) return BadRequest("Invalid option.");
113 +
114 + var allVotes = await _uow.PollVotes.GetAllAsync();
115 + var pollOptionIds = (await _uow.PollOptions.GetAllAsync())
116 + .Where(o => o.PollId == poll.Id)
117 + .Select(o => o.Id)
118 + .ToHashSet();
119 +
120 + var existingVotesForUser = allVotes
121 + .Where(v => v.UserId == userId.Value && pollOptionIds.Contains(v.PollOptionId))
122 + .ToList();
123 +
124 + if (!poll.AllowMultipleVotes)
125 + {
126 + foreach (var v in existingVotesForUser)
127 + {
128 + await _uow.PollVotes.RemoveAsync(v.Id);
129 + }
130 + }
131 + else if (existingVotesForUser.Any(v => v.PollOptionId == request.OptionId))
132 + {
133 + // Toggle off if same option voted again under multi-vote
134 + var existing = existingVotesForUser.First(v => v.PollOptionId == request.OptionId);
135 + await _uow.PollVotes.RemoveAsync(existing.Id);
136 + await _uow.SaveChangesAsync();
137 + return Ok();
138 + }
139 +
140 + _uow.PollVotes.Add(new TripPollVote
141 + {
142 + PollOptionId = request.OptionId,
143 + UserId = userId.Value,
144 + });
145 +
146 + await _uow.SaveChangesAsync();
147 + return Ok();
148 + }
149 +
150 + [HttpPost("{id:guid}/close")]
151 + public async Task<IActionResult> Close(Guid id)
152 + {
153 + var userId = CurrentUserId();
154 + if (userId == null) return Unauthorized();
155 +
156 + var poll = await _uow.Polls.GetByIdAsync(id);
157 + if (poll == null) return NotFound();
158 +
159 + var canClose = poll.CreatedByUserId == userId.Value
160 + || await IsOrganizerAsync(poll.TripId, userId.Value);
161 + if (!canClose) return Forbid();
162 +
163 + poll.ClosedAt = DateTime.UtcNow;
164 + _uow.Polls.Update(poll);
165 + await _uow.SaveChangesAsync();
166 + return Ok();
167 + }
168 +
169 + [HttpDelete("{id:guid}")]
170 + public async Task<IActionResult> Delete(Guid id)
171 + {
172 + var userId = CurrentUserId();
173 + if (userId == null) return Unauthorized();
174 +
175 + var poll = await _uow.Polls.GetByIdAsync(id);
176 + if (poll == null) return NotFound();
177 +
178 + var canDelete = poll.CreatedByUserId == userId.Value
179 + || await IsOrganizerAsync(poll.TripId, userId.Value);
180 + if (!canDelete) return Forbid();
181 +
182 + var allOptions = await _uow.PollOptions.GetAllAsync();
183 + var allVotes = await _uow.PollVotes.GetAllAsync();
184 + var optionIds = allOptions.Where(o => o.PollId == poll.Id).Select(o => o.Id).ToHashSet();
185 +
186 + foreach (var v in allVotes.Where(v => optionIds.Contains(v.PollOptionId)))
187 + {
188 + await _uow.PollVotes.RemoveAsync(v.Id);
189 + }
190 + foreach (var oid in optionIds)
191 + {
192 + await _uow.PollOptions.RemoveAsync(oid);
193 + }
194 + await _uow.Polls.RemoveAsync(id);
195 + await _uow.SaveChangesAsync();
196 + return NoContent();
197 + }
198 +
199 + private Guid? CurrentUserId()
200 + {
201 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
202 + return Guid.TryParse(raw, out var id) ? id : null;
203 + }
204 +
205 + private async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
206 + {
207 + var trip = await _uow.Trips.GetByIdAsync(tripId);
208 + if (trip == null) return false;
209 + if (trip.CreatedById == userId) return true;
210 + var all = await _uow.Participants.GetAllAsync();
211 + return all.Any(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
212 + }
213 +
214 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
215 + {
216 + var trip = await _uow.Trips.GetByIdAsync(tripId);
217 + if (trip == null) return false;
218 + if (trip.CreatedById == userId) return true;
219 + var all = await _uow.Participants.GetAllAsync();
220 + return all.Any(p => p.TripId == tripId
221 + && p.UserId == userId
222 + && p.IsActive
223 + && p.Role == EParticipantRole.Organizer);
224 + }
225 +
226 + private static PollDto MapPoll(
227 + TripPoll poll,
228 + IEnumerable<TripPollOption> allOptions,
229 + IEnumerable<TripPollVote> allVotes,
230 + Guid currentUserId)
231 + {
232 + var options = allOptions.Where(o => o.PollId == poll.Id).OrderBy(o => o.DisplayOrder).ToList();
233 + var optionIds = options.Select(o => o.Id).ToHashSet();
234 + var votes = allVotes.Where(v => optionIds.Contains(v.PollOptionId)).ToList();
235 +
236 + return new PollDto
237 + {
238 + Id = poll.Id,
239 + TripId = poll.TripId,
240 + CreatedByUserId = poll.CreatedByUserId,
241 + Question = poll.Question,
242 + AllowMultipleVotes = poll.AllowMultipleVotes,
243 + IsAnonymous = poll.IsAnonymous,
244 + ClosedAt = poll.ClosedAt,
245 + Options = options.Select(o => new PollOptionDto
246 + {
247 + Id = o.Id,
248 + Text = o.Text,
249 + DisplayOrder = o.DisplayOrder,
250 + VoteCount = votes.Count(v => v.PollOptionId == o.Id),
251 + VotedByCurrentUser = votes.Any(v => v.PollOptionId == o.Id && v.UserId == currentUserId),
252 + }).ToList(),
253 + };
254 + }
255 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/TripsController.cs +328 −0
@@ -0,0 +1,328 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Expenses.Commands;
12 +using SplitApp.Shared.Contracts.Expenses.Queries;
13 +using SplitApp.Shared.Contracts.Trips.Events;
14 +using SplitApp.Shared.Contracts.Users.Queries;
15 +
16 +namespace SplitApp.Modules.Trips.Api.Controllers;
17 +
18 +[ApiVersion("1.0")]
19 +[ApiController]
20 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
21 +[Route("api/v{version:apiVersion}/[controller]")]
22 +public class TripsController : ControllerBase
23 +{
24 + private readonly ITripsUnitOfWork _uow;
25 + private readonly IMediator _mediator;
26 +
27 + public TripsController(ITripsUnitOfWork uow, IMediator mediator)
28 + {
29 + _uow = uow;
30 + _mediator = mediator;
31 + }
32 +
33 + [HttpGet]
34 + public async Task<ActionResult<IEnumerable<TripDto>>> List()
35 + {
36 + var userId = CurrentUserId();
37 + if (userId == null) return Unauthorized();
38 +
39 + var all = (await _uow.Trips.GetAllAsync()).ToList();
40 + var allParticipants = (await _uow.Participants.GetAllAsync()).ToList();
41 + var participantTripIds = allParticipants
42 + .Where(p => p.UserId == userId.Value && p.IsActive)
43 + .Select(p => p.TripId)
44 + .ToHashSet();
45 +
46 + var visible = all
47 + .Where(t => t.CreatedById == userId.Value || participantTripIds.Contains(t.Id))
48 + .ToList();
49 +
50 + // Cross-module currency lookup so frontend gets defaultCurrencyCode/Symbol.
51 + var currencyIds = visible.Select(t => t.DefaultCurrencyId).Distinct().ToList();
52 + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(currencyIds)))
53 + .ToDictionary(c => c.Id);
54 +
55 + // Per-trip participant counts, no embedded list (keeps payload small for index page).
56 + var perTripParticipantCount = allParticipants
57 + .Where(p => p.IsActive)
58 + .GroupBy(p => p.TripId)
59 + .ToDictionary(g => g.Key, g => g.Count());
60 +
61 + return Ok(visible.Select(t => MapToDto(t, currencies, perTripParticipantCount.GetValueOrDefault(t.Id))));
62 + }
63 +
64 + [HttpGet("{id:guid}")]
65 + public async Task<ActionResult<TripDto>> Get(Guid id)
66 + {
67 + var userId = CurrentUserId();
68 + if (userId == null) return Unauthorized();
69 +
70 + var trip = await _uow.Trips.GetByIdAsync(id);
71 + if (trip == null) return NotFound();
72 + if (!await CanReadAsync(trip, userId.Value)) return Forbid();
73 +
74 + // Currency
75 + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId })))
76 + .ToDictionary(c => c.Id);
77 +
78 + // Participants + cross-module user name lookup
79 + var allParticipants = await _uow.Participants.GetAllAsync();
80 + var tripParticipants = allParticipants
81 + .Where(p => p.TripId == trip.Id)
82 + .OrderBy(p => p.JoinedAt)
83 + .ToList();
84 + var participantDtos = await BuildParticipantDtosAsync(tripParticipants);
85 +
86 + var dto = MapToDto(trip, currencies, tripParticipants.Count(p => p.IsActive));
87 + dto.Participants = participantDtos;
88 + return Ok(dto);
89 + }
90 +
91 + [HttpPost]
92 + public async Task<ActionResult<TripDto>> Create([FromBody] TripCreateDto dto)
93 + {
94 + var userId = CurrentUserId();
95 + if (userId == null) return Unauthorized();
96 +
97 + var trip = new Trip
98 + {
99 + Name = dto.Name,
100 + Description = dto.Description,
101 + Destination = dto.Destination,
102 + StartDate = dto.StartDate,
103 + EndDate = dto.EndDate,
104 + DefaultCurrencyId = dto.DefaultCurrencyId,
105 + CreatedById = userId.Value,
106 + Status = ETripStatus.Active,
107 + };
108 + _uow.Trips.Add(trip);
109 +
110 + _uow.Participants.Add(new TripParticipant
111 + {
112 + TripId = trip.Id,
113 + UserId = userId.Value,
114 + Role = EParticipantRole.Organizer,
115 + JoinedAt = DateTime.UtcNow,
116 + IsActive = true,
117 + });
118 +
119 + await _uow.SaveChangesAsync();
120 +
121 + // Reload with full hydration so frontend gets a complete TripDto on POST response.
122 + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId })))
123 + .ToDictionary(c => c.Id);
124 + var participants = (await _uow.Participants.GetAllAsync())
125 + .Where(p => p.TripId == trip.Id).OrderBy(p => p.JoinedAt).ToList();
126 + var participantDtos = await BuildParticipantDtosAsync(participants);
127 + var responseDto = MapToDto(trip, currencies, participants.Count(p => p.IsActive));
128 + responseDto.Participants = participantDtos;
129 +
130 + return CreatedAtAction(nameof(Get), new { id = trip.Id, version = "1.0" }, responseDto);
131 + }
132 +
133 + [HttpPut("{id:guid}")]
134 + public async Task<IActionResult> Update(Guid id, [FromBody] TripUpdateDto dto)
135 + {
136 + var userId = CurrentUserId();
137 + if (userId == null) return Unauthorized();
138 + if (dto.Id != Guid.Empty && dto.Id != id) return BadRequest();
139 +
140 + var trip = await _uow.Trips.GetByIdAsync(id);
141 + if (trip == null) return NotFound();
142 + if (trip.CreatedById != userId.Value) return Forbid();
143 +
144 + trip.Name = dto.Name;
145 + trip.Description = dto.Description;
146 + trip.Destination = dto.Destination;
147 + trip.StartDate = dto.StartDate;
148 + trip.EndDate = dto.EndDate;
149 + trip.DefaultCurrencyId = dto.DefaultCurrencyId;
150 + if (!string.IsNullOrEmpty(dto.Status) && Enum.TryParse<ETripStatus>(dto.Status, true, out var newStatus))
151 + {
152 + trip.Status = newStatus;
153 + }
154 + _uow.Trips.Update(trip);
155 + await _uow.SaveChangesAsync();
156 + return NoContent();
157 + }
158 +
159 + [HttpDelete("{id:guid}")]
160 + public async Task<IActionResult> Delete(Guid id)
161 + {
162 + var userId = CurrentUserId();
163 + if (userId == null) return Unauthorized();
164 +
165 + var trip = await _uow.Trips.GetByIdAsync(id);
166 + if (trip == null) return NotFound();
167 + if (trip.CreatedById != userId.Value) return Forbid();
168 +
169 + await _uow.Trips.RemoveAsync(id);
170 + await _uow.SaveChangesAsync();
171 +
172 + await _mediator.Publish(new TripDeletedEvent(id));
173 + return NoContent();
174 + }
175 +
176 + [HttpGet("{tripId:guid}/participants")]
177 + public async Task<ActionResult<List<TripParticipantDto>>> GetParticipants(Guid tripId)
178 + {
179 + var userId = CurrentUserId();
180 + if (userId == null) return Unauthorized();
181 +
182 + var trip = await _uow.Trips.GetByIdAsync(tripId);
183 + if (trip == null) return NotFound();
184 + if (!await CanReadAsync(trip, userId.Value)) return Forbid();
185 +
186 + var rows = (await _uow.Participants.GetAllAsync())
187 + .Where(p => p.TripId == tripId)
188 + .OrderBy(p => p.JoinedAt)
189 + .ToList();
190 + return Ok(await BuildParticipantDtosAsync(rows));
191 + }
192 +
193 + [HttpPost("{id:guid}/finalize")]
194 + public async Task<IActionResult> Finalize(Guid id)
195 + {
196 + var userId = CurrentUserId();
197 + if (userId == null) return Unauthorized();
198 +
199 + var trip = await _uow.Trips.GetByIdAsync(id);
200 + if (trip == null) return NotFound();
201 + if (trip.CreatedById != userId.Value) return Forbid();
202 + if (trip.Status != ETripStatus.Active) return BadRequest(new { error = "Trip must be active to finalize." });
203 +
204 + trip.Status = ETripStatus.Finalizing;
205 + _uow.Trips.Update(trip);
206 + await _uow.SaveChangesAsync();
207 +
208 + // Auto-create the settlement plan + concrete payments (phase-2 parity).
209 + // The Vue front shows real Mark Paid / Confirm Receipt UI only when latestPlan is non-null;
210 + // without this step it falls back to a "preview" view with no per-user actions.
211 + var planId = await _mediator.Send(new CalculateSettlementCommand(id, userId.Value));
212 +
213 + // No outstanding balances → trip is already settled. Mark accordingly.
214 + if (planId == null)
215 + {
216 + trip.Status = ETripStatus.Settled;
217 + _uow.Trips.Update(trip);
218 + await _uow.SaveChangesAsync();
219 + }
220 +
221 + return Ok();
222 + }
223 +
224 + [HttpPost("{id:guid}/reopen")]
225 + public async Task<IActionResult> Reopen(Guid id)
226 + {
227 + var userId = CurrentUserId();
228 + if (userId == null) return Unauthorized();
229 +
230 + var trip = await _uow.Trips.GetByIdAsync(id);
231 + if (trip == null) return NotFound();
232 + if (trip.CreatedById != userId.Value) return Forbid();
233 + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled)
234 + return BadRequest(new { error = "Trip can only be reopened from Finalizing or Settled." });
235 +
236 + // Drop the existing settlement plan + payments so the next Finalize starts fresh.
237 + // Returns false if any payment is already Confirmed — phase-2 parity.
238 + var removed = await _mediator.Send(new RemoveSettlementPlanCommand(id));
239 + if (!removed)
240 + {
241 + return BadRequest(new { error = "Cannot reopen: settlement has confirmed payments." });
242 + }
243 +
244 + trip.Status = ETripStatus.Active;
245 + _uow.Trips.Update(trip);
246 + await _uow.SaveChangesAsync();
247 + return Ok();
248 + }
249 +
250 + [HttpDelete("{tripId:guid}/participants/{userId:guid}")]
251 + public async Task<IActionResult> RemoveParticipant(Guid tripId, Guid userId)
252 + {
253 + var currentUserId = CurrentUserId();
254 + if (currentUserId == null) return Unauthorized();
255 +
256 + var trip = await _uow.Trips.GetByIdAsync(tripId);
257 + if (trip == null) return NotFound();
258 + if (trip.CreatedById != currentUserId.Value) return Forbid();
259 + if (userId == currentUserId.Value) return BadRequest(new { error = "Cannot remove yourself." });
260 +
261 + var participant = (await _uow.Participants.GetAllAsync())
262 + .FirstOrDefault(p => p.TripId == tripId && p.UserId == userId);
263 + if (participant == null) return NotFound();
264 + if (participant.Role == EParticipantRole.Organizer) return BadRequest(new { error = "Cannot remove an organizer." });
265 +
266 + participant.IsActive = false;
267 + participant.LeftAt = DateTime.UtcNow;
268 + _uow.Participants.Update(participant);
269 + await _uow.SaveChangesAsync();
270 + return NoContent();
271 + }
272 +
273 + private Guid? CurrentUserId()
274 + {
275 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
276 + return Guid.TryParse(raw, out var id) ? id : null;
277 + }
278 +
279 + private async Task<bool> CanReadAsync(Trip trip, Guid userId)
280 + {
281 + if (trip.CreatedById == userId) return true;
282 + var allParticipants = await _uow.Participants.GetAllAsync();
283 + return allParticipants.Any(p => p.TripId == trip.Id && p.UserId == userId && p.IsActive);
284 + }
285 +
286 + private async Task<List<TripParticipantDto>> BuildParticipantDtosAsync(IList<TripParticipant> rows)
287 + {
288 + if (rows.Count == 0) return new List<TripParticipantDto>();
289 + var userIds = rows.Select(p => p.UserId).Distinct().ToList();
290 + var users = (await _mediator.Send(new GetUsersByIdsQuery(userIds)))
291 + .ToDictionary(u => u.Id);
292 + return rows.Select(p =>
293 + {
294 + users.TryGetValue(p.UserId, out var u);
295 + return new TripParticipantDto
296 + {
297 + Id = p.Id,
298 + TripId = p.TripId,
299 + UserId = p.UserId,
300 + UserName = u?.DisplayName,
301 + UserEmail = u?.Email,
302 + Role = p.Role.ToString(),
303 + Nickname = p.Nickname,
304 + JoinedAt = p.JoinedAt,
305 + IsActive = p.IsActive,
306 + };
307 + }).ToList();
308 + }
309 +
310 + private static TripDto MapToDto(
311 + Trip trip,
312 + IDictionary<Guid, SplitApp.Shared.Contracts.Expenses.CurrencyDto> currencies,
313 + int participantCount) => new()
314 + {
315 + Id = trip.Id,
316 + Name = trip.Name,
317 + Description = trip.Description,
318 + Destination = trip.Destination,
319 + StartDate = trip.StartDate,
320 + EndDate = trip.EndDate,
321 + Status = trip.Status.ToString(),
322 + DefaultCurrencyId = trip.DefaultCurrencyId,
323 + DefaultCurrencyCode = currencies.TryGetValue(trip.DefaultCurrencyId, out var c) ? c.Code : null,
324 + DefaultCurrencySymbol = currencies.TryGetValue(trip.DefaultCurrencyId, out var c2) ? c2.Symbol : null,
325 + CreatedById = trip.CreatedById,
326 + ParticipantCount = participantCount,
327 + };
328 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/WishlistController.cs +242 −0
@@ -0,0 +1,242 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Users.Queries;
12 +
13 +namespace SplitApp.Modules.Trips.Api.Controllers;
14 +
15 +[ApiVersion("1.0")]
16 +[ApiController]
17 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
18 +[Route("api/v{version:apiVersion}/[controller]")]
19 +public class WishlistController : ControllerBase
20 +{
21 + private readonly ITripsUnitOfWork _uow;
22 + private readonly IMediator _mediator;
23 +
24 + public WishlistController(ITripsUnitOfWork uow, IMediator mediator)
25 + {
26 + _uow = uow;
27 + _mediator = mediator;
28 + }
29 +
30 + [HttpGet("trip/{tripId:guid}")]
31 + public async Task<ActionResult<List<WishlistItemDto>>> GetForTrip(Guid tripId)
32 + {
33 + var userId = CurrentUserId();
34 + if (userId == null) return Unauthorized();
35 +
36 + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid();
37 +
38 + var allItems = await _uow.WishlistItems.GetAllAsync();
39 + var items = allItems.Where(i => i.TripId == tripId).OrderBy(i => i.DisplayOrder).ToList();
40 + var allVotes = await _uow.WishlistVotes.GetAllAsync();
41 +
42 + var addedByIds = items.Select(i => i.AddedByUserId).Distinct().ToList();
43 + var users = addedByIds.Count == 0
44 + ? new List<SplitApp.Shared.Contracts.Users.UserDto>()
45 + : (await _mediator.Send(new GetUsersByIdsQuery(addedByIds))).ToList();
46 + var nameLookup = users.ToDictionary(u => u.Id, u => u.DisplayName);
47 +
48 + return Ok(items.Select(i => MapItem(i, allVotes, userId.Value, nameLookup)).ToList());
49 + }
50 +
51 + [HttpPost]
52 + public async Task<ActionResult<WishlistItemDto>> Create([FromBody] WishlistItemCreateDto dto)
53 + {
54 + var userId = CurrentUserId();
55 + if (userId == null) return Unauthorized();
56 +
57 + if (!await IsParticipantAsync(dto.TripId, userId.Value)) return Forbid();
58 + if (!Enum.TryParse<EWishlistCategory>(dto.Category, true, out var category))
59 + return BadRequest($"Unknown category '{dto.Category}'.");
60 + if (!Enum.TryParse<EWishlistPriority>(dto.Priority, true, out var priority))
61 + return BadRequest($"Unknown priority '{dto.Priority}'.");
62 +
63 + var entity = new TripWishlistItem
64 + {
65 + TripId = dto.TripId,
66 + AddedByUserId = userId.Value,
67 + Title = dto.Title,
68 + Description = dto.Description,
69 + Category = category,
70 + Priority = priority,
71 + EstimatedCost = dto.EstimatedCost,
72 + Url = dto.Url,
73 + Location = dto.Location,
74 + IsCompleted = false,
75 + DisplayOrder = 0,
76 + };
77 + _uow.WishlistItems.Add(entity);
78 + await _uow.SaveChangesAsync();
79 +
80 + var votes = await _uow.WishlistVotes.GetAllAsync();
81 + return CreatedAtAction(null, new { id = entity.Id }, MapItem(entity, votes, userId.Value, null));
82 + }
83 +
84 + [HttpPut("{id:guid}")]
85 + public async Task<IActionResult> Update(Guid id, [FromBody] WishlistItemCreateDto dto)
86 + {
87 + var userId = CurrentUserId();
88 + if (userId == null) return Unauthorized();
89 +
90 + var existing = await _uow.WishlistItems.GetByIdAsync(id);
91 + if (existing == null) return NotFound();
92 +
93 + var canEdit = existing.AddedByUserId == userId.Value
94 + || await IsOrganizerAsync(existing.TripId, userId.Value);
95 + if (!canEdit) return Forbid();
96 +
97 + if (!Enum.TryParse<EWishlistCategory>(dto.Category, true, out var category))
98 + return BadRequest($"Unknown category '{dto.Category}'.");
99 + if (!Enum.TryParse<EWishlistPriority>(dto.Priority, true, out var priority))
100 + return BadRequest($"Unknown priority '{dto.Priority}'.");
101 +
102 + existing.Title = dto.Title;
103 + existing.Description = dto.Description;
104 + existing.Category = category;
105 + existing.Priority = priority;
106 + existing.EstimatedCost = dto.EstimatedCost;
107 + existing.Url = dto.Url;
108 + existing.Location = dto.Location;
109 +
110 + _uow.WishlistItems.Update(existing);
111 + await _uow.SaveChangesAsync();
112 + return NoContent();
113 + }
114 +
115 + [HttpDelete("{id:guid}")]
116 + public async Task<IActionResult> Delete(Guid id)
117 + {
118 + var userId = CurrentUserId();
119 + if (userId == null) return Unauthorized();
120 +
121 + var existing = await _uow.WishlistItems.GetByIdAsync(id);
122 + if (existing == null) return NotFound();
123 +
124 + var canDelete = existing.AddedByUserId == userId.Value
125 + || await IsOrganizerAsync(existing.TripId, userId.Value);
126 + if (!canDelete) return Forbid();
127 +
128 + var allVotes = await _uow.WishlistVotes.GetAllAsync();
129 + foreach (var v in allVotes.Where(v => v.WishlistItemId == id))
130 + {
131 + await _uow.WishlistVotes.RemoveAsync(v.Id);
132 + }
133 + await _uow.WishlistItems.RemoveAsync(id);
134 + await _uow.SaveChangesAsync();
135 + return NoContent();
136 + }
137 +
138 + [HttpPost("{id:guid}/vote")]
139 + public async Task<IActionResult> ToggleVote(Guid id)
140 + {
141 + var userId = CurrentUserId();
142 + if (userId == null) return Unauthorized();
143 +
144 + var item = await _uow.WishlistItems.GetByIdAsync(id);
145 + if (item == null) return NotFound();
146 +
147 + if (!await IsParticipantAsync(item.TripId, userId.Value)) return Forbid();
148 +
149 + var allVotes = await _uow.WishlistVotes.GetAllAsync();
150 + var existing = allVotes.FirstOrDefault(v => v.WishlistItemId == id && v.UserId == userId.Value);
151 + if (existing != null)
152 + {
153 + await _uow.WishlistVotes.RemoveAsync(existing.Id);
154 + }
155 + else
156 + {
157 + _uow.WishlistVotes.Add(new TripWishlistVote
158 + {
159 + WishlistItemId = id,
160 + UserId = userId.Value,
161 + IsInterested = true,
162 + });
163 + }
164 +
165 + await _uow.SaveChangesAsync();
166 + return Ok();
167 + }
168 +
169 + [HttpPost("{id:guid}/complete")]
170 + public async Task<IActionResult> ToggleComplete(Guid id)
171 + {
172 + var userId = CurrentUserId();
173 + if (userId == null) return Unauthorized();
174 +
175 + var item = await _uow.WishlistItems.GetByIdAsync(id);
176 + if (item == null) return NotFound();
177 +
178 + var canToggle = item.AddedByUserId == userId.Value
179 + || await IsOrganizerAsync(item.TripId, userId.Value);
180 + if (!canToggle) return Forbid();
181 +
182 + item.IsCompleted = !item.IsCompleted;
183 + item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
184 + _uow.WishlistItems.Update(item);
185 + await _uow.SaveChangesAsync();
186 + return Ok();
187 + }
188 +
189 + private Guid? CurrentUserId()
190 + {
191 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
192 + return Guid.TryParse(raw, out var id) ? id : null;
193 + }
194 +
195 + private async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
196 + {
197 + var trip = await _uow.Trips.GetByIdAsync(tripId);
198 + if (trip == null) return false;
199 + if (trip.CreatedById == userId) return true;
200 + var all = await _uow.Participants.GetAllAsync();
201 + return all.Any(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
202 + }
203 +
204 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
205 + {
206 + var trip = await _uow.Trips.GetByIdAsync(tripId);
207 + if (trip == null) return false;
208 + if (trip.CreatedById == userId) return true;
209 + var all = await _uow.Participants.GetAllAsync();
210 + return all.Any(p => p.TripId == tripId
211 + && p.UserId == userId
212 + && p.IsActive
213 + && p.Role == EParticipantRole.Organizer);
214 + }
215 +
216 + private static WishlistItemDto MapItem(
217 + TripWishlistItem item,
218 + IEnumerable<TripWishlistVote> allVotes,
219 + Guid currentUserId,
220 + IDictionary<Guid, string>? userNames)
221 + {
222 + var votes = allVotes.Where(v => v.WishlistItemId == item.Id).ToList();
223 + return new WishlistItemDto
224 + {
225 + Id = item.Id,
226 + TripId = item.TripId,
227 + AddedByUserId = item.AddedByUserId,
228 + AddedByUserName = userNames != null && userNames.TryGetValue(item.AddedByUserId, out var name) ? name : null,
229 + Title = item.Title,
230 + Description = item.Description,
231 + Category = item.Category.ToString(),
232 + Priority = item.Priority.ToString(),
233 + EstimatedCost = item.EstimatedCost,
234 + Url = item.Url,
235 + Location = item.Location,
236 + IsCompleted = item.IsCompleted,
237 + VoteCount = votes.Count,
238 + UserHasVoted = votes.Any(v => v.UserId == currentUserId),
239 + DisplayOrder = item.DisplayOrder,
240 + };
241 + }
242 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/BudgetCategoryDto.cs +21 −0
@@ -0,0 +1,21 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class BudgetCategoryDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public string Name { get; set; } = default!;
8 + public string? IconName { get; set; }
9 + public decimal? PlannedAmount { get; set; }
10 + public decimal SpentAmount { get; set; }
11 + public int DisplayOrder { get; set; }
12 +}
13 +
14 +public class BudgetCategoryCreateDto
15 +{
16 + public Guid TripId { get; set; }
17 + public string Name { get; set; } = default!;
18 + public string? IconName { get; set; }
19 + public decimal? PlannedAmount { get; set; }
20 + public int DisplayOrder { get; set; }
21 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/InvitationDto.cs +17 −0
@@ -0,0 +1,17 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class InvitationDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public string? TripName { get; set; }
8 + public string Token { get; set; } = default!;
9 + public string Status { get; set; } = default!;
10 + public DateTime ExpiresAt { get; set; }
11 + public string? InvitedByUserName { get; set; }
12 +}
13 +
14 +public class InvitationCreateDto
15 +{
16 + public Guid TripId { get; set; }
17 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/PollDto.cs +36 −0
@@ -0,0 +1,36 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class PollDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public Guid CreatedByUserId { get; set; }
8 + public string Question { get; set; } = default!;
9 + public bool AllowMultipleVotes { get; set; }
10 + public bool IsAnonymous { get; set; }
11 + public DateTime? ClosedAt { get; set; }
12 + public List<PollOptionDto>? Options { get; set; }
13 +}
14 +
15 +public class PollOptionDto
16 +{
17 + public Guid Id { get; set; }
18 + public string Text { get; set; } = default!;
19 + public int VoteCount { get; set; }
20 + public bool VotedByCurrentUser { get; set; }
21 + public int DisplayOrder { get; set; }
22 +}
23 +
24 +public class PollCreateDto
25 +{
26 + public Guid TripId { get; set; }
27 + public string Question { get; set; } = default!;
28 + public bool AllowMultipleVotes { get; set; }
29 + public bool IsAnonymous { get; set; }
30 + public List<string> Options { get; set; } = default!;
31 +}
32 +
33 +public class PollVoteRequest
34 +{
35 + public Guid OptionId { get; set; }
36 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/TripDto.cs +53 −0
@@ -0,0 +1,53 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class TripDto
4 +{
5 + public Guid Id { get; set; }
6 + public string Name { get; set; } = default!;
7 + public string? Description { get; set; }
8 + public string? Destination { get; set; }
9 + public DateTime? StartDate { get; set; }
10 + public DateTime? EndDate { get; set; }
11 + public string Status { get; set; } = default!;
12 + public Guid DefaultCurrencyId { get; set; }
13 + public string? DefaultCurrencyCode { get; set; }
14 + public string? DefaultCurrencySymbol { get; set; }
15 + public Guid CreatedById { get; set; }
16 + public int ParticipantCount { get; set; }
17 + public List<TripParticipantDto>? Participants { get; set; }
18 +}
19 +
20 +public class TripParticipantDto
21 +{
22 + public Guid Id { get; set; }
23 + public Guid TripId { get; set; }
24 + public Guid UserId { get; set; }
25 + public string? UserName { get; set; }
26 + public string? UserEmail { get; set; }
27 + public string Role { get; set; } = default!;
28 + public string? Nickname { get; set; }
29 + public DateTime JoinedAt { get; set; }
30 + public bool IsActive { get; set; }
31 +}
32 +
33 +public class TripCreateDto
34 +{
35 + public string Name { get; set; } = default!;
36 + public string? Description { get; set; }
37 + public string? Destination { get; set; }
38 + public DateTime? StartDate { get; set; }
39 + public DateTime? EndDate { get; set; }
40 + public Guid DefaultCurrencyId { get; set; }
41 +}
42 +
43 +public class TripUpdateDto
44 +{
45 + public Guid Id { get; set; }
46 + public string Name { get; set; } = default!;
47 + public string? Description { get; set; }
48 + public string? Destination { get; set; }
49 + public DateTime? StartDate { get; set; }
50 + public DateTime? EndDate { get; set; }
51 + public string? Status { get; set; }
52 + public Guid DefaultCurrencyId { get; set; }
53 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/WishlistItemDto.cs +32 −0
@@ -0,0 +1,32 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class WishlistItemDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public Guid AddedByUserId { get; set; }
8 + public string? AddedByUserName { get; set; }
9 + public string Title { get; set; } = default!;
10 + public string? Description { get; set; }
11 + public string Category { get; set; } = default!;
12 + public string Priority { get; set; } = default!;
13 + public decimal? EstimatedCost { get; set; }
14 + public string? Url { get; set; }
15 + public string? Location { get; set; }
16 + public bool IsCompleted { get; set; }
17 + public int VoteCount { get; set; }
18 + public bool UserHasVoted { get; set; }
19 + public int DisplayOrder { get; set; }
20 +}
21 +
22 +public class WishlistItemCreateDto
23 +{
24 + public Guid TripId { get; set; }
25 + public string Title { get; set; } = default!;
26 + public string? Description { get; set; }
27 + public string Category { get; set; } = default!;
28 + public string Priority { get; set; } = default!;
29 + public decimal? EstimatedCost { get; set; }
30 + public string? Url { get; set; }
31 + public string? Location { get; set; }
32 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/ExampleJsInterop.cs +31 −0
@@ -0,0 +1,31 @@
1 +using Microsoft.JSInterop;
2 +
3 +namespace SplitApp.Modules.Trips.Api;
4 +
5 +// This class provides an example of how JavaScript functionality can be wrapped
6 +// in a .NET class for easy consumption. The associated JavaScript module is
7 +// loaded on demand when first needed.
8 +//
9 +// This class can be registered as scoped DI service and then injected into Blazor
10 +// components for use.
11 +
12 +public class ExampleJsInterop(IJSRuntime jsRuntime) : IAsyncDisposable
13 +{
14 + private readonly Lazy<Task<IJSObjectReference>> moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
15 + "import", "./_content/SplitApp.Modules.Trips.Api/exampleJsInterop.js").AsTask());
16 +
17 + public async ValueTask<string> Prompt(string message)
18 + {
19 + var module = await moduleTask.Value;
20 + return await module.InvokeAsync<string>("showPrompt", message);
21 + }
22 +
23 + public async ValueTask DisposeAsync()
24 + {
25 + if (moduleTask.IsValueCreated)
26 + {
27 + var module = await moduleTask.Value;
28 + await module.DisposeAsync();
29 + }
30 + }
31 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/SplitApp.Modules.Trips.Api.csproj +26 −0
@@ -0,0 +1,26 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Razor">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + </PropertyGroup>
8 +
9 +
10 + <ItemGroup>
11 + <SupportedPlatform Include="browser" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
18 + </ItemGroup>
19 +
20 + <ItemGroup>
21 + <ProjectReference Include="..\SplitApp.Modules.Trips.Application\SplitApp.Modules.Trips.Application.csproj" />
22 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
23 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
24 + </ItemGroup>
25 +
26 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/_Imports.razor +1 −0
@@ -0,0 +1 @@
1 +@using Microsoft.AspNetCore.Components.Web
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/background.png +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/exampleJsInterop.js +6 −0
@@ -0,0 +1,6 @@
1 +// This is a JavaScript module that is loaded on demand. It can export any number of
2 +// functions, and may import other JavaScript modules if required.
3 +
4 +export function showPrompt(message) {
5 + return prompt(message, 'Type anything here');
6 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Contracts/ITripsUnitOfWork.cs +18 −0
@@ -0,0 +1,18 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Trips.Application.Contracts;
6 +
7 +public interface ITripsUnitOfWork : IUnitOfWork
8 +{
9 + IBaseRepository<Trip> Trips { get; }
10 + IBaseRepository<TripParticipant> Participants { get; }
11 + IBaseRepository<TripInvitation> Invitations { get; }
12 + IBaseRepository<BudgetCategory> BudgetCategories { get; }
13 + IBaseRepository<TripPoll> Polls { get; }
14 + IBaseRepository<TripPollOption> PollOptions { get; }
15 + IBaseRepository<TripPollVote> PollVotes { get; }
16 + IBaseRepository<TripWishlistItem> WishlistItems { get; }
17 + IBaseRepository<TripWishlistVote> WishlistVotes { get; }
18 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Handlers/GetTripByIdHandler.cs +23 −0
@@ -0,0 +1,23 @@
1 +using MediatR;
2 +using SplitApp.Modules.Trips.Application.Contracts;
3 +using SplitApp.Shared.Contracts.Trips;
4 +using SplitApp.Shared.Contracts.Trips.Queries;
5 +
6 +namespace SplitApp.Modules.Trips.Application.Handlers;
7 +
8 +public class GetTripByIdHandler : IRequestHandler<GetTripByIdQuery, TripSummaryDto?>
9 +{
10 + private readonly ITripsUnitOfWork _uow;
11 +
12 + public GetTripByIdHandler(ITripsUnitOfWork uow)
13 + {
14 + _uow = uow;
15 + }
16 +
17 + public async Task<TripSummaryDto?> Handle(GetTripByIdQuery request, CancellationToken cancellationToken)
18 + {
19 + var trip = await _uow.Trips.GetByIdAsync(request.TripId);
20 + if (trip == null) return null;
21 + return new TripSummaryDto(trip.Id, trip.CreatedById, trip.Name, trip.DefaultCurrencyId);
22 + }
23 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/SplitApp.Modules.Trips.Application.csproj +19 −0
@@ -0,0 +1,19 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Trips.Domain\SplitApp.Modules.Trips.Domain.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="MediatR" Version="12.4.1" />
11 + </ItemGroup>
12 +
13 + <PropertyGroup>
14 + <TargetFramework>net10.0</TargetFramework>
15 + <ImplicitUsings>enable</ImplicitUsings>
16 + <Nullable>enable</Nullable>
17 + </PropertyGroup>
18 +
19 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/TripsModuleMarker.cs +4 −0
@@ -0,0 +1,4 @@
1 +namespace SplitApp.Modules.Trips.Application;
2 +
3 +/// <summary>Assembly marker for MediatR handler scanning. Must remain in the Application assembly.</summary>
4 +public sealed class TripsModuleMarker;
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/BudgetCategory.cs +25 −0
@@ -0,0 +1,25 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Shared.Kernel.Domain;
5 +using SplitApp.Shared.Kernel.Localization;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class BudgetCategory : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public LangStr Name { get; set; } = new();
15 +
16 + [MaxLength(100)]
17 + public string? IconName { get; set; }
18 +
19 + public decimal? PlannedAmount { get; set; }
20 +
21 + public int DisplayOrder { get; set; }
22 +
23 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
24 + [NotMapped] public ICollection<Expense>? Expenses { get; set; }
25 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/Trip.cs +56 −0
@@ -0,0 +1,56 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +
8 +namespace SplitApp.Modules.Trips.Domain.Entities;
9 +
10 +public class Trip : BaseEntity, IValidatableObject
11 +{
12 + [MaxLength(200)]
13 + public string Name { get; set; } = default!;
14 +
15 + public string? Description { get; set; }
16 +
17 + [MaxLength(200)]
18 + public string? Destination { get; set; }
19 +
20 + public DateTime? StartDate { get; set; }
21 +
22 + public DateTime? EndDate { get; set; }
23 +
24 + public ETripStatus Status { get; set; } = ETripStatus.Active;
25 +
26 + /// <summary>FK to expenses.Currency.</summary>
27 + public Guid DefaultCurrencyId { get; set; }
28 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
29 + [NotMapped] public Currency? DefaultCurrency { get; set; }
30 +
31 + /// <summary>FK to users.AspNetUsers.</summary>
32 + public Guid CreatedById { get; set; }
33 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
34 + [NotMapped] public AppUser? CreatedBy { get; set; }
35 +
36 + public ICollection<TripParticipant>? Participants { get; set; }
37 + public ICollection<BudgetCategory>? BudgetCategories { get; set; }
38 + public ICollection<TripWishlistItem>? WishlistItems { get; set; }
39 + public ICollection<TripPoll>? Polls { get; set; }
40 + public ICollection<TripInvitation>? Invitations { get; set; }
41 +
42 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
43 + [NotMapped] public ICollection<Expense>? Expenses { get; set; }
44 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
45 + [NotMapped] public ICollection<SettlementPlan>? SettlementPlans { get; set; }
46 +
47 + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
48 + {
49 + if (StartDate.HasValue && EndDate.HasValue && EndDate.Value < StartDate.Value)
50 + {
51 + yield return new ValidationResult(
52 + "End date must be on or after start date.",
53 + new[] { nameof(EndDate) });
54 + }
55 + }
56 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripInvitation.cs +24 −0
@@ -0,0 +1,24 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class TripInvitation : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public Guid InvitedByUserId { get; set; }
15 + [NotMapped] public AppUser? InvitedByUser { get; set; }
16 +
17 + [MaxLength(256)]
18 + public string Token { get; set; } = default!;
19 +
20 + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending;
21 +
22 + public DateTime ExpiresAt { get; set; }
23 + public DateTime? RespondedAt { get; set; }
24 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripParticipant.cs +27 −0
@@ -0,0 +1,27 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class TripParticipant : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public Guid UserId { get; set; }
15 + [NotMapped] public AppUser? User { get; set; }
16 +
17 + public EParticipantRole Role { get; set; } = EParticipantRole.Participant;
18 +
19 + [MaxLength(100)]
20 + public string? Nickname { get; set; }
21 +
22 + public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
23 +
24 + public DateTime? LeftAt { get; set; }
25 +
26 + public bool IsActive { get; set; } = true;
27 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPoll.cs +26 −0
@@ -0,0 +1,26 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +using SplitApp.Shared.Kernel.Domain;
5 +
6 +namespace SplitApp.Modules.Trips.Domain.Entities;
7 +
8 +public class TripPoll : BaseEntity
9 +{
10 + public Guid TripId { get; set; }
11 + public Trip? Trip { get; set; }
12 +
13 + public Guid CreatedByUserId { get; set; }
14 + [NotMapped] public AppUser? CreatedByUser { get; set; }
15 +
16 + [MaxLength(500)]
17 + public string Question { get; set; } = default!;
18 +
19 + public bool AllowMultipleVotes { get; set; }
20 +
21 + public bool IsAnonymous { get; set; }
22 +
23 + public DateTime? ClosedAt { get; set; }
24 +
25 + public ICollection<TripPollOption>? Options { get; set; }
26 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollOption.cs +17 −0
@@ -0,0 +1,17 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Shared.Kernel.Domain;
3 +
4 +namespace SplitApp.Modules.Trips.Domain.Entities;
5 +
6 +public class TripPollOption : BaseEntity
7 +{
8 + public Guid PollId { get; set; }
9 + public TripPoll? Poll { get; set; }
10 +
11 + [MaxLength(300)]
12 + public string Text { get; set; } = default!;
13 +
14 + public int DisplayOrder { get; set; }
15 +
16 + public ICollection<TripPollVote>? Votes { get; set; }
17 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollVote.cs +14 −0
@@ -0,0 +1,14 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Users.Domain.Entities;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Trips.Domain.Entities;
6 +
7 +public class TripPollVote : BaseEntity
8 +{
9 + public Guid PollOptionId { get; set; }
10 + public TripPollOption? PollOption { get; set; }
11 +
12 + public Guid UserId { get; set; }
13 + [NotMapped] public AppUser? User { get; set; }
14 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistItem.cs +40 −0
@@ -0,0 +1,40 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class TripWishlistItem : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public Guid AddedByUserId { get; set; }
15 + [NotMapped] public AppUser? AddedByUser { get; set; }
16 +
17 + [MaxLength(200)]
18 + public string Title { get; set; } = default!;
19 +
20 + public string? Description { get; set; }
21 +
22 + public EWishlistCategory Category { get; set; }
23 +
24 + public EWishlistPriority Priority { get; set; }
25 +
26 + public decimal? EstimatedCost { get; set; }
27 +
28 + [MaxLength(500)]
29 + public string? Url { get; set; }
30 +
31 + [MaxLength(300)]
32 + public string? Location { get; set; }
33 +
34 + public bool IsCompleted { get; set; }
35 + public DateTime? CompletedAt { get; set; }
36 +
37 + public int DisplayOrder { get; set; }
38 +
39 + public ICollection<TripWishlistVote>? Votes { get; set; }
40 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistVote.cs +16 −0
@@ -0,0 +1,16 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Users.Domain.Entities;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Trips.Domain.Entities;
6 +
7 +public class TripWishlistVote : BaseEntity
8 +{
9 + public Guid WishlistItemId { get; set; }
10 + public TripWishlistItem? WishlistItem { get; set; }
11 +
12 + public Guid UserId { get; set; }
13 + [NotMapped] public AppUser? User { get; set; }
14 +
15 + public bool IsInterested { get; set; } = true;
16 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EInvitationStatus.cs +10 −0
@@ -0,0 +1,10 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EInvitationStatus
4 +{
5 + Pending,
6 + Accepted,
7 + Declined,
8 + Expired,
9 + Revoked
10 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EParticipantRole.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EParticipantRole
4 +{
5 + Organizer,
6 + Participant
7 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/ETripStatus.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum ETripStatus
4 +{
5 + Active,
6 + Settled,
7 + Archived,
8 + Finalizing
9 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistCategory.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EWishlistCategory
4 +{
5 + Place,
6 + Activity,
7 + Restaurant,
8 + Other
9 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistPriority.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EWishlistPriority
4 +{
5 + MustDo,
6 + NiceToHave,
7 + Optional
8 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/SplitApp.Modules.Trips.Domain.csproj +15 −0
@@ -0,0 +1,15 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + <ProjectReference Include="..\..\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
6 + <ProjectReference Include="..\..\Expenses\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj" />
7 + </ItemGroup>
8 +
9 + <PropertyGroup>
10 + <TargetFramework>net10.0</TargetFramework>
11 + <ImplicitUsings>enable</ImplicitUsings>
12 + <Nullable>enable</Nullable>
13 + </PropertyGroup>
14 +
15 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetBudgetCategoryNamesByIdsHandler.cs +30 −0
@@ -0,0 +1,30 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips;
5 +using SplitApp.Shared.Contracts.Trips.Queries;
6 +
7 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
8 +
9 +public class GetBudgetCategoryNamesByIdsHandler
10 + : IRequestHandler<GetBudgetCategoryNamesByIdsQuery, IReadOnlyList<BudgetCategoryNameDto>>
11 +{
12 + private readonly TripsDbContext _db;
13 +
14 + public GetBudgetCategoryNamesByIdsHandler(TripsDbContext db) => _db = db;
15 +
16 + public async Task<IReadOnlyList<BudgetCategoryNameDto>> Handle(
17 + GetBudgetCategoryNamesByIdsQuery request,
18 + CancellationToken cancellationToken)
19 + {
20 + if (request.CategoryIds.Count == 0) return Array.Empty<BudgetCategoryNameDto>();
21 + var ids = request.CategoryIds.Distinct().ToList();
22 + var rows = await _db.BudgetCategories
23 + .Where(c => ids.Contains(c.Id))
24 + .ToListAsync(cancellationToken);
25 + // LangStr.Translate() reads CurrentUICulture — gives the culture-appropriate name.
26 + return rows
27 + .Select(c => new BudgetCategoryNameDto(c.Id, c.Name.Translate() ?? c.Name.ToString()))
28 + .ToList();
29 + }
30 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetTripParticipantsHandler.cs +27 −0
@@ -0,0 +1,27 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips;
5 +using SplitApp.Shared.Contracts.Trips.Queries;
6 +
7 +namespace SplitApp.Modules.Trips.Application.Handlers;
8 +
9 +public class GetTripParticipantsHandler : IRequestHandler<GetTripParticipantsQuery, IReadOnlyList<TripParticipantDto>>
10 +{
11 + private readonly TripsDbContext _db;
12 +
13 + public GetTripParticipantsHandler(TripsDbContext db)
14 + {
15 + _db = db;
16 + }
17 +
18 + public async Task<IReadOnlyList<TripParticipantDto>> Handle(GetTripParticipantsQuery request, CancellationToken cancellationToken)
19 + {
20 + var rows = await _db.TripParticipants
21 + .Where(p => p.TripId == request.TripId)
22 + .ToListAsync(cancellationToken);
23 + return rows
24 + .Select(p => new TripParticipantDto(p.Id, p.TripId, p.UserId, p.Role.ToString(), p.Nickname, p.JoinedAt, p.IsActive))
25 + .ToList();
26 + }
27 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/IsTripParticipantHandler.cs +25 −0
@@ -0,0 +1,25 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips.Queries;
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
7 +
8 +public class IsTripParticipantHandler : IRequestHandler<IsTripParticipantQuery, bool>
9 +{
10 + private readonly TripsDbContext _db;
11 +
12 + public IsTripParticipantHandler(TripsDbContext db)
13 + {
14 + _db = db;
15 + }
16 +
17 + public Task<bool> Handle(IsTripParticipantQuery request, CancellationToken cancellationToken)
18 + {
19 + return _db.TripParticipants
20 + .AnyAsync(p => p.TripId == request.TripId
21 + && p.UserId == request.UserId
22 + && p.IsActive,
23 + cancellationToken);
24 + }
25 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/SettlementPlanCompletedHandler.cs +32 −0
@@ -0,0 +1,32 @@
1 +using MediatR;
2 +using SplitApp.Modules.Trips.Application.Contracts;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Expenses.Events;
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
7 +
8 +/// <summary>
9 +/// When the Expenses module finishes settling every payment in a plan it publishes
10 +/// <see cref="SettlementPlanCompletedEvent"/>. The Trips module owns trip lifecycle,
11 +/// so it advances trips that were "Finalizing" into "Settled".
12 +/// </summary>
13 +public class SettlementPlanCompletedHandler : INotificationHandler<SettlementPlanCompletedEvent>
14 +{
15 + private readonly ITripsUnitOfWork _uow;
16 +
17 + public SettlementPlanCompletedHandler(ITripsUnitOfWork uow)
18 + {
19 + _uow = uow;
20 + }
21 +
22 + public async Task Handle(SettlementPlanCompletedEvent notification, CancellationToken cancellationToken)
23 + {
24 + var trip = await _uow.Trips.GetByIdAsync(notification.TripId);
25 + if (trip == null) return;
26 + if (trip.Status != ETripStatus.Finalizing) return;
27 +
28 + trip.Status = ETripStatus.Settled;
29 + _uow.Trips.Update(trip);
30 + await _uow.SaveChangesAsync();
31 + }
32 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/UserDeletedHandler.cs +35 −0
@@ -0,0 +1,35 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Users.Events;
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
7 +
8 +public class UserDeletedHandler : INotificationHandler<UserDeletedEvent>
9 +{
10 + private readonly TripsDbContext _db;
11 +
12 + public UserDeletedHandler(TripsDbContext db)
13 + {
14 + _db = db;
15 + }
16 +
17 + public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken)
18 + {
19 + await _db.TripParticipants
20 + .Where(p => p.UserId == notification.UserId)
21 + .ExecuteDeleteAsync(cancellationToken);
22 +
23 + await _db.TripInvitations
24 + .Where(i => i.InvitedByUserId == notification.UserId)
25 + .ExecuteDeleteAsync(cancellationToken);
26 +
27 + await _db.TripPollVotes
28 + .Where(v => v.UserId == notification.UserId)
29 + .ExecuteDeleteAsync(cancellationToken);
30 +
31 + await _db.TripWishlistVotes
32 + .Where(v => v.UserId == notification.UserId)
33 + .ExecuteDeleteAsync(cancellationToken);
34 + }
35 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.Designer.cs +495 −0
@@ -0,0 +1,495 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Migrations;
6 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
8 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
9 +
10 +#nullable disable
11 +
12 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Migrations
13 +{
14 + [DbContext(typeof(TripsDbContext))]
15 + [Migration("20260430134536_Init")]
16 + partial class Init
17 + {
18 + /// <inheritdoc />
19 + protected override void BuildTargetModel(ModelBuilder modelBuilder)
20 + {
21 +#pragma warning disable 612, 618
22 + modelBuilder
23 + .HasDefaultSchema("trips")
24 + .HasAnnotation("ProductVersion", "10.0.5")
25 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
26 +
27 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
28 +
29 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
30 + {
31 + b.Property<Guid>("Id")
32 + .ValueGeneratedOnAdd()
33 + .HasColumnType("uuid");
34 +
35 + b.Property<DateTime>("CreatedAt")
36 + .HasColumnType("timestamp with time zone");
37 +
38 + b.Property<int>("DisplayOrder")
39 + .HasColumnType("integer");
40 +
41 + b.Property<string>("IconName")
42 + .HasMaxLength(100)
43 + .HasColumnType("character varying(100)");
44 +
45 + b.Property<string>("Name")
46 + .IsRequired()
47 + .HasMaxLength(1024)
48 + .HasColumnType("character varying(1024)");
49 +
50 + b.Property<decimal?>("PlannedAmount")
51 + .HasColumnType("numeric");
52 +
53 + b.Property<Guid>("TripId")
54 + .HasColumnType("uuid");
55 +
56 + b.Property<DateTime>("UpdatedAt")
57 + .HasColumnType("timestamp with time zone");
58 +
59 + b.HasKey("Id");
60 +
61 + b.HasIndex("TripId");
62 +
63 + b.ToTable("BudgetCategories", "trips");
64 + });
65 +
66 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
67 + {
68 + b.Property<Guid>("Id")
69 + .ValueGeneratedOnAdd()
70 + .HasColumnType("uuid");
71 +
72 + b.Property<DateTime>("CreatedAt")
73 + .HasColumnType("timestamp with time zone");
74 +
75 + b.Property<Guid>("CreatedById")
76 + .HasColumnType("uuid");
77 +
78 + b.Property<Guid>("DefaultCurrencyId")
79 + .HasColumnType("uuid");
80 +
81 + b.Property<string>("Description")
82 + .HasColumnType("text");
83 +
84 + b.Property<string>("Destination")
85 + .HasMaxLength(200)
86 + .HasColumnType("character varying(200)");
87 +
88 + b.Property<DateTime?>("EndDate")
89 + .HasColumnType("timestamp with time zone");
90 +
91 + b.Property<string>("Name")
92 + .IsRequired()
93 + .HasMaxLength(200)
94 + .HasColumnType("character varying(200)");
95 +
96 + b.Property<DateTime?>("StartDate")
97 + .HasColumnType("timestamp with time zone");
98 +
99 + b.Property<int>("Status")
100 + .HasColumnType("integer");
101 +
102 + b.Property<DateTime>("UpdatedAt")
103 + .HasColumnType("timestamp with time zone");
104 +
105 + b.HasKey("Id");
106 +
107 + b.ToTable("Trips", "trips");
108 + });
109 +
110 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
111 + {
112 + b.Property<Guid>("Id")
113 + .ValueGeneratedOnAdd()
114 + .HasColumnType("uuid");
115 +
116 + b.Property<DateTime>("CreatedAt")
117 + .HasColumnType("timestamp with time zone");
118 +
119 + b.Property<DateTime>("ExpiresAt")
120 + .HasColumnType("timestamp with time zone");
121 +
122 + b.Property<Guid>("InvitedByUserId")
123 + .HasColumnType("uuid");
124 +
125 + b.Property<DateTime?>("RespondedAt")
126 + .HasColumnType("timestamp with time zone");
127 +
128 + b.Property<int>("Status")
129 + .HasColumnType("integer");
130 +
131 + b.Property<string>("Token")
132 + .IsRequired()
133 + .HasMaxLength(256)
134 + .HasColumnType("character varying(256)");
135 +
136 + b.Property<Guid>("TripId")
137 + .HasColumnType("uuid");
138 +
139 + b.Property<DateTime>("UpdatedAt")
140 + .HasColumnType("timestamp with time zone");
141 +
142 + b.HasKey("Id");
143 +
144 + b.HasIndex("Token")
145 + .IsUnique();
146 +
147 + b.HasIndex("TripId");
148 +
149 + b.ToTable("TripInvitations", "trips");
150 + });
151 +
152 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
153 + {
154 + b.Property<Guid>("Id")
155 + .ValueGeneratedOnAdd()
156 + .HasColumnType("uuid");
157 +
158 + b.Property<DateTime>("CreatedAt")
159 + .HasColumnType("timestamp with time zone");
160 +
161 + b.Property<bool>("IsActive")
162 + .HasColumnType("boolean");
163 +
164 + b.Property<DateTime>("JoinedAt")
165 + .HasColumnType("timestamp with time zone");
166 +
167 + b.Property<DateTime?>("LeftAt")
168 + .HasColumnType("timestamp with time zone");
169 +
170 + b.Property<string>("Nickname")
171 + .HasMaxLength(100)
172 + .HasColumnType("character varying(100)");
173 +
174 + b.Property<int>("Role")
175 + .HasColumnType("integer");
176 +
177 + b.Property<Guid>("TripId")
178 + .HasColumnType("uuid");
179 +
180 + b.Property<DateTime>("UpdatedAt")
181 + .HasColumnType("timestamp with time zone");
182 +
183 + b.Property<Guid>("UserId")
184 + .HasColumnType("uuid");
185 +
186 + b.HasKey("Id");
187 +
188 + b.HasIndex("TripId", "UserId")
189 + .IsUnique();
190 +
191 + b.ToTable("TripParticipants", "trips");
192 + });
193 +
194 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
195 + {
196 + b.Property<Guid>("Id")
197 + .ValueGeneratedOnAdd()
198 + .HasColumnType("uuid");
199 +
200 + b.Property<bool>("AllowMultipleVotes")
201 + .HasColumnType("boolean");
202 +
203 + b.Property<DateTime?>("ClosedAt")
204 + .HasColumnType("timestamp with time zone");
205 +
206 + b.Property<DateTime>("CreatedAt")
207 + .HasColumnType("timestamp with time zone");
208 +
209 + b.Property<Guid>("CreatedByUserId")
210 + .HasColumnType("uuid");
211 +
212 + b.Property<bool>("IsAnonymous")
213 + .HasColumnType("boolean");
214 +
215 + b.Property<string>("Question")
216 + .IsRequired()
217 + .HasMaxLength(500)
218 + .HasColumnType("character varying(500)");
219 +
220 + b.Property<Guid>("TripId")
221 + .HasColumnType("uuid");
222 +
223 + b.Property<DateTime>("UpdatedAt")
224 + .HasColumnType("timestamp with time zone");
225 +
226 + b.HasKey("Id");
227 +
228 + b.HasIndex("TripId");
229 +
230 + b.ToTable("TripPolls", "trips");
231 + });
232 +
233 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
234 + {
235 + b.Property<Guid>("Id")
236 + .ValueGeneratedOnAdd()
237 + .HasColumnType("uuid");
238 +
239 + b.Property<DateTime>("CreatedAt")
240 + .HasColumnType("timestamp with time zone");
241 +
242 + b.Property<int>("DisplayOrder")
243 + .HasColumnType("integer");
244 +
245 + b.Property<Guid>("PollId")
246 + .HasColumnType("uuid");
247 +
248 + b.Property<string>("Text")
249 + .IsRequired()
250 + .HasMaxLength(300)
251 + .HasColumnType("character varying(300)");
252 +
253 + b.Property<DateTime>("UpdatedAt")
254 + .HasColumnType("timestamp with time zone");
255 +
256 + b.HasKey("Id");
257 +
258 + b.HasIndex("PollId");
259 +
260 + b.ToTable("TripPollOptions", "trips");
261 + });
262 +
263 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
264 + {
265 + b.Property<Guid>("Id")
266 + .ValueGeneratedOnAdd()
267 + .HasColumnType("uuid");
268 +
269 + b.Property<DateTime>("CreatedAt")
270 + .HasColumnType("timestamp with time zone");
271 +
272 + b.Property<Guid>("PollOptionId")
273 + .HasColumnType("uuid");
274 +
275 + b.Property<DateTime>("UpdatedAt")
276 + .HasColumnType("timestamp with time zone");
277 +
278 + b.Property<Guid>("UserId")
279 + .HasColumnType("uuid");
280 +
281 + b.HasKey("Id");
282 +
283 + b.HasIndex("PollOptionId", "UserId")
284 + .IsUnique();
285 +
286 + b.ToTable("TripPollVotes", "trips");
287 + });
288 +
289 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
290 + {
291 + b.Property<Guid>("Id")
292 + .ValueGeneratedOnAdd()
293 + .HasColumnType("uuid");
294 +
295 + b.Property<Guid>("AddedByUserId")
296 + .HasColumnType("uuid");
297 +
298 + b.Property<int>("Category")
299 + .HasColumnType("integer");
300 +
301 + b.Property<DateTime?>("CompletedAt")
302 + .HasColumnType("timestamp with time zone");
303 +
304 + b.Property<DateTime>("CreatedAt")
305 + .HasColumnType("timestamp with time zone");
306 +
307 + b.Property<string>("Description")
308 + .HasColumnType("text");
309 +
310 + b.Property<int>("DisplayOrder")
311 + .HasColumnType("integer");
312 +
313 + b.Property<decimal?>("EstimatedCost")
314 + .HasColumnType("numeric");
315 +
316 + b.Property<bool>("IsCompleted")
317 + .HasColumnType("boolean");
318 +
319 + b.Property<string>("Location")
320 + .HasMaxLength(300)
321 + .HasColumnType("character varying(300)");
322 +
323 + b.Property<int>("Priority")
324 + .HasColumnType("integer");
325 +
326 + b.Property<string>("Title")
327 + .IsRequired()
328 + .HasMaxLength(200)
329 + .HasColumnType("character varying(200)");
330 +
331 + b.Property<Guid>("TripId")
332 + .HasColumnType("uuid");
333 +
334 + b.Property<DateTime>("UpdatedAt")
335 + .HasColumnType("timestamp with time zone");
336 +
337 + b.Property<string>("Url")
338 + .HasMaxLength(500)
339 + .HasColumnType("character varying(500)");
340 +
341 + b.HasKey("Id");
342 +
343 + b.HasIndex("TripId");
344 +
345 + b.ToTable("TripWishlistItems", "trips");
346 + });
347 +
348 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
349 + {
350 + b.Property<Guid>("Id")
351 + .ValueGeneratedOnAdd()
352 + .HasColumnType("uuid");
353 +
354 + b.Property<DateTime>("CreatedAt")
355 + .HasColumnType("timestamp with time zone");
356 +
357 + b.Property<bool>("IsInterested")
358 + .HasColumnType("boolean");
359 +
360 + b.Property<DateTime>("UpdatedAt")
361 + .HasColumnType("timestamp with time zone");
362 +
363 + b.Property<Guid>("UserId")
364 + .HasColumnType("uuid");
365 +
366 + b.Property<Guid>("WishlistItemId")
367 + .HasColumnType("uuid");
368 +
369 + b.HasKey("Id");
370 +
371 + b.HasIndex("WishlistItemId", "UserId")
372 + .IsUnique();
373 +
374 + b.ToTable("TripWishlistVotes", "trips");
375 + });
376 +
377 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
378 + {
379 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
380 + .WithMany("BudgetCategories")
381 + .HasForeignKey("TripId")
382 + .OnDelete(DeleteBehavior.Restrict)
383 + .IsRequired();
384 +
385 + b.Navigation("Trip");
386 + });
387 +
388 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
389 + {
390 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
391 + .WithMany("Invitations")
392 + .HasForeignKey("TripId")
393 + .OnDelete(DeleteBehavior.Restrict)
394 + .IsRequired();
395 +
396 + b.Navigation("Trip");
397 + });
398 +
399 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
400 + {
401 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
402 + .WithMany("Participants")
403 + .HasForeignKey("TripId")
404 + .OnDelete(DeleteBehavior.Restrict)
405 + .IsRequired();
406 +
407 + b.Navigation("Trip");
408 + });
409 +
410 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
411 + {
412 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
413 + .WithMany("Polls")
414 + .HasForeignKey("TripId")
415 + .OnDelete(DeleteBehavior.Restrict)
416 + .IsRequired();
417 +
418 + b.Navigation("Trip");
419 + });
420 +
421 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
422 + {
423 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPoll", "Poll")
424 + .WithMany("Options")
425 + .HasForeignKey("PollId")
426 + .OnDelete(DeleteBehavior.Restrict)
427 + .IsRequired();
428 +
429 + b.Navigation("Poll");
430 + });
431 +
432 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
433 + {
434 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", "PollOption")
435 + .WithMany("Votes")
436 + .HasForeignKey("PollOptionId")
437 + .OnDelete(DeleteBehavior.Restrict)
438 + .IsRequired();
439 +
440 + b.Navigation("PollOption");
441 + });
442 +
443 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
444 + {
445 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
446 + .WithMany("WishlistItems")
447 + .HasForeignKey("TripId")
448 + .OnDelete(DeleteBehavior.Restrict)
449 + .IsRequired();
450 +
451 + b.Navigation("Trip");
452 + });
453 +
454 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
455 + {
456 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", "WishlistItem")
457 + .WithMany("Votes")
458 + .HasForeignKey("WishlistItemId")
459 + .OnDelete(DeleteBehavior.Restrict)
460 + .IsRequired();
461 +
462 + b.Navigation("WishlistItem");
463 + });
464 +
465 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
466 + {
467 + b.Navigation("BudgetCategories");
468 +
469 + b.Navigation("Invitations");
470 +
471 + b.Navigation("Participants");
472 +
473 + b.Navigation("Polls");
474 +
475 + b.Navigation("WishlistItems");
476 + });
477 +
478 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
479 + {
480 + b.Navigation("Options");
481 + });
482 +
483 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
484 + {
485 + b.Navigation("Votes");
486 + });
487 +
488 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
489 + {
490 + b.Navigation("Votes");
491 + });
492 +#pragma warning restore 612, 618
493 + }
494 + }
495 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.cs +350 −0
@@ -0,0 +1,350 @@
1 +using System;
2 +using Microsoft.EntityFrameworkCore.Migrations;
3 +
4 +#nullable disable
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Migrations
7 +{
8 + /// <inheritdoc />
9 + public partial class Init : Migration
10 + {
11 + /// <inheritdoc />
12 + protected override void Up(MigrationBuilder migrationBuilder)
13 + {
14 + migrationBuilder.EnsureSchema(
15 + name: "trips");
16 +
17 + migrationBuilder.CreateTable(
18 + name: "Trips",
19 + schema: "trips",
20 + columns: table => new
21 + {
22 + Id = table.Column<Guid>(type: "uuid", nullable: false),
23 + Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
24 + Description = table.Column<string>(type: "text", nullable: true),
25 + Destination = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
26 + StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
27 + EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
28 + Status = table.Column<int>(type: "integer", nullable: false),
29 + DefaultCurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
30 + CreatedById = table.Column<Guid>(type: "uuid", nullable: false),
31 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
32 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
33 + },
34 + constraints: table =>
35 + {
36 + table.PrimaryKey("PK_Trips", x => x.Id);
37 + });
38 +
39 + migrationBuilder.CreateTable(
40 + name: "BudgetCategories",
41 + schema: "trips",
42 + columns: table => new
43 + {
44 + Id = table.Column<Guid>(type: "uuid", nullable: false),
45 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
46 + Name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
47 + IconName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
48 + PlannedAmount = table.Column<decimal>(type: "numeric", nullable: true),
49 + DisplayOrder = table.Column<int>(type: "integer", nullable: false),
50 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
51 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
52 + },
53 + constraints: table =>
54 + {
55 + table.PrimaryKey("PK_BudgetCategories", x => x.Id);
56 + table.ForeignKey(
57 + name: "FK_BudgetCategories_Trips_TripId",
58 + column: x => x.TripId,
59 + principalSchema: "trips",
60 + principalTable: "Trips",
61 + principalColumn: "Id",
62 + onDelete: ReferentialAction.Restrict);
63 + });
64 +
65 + migrationBuilder.CreateTable(
66 + name: "TripInvitations",
67 + schema: "trips",
68 + columns: table => new
69 + {
70 + Id = table.Column<Guid>(type: "uuid", nullable: false),
71 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
72 + InvitedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
73 + Token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
74 + Status = table.Column<int>(type: "integer", nullable: false),
75 + ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
76 + RespondedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
77 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
78 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
79 + },
80 + constraints: table =>
81 + {
82 + table.PrimaryKey("PK_TripInvitations", x => x.Id);
83 + table.ForeignKey(
84 + name: "FK_TripInvitations_Trips_TripId",
85 + column: x => x.TripId,
86 + principalSchema: "trips",
87 + principalTable: "Trips",
88 + principalColumn: "Id",
89 + onDelete: ReferentialAction.Restrict);
90 + });
91 +
92 + migrationBuilder.CreateTable(
93 + name: "TripParticipants",
94 + schema: "trips",
95 + columns: table => new
96 + {
97 + Id = table.Column<Guid>(type: "uuid", nullable: false),
98 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
99 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
100 + Role = table.Column<int>(type: "integer", nullable: false),
101 + Nickname = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
102 + JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
103 + LeftAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
104 + IsActive = table.Column<bool>(type: "boolean", nullable: false),
105 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
106 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
107 + },
108 + constraints: table =>
109 + {
110 + table.PrimaryKey("PK_TripParticipants", x => x.Id);
111 + table.ForeignKey(
112 + name: "FK_TripParticipants_Trips_TripId",
113 + column: x => x.TripId,
114 + principalSchema: "trips",
115 + principalTable: "Trips",
116 + principalColumn: "Id",
117 + onDelete: ReferentialAction.Restrict);
118 + });
119 +
120 + migrationBuilder.CreateTable(
121 + name: "TripPolls",
122 + schema: "trips",
123 + columns: table => new
124 + {
125 + Id = table.Column<Guid>(type: "uuid", nullable: false),
126 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
127 + CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
128 + Question = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
129 + AllowMultipleVotes = table.Column<bool>(type: "boolean", nullable: false),
130 + IsAnonymous = table.Column<bool>(type: "boolean", nullable: false),
131 + ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
132 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
133 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
134 + },
135 + constraints: table =>
136 + {
137 + table.PrimaryKey("PK_TripPolls", x => x.Id);
138 + table.ForeignKey(
139 + name: "FK_TripPolls_Trips_TripId",
140 + column: x => x.TripId,
141 + principalSchema: "trips",
142 + principalTable: "Trips",
143 + principalColumn: "Id",
144 + onDelete: ReferentialAction.Restrict);
145 + });
146 +
147 + migrationBuilder.CreateTable(
148 + name: "TripWishlistItems",
149 + schema: "trips",
150 + columns: table => new
151 + {
152 + Id = table.Column<Guid>(type: "uuid", nullable: false),
153 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
154 + AddedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
155 + Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
156 + Description = table.Column<string>(type: "text", nullable: true),
157 + Category = table.Column<int>(type: "integer", nullable: false),
158 + Priority = table.Column<int>(type: "integer", nullable: false),
159 + EstimatedCost = table.Column<decimal>(type: "numeric", nullable: true),
160 + Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
161 + Location = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
162 + IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
163 + CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
164 + DisplayOrder = table.Column<int>(type: "integer", nullable: false),
165 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
166 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
167 + },
168 + constraints: table =>
169 + {
170 + table.PrimaryKey("PK_TripWishlistItems", x => x.Id);
171 + table.ForeignKey(
172 + name: "FK_TripWishlistItems_Trips_TripId",
173 + column: x => x.TripId,
174 + principalSchema: "trips",
175 + principalTable: "Trips",
176 + principalColumn: "Id",
177 + onDelete: ReferentialAction.Restrict);
178 + });
179 +
180 + migrationBuilder.CreateTable(
181 + name: "TripPollOptions",
182 + schema: "trips",
183 + columns: table => new
184 + {
185 + Id = table.Column<Guid>(type: "uuid", nullable: false),
186 + PollId = table.Column<Guid>(type: "uuid", nullable: false),
187 + Text = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
188 + DisplayOrder = table.Column<int>(type: "integer", nullable: false),
189 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
190 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
191 + },
192 + constraints: table =>
193 + {
194 + table.PrimaryKey("PK_TripPollOptions", x => x.Id);
195 + table.ForeignKey(
196 + name: "FK_TripPollOptions_TripPolls_PollId",
197 + column: x => x.PollId,
198 + principalSchema: "trips",
199 + principalTable: "TripPolls",
200 + principalColumn: "Id",
201 + onDelete: ReferentialAction.Restrict);
202 + });
203 +
204 + migrationBuilder.CreateTable(
205 + name: "TripWishlistVotes",
206 + schema: "trips",
207 + columns: table => new
208 + {
209 + Id = table.Column<Guid>(type: "uuid", nullable: false),
210 + WishlistItemId = table.Column<Guid>(type: "uuid", nullable: false),
211 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
212 + IsInterested = table.Column<bool>(type: "boolean", nullable: false),
213 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
214 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
215 + },
216 + constraints: table =>
217 + {
218 + table.PrimaryKey("PK_TripWishlistVotes", x => x.Id);
219 + table.ForeignKey(
220 + name: "FK_TripWishlistVotes_TripWishlistItems_WishlistItemId",
221 + column: x => x.WishlistItemId,
222 + principalSchema: "trips",
223 + principalTable: "TripWishlistItems",
224 + principalColumn: "Id",
225 + onDelete: ReferentialAction.Restrict);
226 + });
227 +
228 + migrationBuilder.CreateTable(
229 + name: "TripPollVotes",
230 + schema: "trips",
231 + columns: table => new
232 + {
233 + Id = table.Column<Guid>(type: "uuid", nullable: false),
234 + PollOptionId = table.Column<Guid>(type: "uuid", nullable: false),
235 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
236 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
237 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
238 + },
239 + constraints: table =>
240 + {
241 + table.PrimaryKey("PK_TripPollVotes", x => x.Id);
242 + table.ForeignKey(
243 + name: "FK_TripPollVotes_TripPollOptions_PollOptionId",
244 + column: x => x.PollOptionId,
245 + principalSchema: "trips",
246 + principalTable: "TripPollOptions",
247 + principalColumn: "Id",
248 + onDelete: ReferentialAction.Restrict);
249 + });
250 +
251 + migrationBuilder.CreateIndex(
252 + name: "IX_BudgetCategories_TripId",
253 + schema: "trips",
254 + table: "BudgetCategories",
255 + column: "TripId");
256 +
257 + migrationBuilder.CreateIndex(
258 + name: "IX_TripInvitations_Token",
259 + schema: "trips",
260 + table: "TripInvitations",
261 + column: "Token",
262 + unique: true);
263 +
264 + migrationBuilder.CreateIndex(
265 + name: "IX_TripInvitations_TripId",
266 + schema: "trips",
267 + table: "TripInvitations",
268 + column: "TripId");
269 +
270 + migrationBuilder.CreateIndex(
271 + name: "IX_TripParticipants_TripId_UserId",
272 + schema: "trips",
273 + table: "TripParticipants",
274 + columns: new[] { "TripId", "UserId" },
275 + unique: true);
276 +
277 + migrationBuilder.CreateIndex(
278 + name: "IX_TripPollOptions_PollId",
279 + schema: "trips",
280 + table: "TripPollOptions",
281 + column: "PollId");
282 +
283 + migrationBuilder.CreateIndex(
284 + name: "IX_TripPolls_TripId",
285 + schema: "trips",
286 + table: "TripPolls",
287 + column: "TripId");
288 +
289 + migrationBuilder.CreateIndex(
290 + name: "IX_TripPollVotes_PollOptionId_UserId",
291 + schema: "trips",
292 + table: "TripPollVotes",
293 + columns: new[] { "PollOptionId", "UserId" },
294 + unique: true);
295 +
296 + migrationBuilder.CreateIndex(
297 + name: "IX_TripWishlistItems_TripId",
298 + schema: "trips",
299 + table: "TripWishlistItems",
300 + column: "TripId");
301 +
302 + migrationBuilder.CreateIndex(
303 + name: "IX_TripWishlistVotes_WishlistItemId_UserId",
304 + schema: "trips",
305 + table: "TripWishlistVotes",
306 + columns: new[] { "WishlistItemId", "UserId" },
307 + unique: true);
308 + }
309 +
310 + /// <inheritdoc />
311 + protected override void Down(MigrationBuilder migrationBuilder)
312 + {
313 + migrationBuilder.DropTable(
314 + name: "BudgetCategories",
315 + schema: "trips");
316 +
317 + migrationBuilder.DropTable(
318 + name: "TripInvitations",
319 + schema: "trips");
320 +
321 + migrationBuilder.DropTable(
322 + name: "TripParticipants",
323 + schema: "trips");
324 +
325 + migrationBuilder.DropTable(
326 + name: "TripPollVotes",
327 + schema: "trips");
328 +
329 + migrationBuilder.DropTable(
330 + name: "TripWishlistVotes",
331 + schema: "trips");
332 +
333 + migrationBuilder.DropTable(
334 + name: "TripPollOptions",
335 + schema: "trips");
336 +
337 + migrationBuilder.DropTable(
338 + name: "TripWishlistItems",
339 + schema: "trips");
340 +
341 + migrationBuilder.DropTable(
342 + name: "TripPolls",
343 + schema: "trips");
344 +
345 + migrationBuilder.DropTable(
346 + name: "Trips",
347 + schema: "trips");
348 + }
349 + }
350 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/TripsDbContextModelSnapshot.cs +492 −0
@@ -0,0 +1,492 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
6 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
7 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +
9 +#nullable disable
10 +
11 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Migrations
12 +{
13 + [DbContext(typeof(TripsDbContext))]
14 + partial class TripsDbContextModelSnapshot : ModelSnapshot
15 + {
16 + protected override void BuildModel(ModelBuilder modelBuilder)
17 + {
18 +#pragma warning disable 612, 618
19 + modelBuilder
20 + .HasDefaultSchema("trips")
21 + .HasAnnotation("ProductVersion", "10.0.5")
22 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
23 +
24 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
25 +
26 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
27 + {
28 + b.Property<Guid>("Id")
29 + .ValueGeneratedOnAdd()
30 + .HasColumnType("uuid");
31 +
32 + b.Property<DateTime>("CreatedAt")
33 + .HasColumnType("timestamp with time zone");
34 +
35 + b.Property<int>("DisplayOrder")
36 + .HasColumnType("integer");
37 +
38 + b.Property<string>("IconName")
39 + .HasMaxLength(100)
40 + .HasColumnType("character varying(100)");
41 +
42 + b.Property<string>("Name")
43 + .IsRequired()
44 + .HasMaxLength(1024)
45 + .HasColumnType("character varying(1024)");
46 +
47 + b.Property<decimal?>("PlannedAmount")
48 + .HasColumnType("numeric");
49 +
50 + b.Property<Guid>("TripId")
51 + .HasColumnType("uuid");
52 +
53 + b.Property<DateTime>("UpdatedAt")
54 + .HasColumnType("timestamp with time zone");
55 +
56 + b.HasKey("Id");
57 +
58 + b.HasIndex("TripId");
59 +
60 + b.ToTable("BudgetCategories", "trips");
61 + });
62 +
63 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
64 + {
65 + b.Property<Guid>("Id")
66 + .ValueGeneratedOnAdd()
67 + .HasColumnType("uuid");
68 +
69 + b.Property<DateTime>("CreatedAt")
70 + .HasColumnType("timestamp with time zone");
71 +
72 + b.Property<Guid>("CreatedById")
73 + .HasColumnType("uuid");
74 +
75 + b.Property<Guid>("DefaultCurrencyId")
76 + .HasColumnType("uuid");
77 +
78 + b.Property<string>("Description")
79 + .HasColumnType("text");
80 +
81 + b.Property<string>("Destination")
82 + .HasMaxLength(200)
83 + .HasColumnType("character varying(200)");
84 +
85 + b.Property<DateTime?>("EndDate")
86 + .HasColumnType("timestamp with time zone");
87 +
88 + b.Property<string>("Name")
89 + .IsRequired()
90 + .HasMaxLength(200)
91 + .HasColumnType("character varying(200)");
92 +
93 + b.Property<DateTime?>("StartDate")
94 + .HasColumnType("timestamp with time zone");
95 +
96 + b.Property<int>("Status")
97 + .HasColumnType("integer");
98 +
99 + b.Property<DateTime>("UpdatedAt")
100 + .HasColumnType("timestamp with time zone");
101 +
102 + b.HasKey("Id");
103 +
104 + b.ToTable("Trips", "trips");
105 + });
106 +
107 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
108 + {
109 + b.Property<Guid>("Id")
110 + .ValueGeneratedOnAdd()
111 + .HasColumnType("uuid");
112 +
113 + b.Property<DateTime>("CreatedAt")
114 + .HasColumnType("timestamp with time zone");
115 +
116 + b.Property<DateTime>("ExpiresAt")
117 + .HasColumnType("timestamp with time zone");
118 +
119 + b.Property<Guid>("InvitedByUserId")
120 + .HasColumnType("uuid");
121 +
122 + b.Property<DateTime?>("RespondedAt")
123 + .HasColumnType("timestamp with time zone");
124 +
125 + b.Property<int>("Status")
126 + .HasColumnType("integer");
127 +
128 + b.Property<string>("Token")
129 + .IsRequired()
130 + .HasMaxLength(256)
131 + .HasColumnType("character varying(256)");
132 +
133 + b.Property<Guid>("TripId")
134 + .HasColumnType("uuid");
135 +
136 + b.Property<DateTime>("UpdatedAt")
137 + .HasColumnType("timestamp with time zone");
138 +
139 + b.HasKey("Id");
140 +
141 + b.HasIndex("Token")
142 + .IsUnique();
143 +
144 + b.HasIndex("TripId");
145 +
146 + b.ToTable("TripInvitations", "trips");
147 + });
148 +
149 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
150 + {
151 + b.Property<Guid>("Id")
152 + .ValueGeneratedOnAdd()
153 + .HasColumnType("uuid");
154 +
155 + b.Property<DateTime>("CreatedAt")
156 + .HasColumnType("timestamp with time zone");
157 +
158 + b.Property<bool>("IsActive")
159 + .HasColumnType("boolean");
160 +
161 + b.Property<DateTime>("JoinedAt")
162 + .HasColumnType("timestamp with time zone");
163 +
164 + b.Property<DateTime?>("LeftAt")
165 + .HasColumnType("timestamp with time zone");
166 +
167 + b.Property<string>("Nickname")
168 + .HasMaxLength(100)
169 + .HasColumnType("character varying(100)");
170 +
171 + b.Property<int>("Role")
172 + .HasColumnType("integer");
173 +
174 + b.Property<Guid>("TripId")
175 + .HasColumnType("uuid");
176 +
177 + b.Property<DateTime>("UpdatedAt")
178 + .HasColumnType("timestamp with time zone");
179 +
180 + b.Property<Guid>("UserId")
181 + .HasColumnType("uuid");
182 +
183 + b.HasKey("Id");
184 +
185 + b.HasIndex("TripId", "UserId")
186 + .IsUnique();
187 +
188 + b.ToTable("TripParticipants", "trips");
189 + });
190 +
191 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
192 + {
193 + b.Property<Guid>("Id")
194 + .ValueGeneratedOnAdd()
195 + .HasColumnType("uuid");
196 +
197 + b.Property<bool>("AllowMultipleVotes")
198 + .HasColumnType("boolean");
199 +
200 + b.Property<DateTime?>("ClosedAt")
201 + .HasColumnType("timestamp with time zone");
202 +
203 + b.Property<DateTime>("CreatedAt")
204 + .HasColumnType("timestamp with time zone");
205 +
206 + b.Property<Guid>("CreatedByUserId")
207 + .HasColumnType("uuid");
208 +
209 + b.Property<bool>("IsAnonymous")
210 + .HasColumnType("boolean");
211 +
212 + b.Property<string>("Question")
213 + .IsRequired()
214 + .HasMaxLength(500)
215 + .HasColumnType("character varying(500)");
216 +
217 + b.Property<Guid>("TripId")
218 + .HasColumnType("uuid");
219 +
220 + b.Property<DateTime>("UpdatedAt")
221 + .HasColumnType("timestamp with time zone");
222 +
223 + b.HasKey("Id");
224 +
225 + b.HasIndex("TripId");
226 +
227 + b.ToTable("TripPolls", "trips");
228 + });
229 +
230 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
231 + {
232 + b.Property<Guid>("Id")
233 + .ValueGeneratedOnAdd()
234 + .HasColumnType("uuid");
235 +
236 + b.Property<DateTime>("CreatedAt")
237 + .HasColumnType("timestamp with time zone");
238 +
239 + b.Property<int>("DisplayOrder")
240 + .HasColumnType("integer");
241 +
242 + b.Property<Guid>("PollId")
243 + .HasColumnType("uuid");
244 +
245 + b.Property<string>("Text")
246 + .IsRequired()
247 + .HasMaxLength(300)
248 + .HasColumnType("character varying(300)");
249 +
250 + b.Property<DateTime>("UpdatedAt")
251 + .HasColumnType("timestamp with time zone");
252 +
253 + b.HasKey("Id");
254 +
255 + b.HasIndex("PollId");
256 +
257 + b.ToTable("TripPollOptions", "trips");
258 + });
259 +
260 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
261 + {
262 + b.Property<Guid>("Id")
263 + .ValueGeneratedOnAdd()
264 + .HasColumnType("uuid");
265 +
266 + b.Property<DateTime>("CreatedAt")
267 + .HasColumnType("timestamp with time zone");
268 +
269 + b.Property<Guid>("PollOptionId")
270 + .HasColumnType("uuid");
271 +
272 + b.Property<DateTime>("UpdatedAt")
273 + .HasColumnType("timestamp with time zone");
274 +
275 + b.Property<Guid>("UserId")
276 + .HasColumnType("uuid");
277 +
278 + b.HasKey("Id");
279 +
280 + b.HasIndex("PollOptionId", "UserId")
281 + .IsUnique();
282 +
283 + b.ToTable("TripPollVotes", "trips");
284 + });
285 +
286 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
287 + {
288 + b.Property<Guid>("Id")
289 + .ValueGeneratedOnAdd()
290 + .HasColumnType("uuid");
291 +
292 + b.Property<Guid>("AddedByUserId")
293 + .HasColumnType("uuid");
294 +
295 + b.Property<int>("Category")
296 + .HasColumnType("integer");
297 +
298 + b.Property<DateTime?>("CompletedAt")
299 + .HasColumnType("timestamp with time zone");
300 +
301 + b.Property<DateTime>("CreatedAt")
302 + .HasColumnType("timestamp with time zone");
303 +
304 + b.Property<string>("Description")
305 + .HasColumnType("text");
306 +
307 + b.Property<int>("DisplayOrder")
308 + .HasColumnType("integer");
309 +
310 + b.Property<decimal?>("EstimatedCost")
311 + .HasColumnType("numeric");
312 +
313 + b.Property<bool>("IsCompleted")
314 + .HasColumnType("boolean");
315 +
316 + b.Property<string>("Location")
317 + .HasMaxLength(300)
318 + .HasColumnType("character varying(300)");
319 +
320 + b.Property<int>("Priority")
321 + .HasColumnType("integer");
322 +
323 + b.Property<string>("Title")
324 + .IsRequired()
325 + .HasMaxLength(200)
326 + .HasColumnType("character varying(200)");
327 +
328 + b.Property<Guid>("TripId")
329 + .HasColumnType("uuid");
330 +
331 + b.Property<DateTime>("UpdatedAt")
332 + .HasColumnType("timestamp with time zone");
333 +
334 + b.Property<string>("Url")
335 + .HasMaxLength(500)
336 + .HasColumnType("character varying(500)");
337 +
338 + b.HasKey("Id");
339 +
340 + b.HasIndex("TripId");
341 +
342 + b.ToTable("TripWishlistItems", "trips");
343 + });
344 +
345 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
346 + {
347 + b.Property<Guid>("Id")
348 + .ValueGeneratedOnAdd()
349 + .HasColumnType("uuid");
350 +
351 + b.Property<DateTime>("CreatedAt")
352 + .HasColumnType("timestamp with time zone");
353 +
354 + b.Property<bool>("IsInterested")
355 + .HasColumnType("boolean");
356 +
357 + b.Property<DateTime>("UpdatedAt")
358 + .HasColumnType("timestamp with time zone");
359 +
360 + b.Property<Guid>("UserId")
361 + .HasColumnType("uuid");
362 +
363 + b.Property<Guid>("WishlistItemId")
364 + .HasColumnType("uuid");
365 +
366 + b.HasKey("Id");
367 +
368 + b.HasIndex("WishlistItemId", "UserId")
369 + .IsUnique();
370 +
371 + b.ToTable("TripWishlistVotes", "trips");
372 + });
373 +
374 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
375 + {
376 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
377 + .WithMany("BudgetCategories")
378 + .HasForeignKey("TripId")
379 + .OnDelete(DeleteBehavior.Restrict)
380 + .IsRequired();
381 +
382 + b.Navigation("Trip");
383 + });
384 +
385 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
386 + {
387 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
388 + .WithMany("Invitations")
389 + .HasForeignKey("TripId")
390 + .OnDelete(DeleteBehavior.Restrict)
391 + .IsRequired();
392 +
393 + b.Navigation("Trip");
394 + });
395 +
396 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
397 + {
398 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
399 + .WithMany("Participants")
400 + .HasForeignKey("TripId")
401 + .OnDelete(DeleteBehavior.Restrict)
402 + .IsRequired();
403 +
404 + b.Navigation("Trip");
405 + });
406 +
407 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
408 + {
409 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
410 + .WithMany("Polls")
411 + .HasForeignKey("TripId")
412 + .OnDelete(DeleteBehavior.Restrict)
413 + .IsRequired();
414 +
415 + b.Navigation("Trip");
416 + });
417 +
418 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
419 + {
420 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPoll", "Poll")
421 + .WithMany("Options")
422 + .HasForeignKey("PollId")
423 + .OnDelete(DeleteBehavior.Restrict)
424 + .IsRequired();
425 +
426 + b.Navigation("Poll");
427 + });
428 +
429 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
430 + {
431 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", "PollOption")
432 + .WithMany("Votes")
433 + .HasForeignKey("PollOptionId")
434 + .OnDelete(DeleteBehavior.Restrict)
435 + .IsRequired();
436 +
437 + b.Navigation("PollOption");
438 + });
439 +
440 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
441 + {
442 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
443 + .WithMany("WishlistItems")
444 + .HasForeignKey("TripId")
445 + .OnDelete(DeleteBehavior.Restrict)
446 + .IsRequired();
447 +
448 + b.Navigation("Trip");
449 + });
450 +
451 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
452 + {
453 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", "WishlistItem")
454 + .WithMany("Votes")
455 + .HasForeignKey("WishlistItemId")
456 + .OnDelete(DeleteBehavior.Restrict)
457 + .IsRequired();
458 +
459 + b.Navigation("WishlistItem");
460 + });
461 +
462 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
463 + {
464 + b.Navigation("BudgetCategories");
465 +
466 + b.Navigation("Invitations");
467 +
468 + b.Navigation("Participants");
469 +
470 + b.Navigation("Polls");
471 +
472 + b.Navigation("WishlistItems");
473 + });
474 +
475 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
476 + {
477 + b.Navigation("Options");
478 + });
479 +
480 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
481 + {
482 + b.Navigation("Votes");
483 + });
484 +
485 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
486 + {
487 + b.Navigation("Votes");
488 + });
489 +#pragma warning restore 612, 618
490 + }
491 + }
492 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Repositories/TripsBaseRepository.cs +29 −0
@@ -0,0 +1,29 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Repositories;
6 +
7 +public class TripsBaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity
8 +{
9 + protected readonly TripsDbContext DbContext;
10 + protected readonly DbSet<TEntity> DbSet;
11 +
12 + public TripsBaseRepository(TripsDbContext dbContext)
13 + {
14 + DbContext = dbContext;
15 + DbSet = dbContext.Set<TEntity>();
16 + }
17 +
18 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync();
19 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id);
20 + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity;
21 + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity;
22 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
23 + {
24 + var entity = await GetByIdAsync(id);
25 + if (entity == null) return null;
26 + return DbSet.Remove(entity).Entity;
27 + }
28 + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id);
29 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsDbContext.cs +96 −0
@@ -0,0 +1,96 @@
1 +using System.Text.Json;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.EntityFrameworkCore.ChangeTracking;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence;
9 +
10 +public class TripsDbContext : DbContext
11 +{
12 + public DbSet<Trip> Trips { get; set; } = default!;
13 + public DbSet<TripParticipant> TripParticipants { get; set; } = default!;
14 + public DbSet<TripInvitation> TripInvitations { get; set; } = default!;
15 + public DbSet<BudgetCategory> BudgetCategories { get; set; } = default!;
16 + public DbSet<TripWishlistItem> TripWishlistItems { get; set; } = default!;
17 + public DbSet<TripWishlistVote> TripWishlistVotes { get; set; } = default!;
18 + public DbSet<TripPoll> TripPolls { get; set; } = default!;
19 + public DbSet<TripPollOption> TripPollOptions { get; set; } = default!;
20 + public DbSet<TripPollVote> TripPollVotes { get; set; } = default!;
21 +
22 + public TripsDbContext(DbContextOptions<TripsDbContext> options) : base(options)
23 + {
24 + }
25 +
26 + public override int SaveChanges()
27 + {
28 + UpdateTimestamps();
29 + return base.SaveChanges();
30 + }
31 +
32 + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
33 + {
34 + UpdateTimestamps();
35 + return base.SaveChangesAsync(cancellationToken);
36 + }
37 +
38 + private void UpdateTimestamps()
39 + {
40 + var entries = ChangeTracker.Entries<BaseEntity>();
41 + foreach (var entry in entries)
42 + {
43 + if (entry.State == EntityState.Modified)
44 + {
45 + entry.Entity.UpdatedAt = DateTime.UtcNow;
46 + }
47 + }
48 + }
49 +
50 + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
51 + {
52 + base.ConfigureConventions(configurationBuilder);
53 + configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
54 + }
55 +
56 + protected override void OnModelCreating(ModelBuilder builder)
57 + {
58 + base.OnModelCreating(builder);
59 +
60 + builder.HasDefaultSchema("trips");
61 +
62 + foreach (var relationship in builder.Model
63 + .GetEntityTypes()
64 + .SelectMany(e => e.GetForeignKeys()))
65 + {
66 + relationship.DeleteBehavior = DeleteBehavior.Restrict;
67 + }
68 +
69 + builder.Entity<TripInvitation>()
70 + .HasIndex(i => i.Token)
71 + .IsUnique();
72 +
73 + builder.Entity<TripParticipant>()
74 + .HasIndex(tp => new { tp.TripId, tp.UserId })
75 + .IsUnique();
76 +
77 + builder.Entity<TripWishlistVote>()
78 + .HasIndex(v => new { v.WishlistItemId, v.UserId })
79 + .IsUnique();
80 +
81 + builder.Entity<TripPollVote>()
82 + .HasIndex(v => new { v.PollOptionId, v.UserId })
83 + .IsUnique();
84 +
85 + builder.Entity<BudgetCategory>()
86 + .Property(c => c.Name)
87 + .HasConversion(
88 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
89 + v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null) ?? new LangStr())
90 + .HasMaxLength(1024)
91 + .Metadata.SetValueComparer(new ValueComparer<LangStr>(
92 + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
93 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
94 + v => JsonSerializer.Deserialize<LangStr>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!));
95 + }
96 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsUnitOfWork.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.Modules.Trips.Application.Contracts;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence.Repositories;
4 +using SplitApp.Shared.Kernel.Domain;
5 +using SplitApp.Shared.Kernel.Persistence;
6 +
7 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +
9 +public class TripsUnitOfWork : ITripsUnitOfWork
10 +{
11 + private readonly TripsDbContext _db;
12 +
13 + public TripsUnitOfWork(TripsDbContext db)
14 + {
15 + _db = db;
16 + Trips = new TripsBaseRepository<Trip>(db);
17 + Participants = new TripsBaseRepository<TripParticipant>(db);
18 + Invitations = new TripsBaseRepository<TripInvitation>(db);
19 + BudgetCategories = new TripsBaseRepository<BudgetCategory>(db);
20 + Polls = new TripsBaseRepository<TripPoll>(db);
21 + PollOptions = new TripsBaseRepository<TripPollOption>(db);
22 + PollVotes = new TripsBaseRepository<TripPollVote>(db);
23 + WishlistItems = new TripsBaseRepository<TripWishlistItem>(db);
24 + WishlistVotes = new TripsBaseRepository<TripWishlistVote>(db);
25 + }
26 +
27 + public IBaseRepository<Trip> Trips { get; }
28 + public IBaseRepository<TripParticipant> Participants { get; }
29 + public IBaseRepository<TripInvitation> Invitations { get; }
30 + public IBaseRepository<BudgetCategory> BudgetCategories { get; }
31 + public IBaseRepository<TripPoll> Polls { get; }
32 + public IBaseRepository<TripPollOption> PollOptions { get; }
33 + public IBaseRepository<TripPollVote> PollVotes { get; }
34 + public IBaseRepository<TripWishlistItem> WishlistItems { get; }
35 + public IBaseRepository<TripWishlistVote> WishlistVotes { get; }
36 +
37 + public Task<int> SaveChangesAsync() => _db.SaveChangesAsync();
38 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
@@ -0,0 +1,14 @@
1 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2 +
3 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +
5 +public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
6 +{
7 + public UtcDateTimeConverter() : base(
8 + v => v.Kind == DateTimeKind.Unspecified
9 + ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
10 + : v.ToUniversalTime(),
11 + v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
12 + {
13 + }
14 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj +30 −0
@@ -0,0 +1,30 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Trips.Domain\SplitApp.Modules.Trips.Domain.csproj" />
5 + <ProjectReference Include="..\SplitApp.Modules.Trips.Application\SplitApp.Modules.Trips.Application.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
16 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
17 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
18 + <PrivateAssets>all</PrivateAssets>
19 + </PackageReference>
20 + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
21 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
22 + </ItemGroup>
23 +
24 + <PropertyGroup>
25 + <TargetFramework>net10.0</TargetFramework>
26 + <ImplicitUsings>enable</ImplicitUsings>
27 + <Nullable>enable</Nullable>
28 + </PropertyGroup>
29 +
30 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/TripsModuleExtensions.cs +40 −0
@@ -0,0 +1,40 @@
1 +using Microsoft.AspNetCore.Builder;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.Extensions.Configuration;
4 +using Microsoft.Extensions.DependencyInjection;
5 +using SplitApp.Modules.Trips.Application;
6 +using SplitApp.Modules.Trips.Application.Contracts;
7 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +
9 +namespace SplitApp.Modules.Trips.Infrastructure;
10 +
11 +public static class TripsModuleExtensions
12 +{
13 + public static IServiceCollection AddTripsModule(
14 + this IServiceCollection services,
15 + IConfiguration configuration)
16 + {
17 + services.AddDbContext<TripsDbContext>(opt =>
18 + {
19 + opt.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
20 + });
21 +
22 + services.AddScoped<ITripsUnitOfWork, TripsUnitOfWork>();
23 +
24 + services.AddMediatR(cfg =>
25 + {
26 + cfg.RegisterServicesFromAssemblyContaining<TripsModuleMarker>();
27 + cfg.RegisterServicesFromAssembly(typeof(TripsModuleExtensions).Assembly);
28 + });
29 +
30 + return services;
31 + }
32 +
33 + public static IApplicationBuilder UseTripsModule(this IApplicationBuilder app)
34 + {
35 + using var scope = app.ApplicationServices.CreateScope();
36 + var db = scope.ServiceProvider.GetRequiredService<TripsDbContext>();
37 + db.Database.Migrate();
38 + return app;
39 + }
40 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor +3 −0
@@ -0,0 +1,3 @@
1 +<div class="my-component">
2 + This component is defined in the <strong>SplitApp.Modules.Users.Api</strong> library.
3 +</div>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor.css +6 −0
@@ -0,0 +1,6 @@
1 +.my-component {
2 + border: 2px dashed red;
3 + padding: 1em;
4 + margin: 1em 0;
5 + background-image: url('background.png');
6 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AccountController.cs +145 −0
@@ -0,0 +1,145 @@
1 +using System.Net;
2 +using System.Security.Claims;
3 +using Asp.Versioning;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using Microsoft.Extensions.Logging;
8 +using SplitApp.Modules.Users.Api.Dto.v1;
9 +using SplitApp.Modules.Users.Application.Services;
10 +
11 +namespace SplitApp.Modules.Users.Api.Controllers;
12 +
13 +[ApiVersion("1.0")]
14 +[ApiController]
15 +[Route("/api/v{version:apiVersion}/identity/[controller]/[action]")]
16 +public class AccountController : ControllerBase
17 +{
18 + private readonly IIdentityService _identityService;
19 + private readonly ILogger<AccountController> _logger;
20 +
21 + public AccountController(IIdentityService identityService, ILogger<AccountController> logger)
22 + {
23 + _identityService = identityService;
24 + _logger = logger;
25 + }
26 +
27 + [HttpPost]
28 + [Produces("application/json")]
29 + [Consumes("application/json")]
30 + [ProducesResponseType<JWTResponse>((int)HttpStatusCode.OK)]
31 + [ProducesResponseType<RestApiErrorResponse>((int)HttpStatusCode.BadRequest)]
32 + public async Task<ActionResult<JWTResponse>> Register(
33 + [FromBody] RegisterInfo registrationData,
34 + [FromQuery] int expiresInSeconds)
35 + {
36 + var result = await _identityService.RegisterAsync(new RegisterRequest
37 + {
38 + Email = registrationData.Email,
39 + Password = registrationData.Password,
40 + FirstName = registrationData.Firstname,
41 + LastName = registrationData.Lastname,
42 + ExpiresInSeconds = expiresInSeconds
43 + });
44 +
45 + if (!result.Success)
46 + {
47 + _logger.LogWarning("WebApi register failed for {Email}: {Error}", registrationData.Email, result.Error);
48 + return MapErrorResult(result);
49 + }
50 +
51 + return Ok(MapJwtPayload(result.Payload!));
52 + }
53 +
54 + [HttpPost]
55 + public async Task<ActionResult<JWTResponse>> Login(
56 + [FromBody] LoginInfo loginInfo,
57 + [FromQuery] int expiresInSeconds)
58 + {
59 + var result = await _identityService.LoginAsync(new LoginRequest
60 + {
61 + Email = loginInfo.Email,
62 + Password = loginInfo.Password,
63 + ExpiresInSeconds = expiresInSeconds
64 + });
65 +
66 + if (!result.Success)
67 + {
68 + _logger.LogWarning("WebApi login failed for {Email}: {Error}", loginInfo.Email, result.Error);
69 + return MapErrorResult(result);
70 + }
71 +
72 + return Ok(MapJwtPayload(result.Payload!));
73 + }
74 +
75 + [HttpPost]
76 + public async Task<ActionResult<JWTResponse>> RefreshTokenData(
77 + [FromBody] TokenRefreshInfo tokenRefreshInfo,
78 + [FromQuery] int expiresInSeconds)
79 + {
80 + var result = await _identityService.RefreshTokenAsync(new RefreshRequest
81 + {
82 + Jwt = tokenRefreshInfo.Jwt,
83 + RefreshToken = tokenRefreshInfo.RefreshToken,
84 + ExpiresInSeconds = expiresInSeconds
85 + });
86 +
87 + if (!result.Success)
88 + {
89 + return MapErrorResult(result);
90 + }
91 +
92 + return Ok(MapJwtPayload(result.Payload!));
93 + }
94 +
95 + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
96 + [HttpPost]
97 + public async Task<ActionResult> Logout([FromBody] LogoutInfo logout)
98 + {
99 + var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
100 + if (userIdStr == null || !Guid.TryParse(userIdStr, out var userId))
101 + {
102 + return BadRequest(new RestApiErrorResponse
103 + {
104 + Status = HttpStatusCode.BadRequest,
105 + Error = "Invalid refresh token"
106 + });
107 + }
108 +
109 + var result = await _identityService.LogoutAsync(new LogoutRequest
110 + {
111 + UserId = userId,
112 + RefreshToken = logout.RefreshToken
113 + });
114 +
115 + if (!result.Success)
116 + {
117 + return MapErrorResult(result);
118 + }
119 +
120 + return Ok(new { TokenDeleteCount = result.TokensDeleted ?? 0 });
121 + }
122 +
123 + private ActionResult MapErrorResult(IdentityServiceResult result)
124 + {
125 + var error = new RestApiErrorResponse
126 + {
127 + Status = result.ErrorKind == IdentityServiceErrorKind.NotFound
128 + ? HttpStatusCode.NotFound
129 + : HttpStatusCode.BadRequest,
130 + Error = result.Error ?? "Unknown error"
131 + };
132 +
133 + return result.ErrorKind == IdentityServiceErrorKind.NotFound
134 + ? NotFound(error)
135 + : BadRequest(error);
136 + }
137 +
138 + private static JWTResponse MapJwtPayload(IdentityJwtPayload payload) => new()
139 + {
140 + Jwt = payload.Jwt,
141 + RefreshToken = payload.RefreshToken,
142 + FirstName = payload.FirstName,
143 + LastName = payload.LastName
144 + };
145 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/JWTResponse.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class JWTResponse
4 +{
5 + public string Jwt { get; set; } = default!;
6 + public string RefreshToken { get; set; } = default!;
7 + public string FirstName { get; set; } = default!;
8 + public string LastName { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LoginInfo.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class LoginInfo
4 +{
5 + public string Email { get; set; } = default!;
6 + public string Password { get; set; } = default!;
7 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LogoutInfo.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class LogoutInfo
4 +{
5 + public string RefreshToken { get; set; } = default!;
6 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RegisterInfo.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class RegisterInfo
4 +{
5 + public string Email { get; set; } = default!;
6 + public string Password { get; set; } = default!;
7 + public string Firstname { get; set; } = default!;
8 + public string Lastname { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RestApiErrorResponse.cs +9 −0
@@ -0,0 +1,9 @@
1 +using System.Net;
2 +
3 +namespace SplitApp.Modules.Users.Api.Dto.v1;
4 +
5 +public class RestApiErrorResponse
6 +{
7 + public HttpStatusCode Status { get; set; }
8 + public string Error { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/TokenRefreshInfo.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class TokenRefreshInfo
4 +{
5 + public string Jwt { get; set; } = default!;
6 + public string RefreshToken { get; set; } = default!;
7 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/ExampleJsInterop.cs +31 −0
@@ -0,0 +1,31 @@
1 +using Microsoft.JSInterop;
2 +
3 +namespace SplitApp.Modules.Users.Api;
4 +
5 +// This class provides an example of how JavaScript functionality can be wrapped
6 +// in a .NET class for easy consumption. The associated JavaScript module is
7 +// loaded on demand when first needed.
8 +//
9 +// This class can be registered as scoped DI service and then injected into Blazor
10 +// components for use.
11 +
12 +public class ExampleJsInterop(IJSRuntime jsRuntime) : IAsyncDisposable
13 +{
14 + private readonly Lazy<Task<IJSObjectReference>> moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
15 + "import", "./_content/SplitApp.Modules.Users.Api/exampleJsInterop.js").AsTask());
16 +
17 + public async ValueTask<string> Prompt(string message)
18 + {
19 + var module = await moduleTask.Value;
20 + return await module.InvokeAsync<string>("showPrompt", message);
21 + }
22 +
23 + public async ValueTask DisposeAsync()
24 + {
25 + if (moduleTask.IsValueCreated)
26 + {
27 + var module = await moduleTask.Value;
28 + await module.DisposeAsync();
29 + }
30 + }
31 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/SplitApp.Modules.Users.Api.csproj +27 −0
@@ -0,0 +1,27 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Razor">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + </PropertyGroup>
8 +
9 +
10 + <ItemGroup>
11 + <SupportedPlatform Include="browser" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
18 + <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.9" />
19 + </ItemGroup>
20 +
21 + <ItemGroup>
22 + <ProjectReference Include="..\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj" />
23 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
24 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
25 + </ItemGroup>
26 +
27 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/_Imports.razor +1 −0
@@ -0,0 +1 @@
1 +@using Microsoft.AspNetCore.Components.Web
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/background.png +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/exampleJsInterop.js +6 −0
@@ -0,0 +1,6 @@
1 +// This is a JavaScript module that is loaded on demand. It can export any number of
2 +// functions, and may import other JavaScript modules if required.
3 +
4 +export function showPrompt(message) {
5 + return prompt(message, 'Type anything here');
6 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IRefreshTokenRepository.cs +12 −0
@@ -0,0 +1,12 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Persistence;
3 +
4 +namespace SplitApp.Modules.Users.Application.Contracts;
5 +
6 +public interface IRefreshTokenRepository : IBaseRepository<AppRefreshToken>
7 +{
8 + Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue);
9 + Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue);
10 + Task<int> RemoveExpiredForUserAsync(Guid userId);
11 + void Remove(AppRefreshToken token);
12 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUserRepository.cs +12 −0
@@ -0,0 +1,12 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Persistence;
3 +
4 +namespace SplitApp.Modules.Users.Application.Contracts;
5 +
6 +public interface IUserRepository : IBaseRepository<AppUser>
7 +{
8 + Task<int> CountAsync();
9 + Task<IEnumerable<AppUser>> GetRecentAsync(int take);
10 + Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId);
11 + Task<IReadOnlyList<AppUser>> GetByIdsAsync(IReadOnlyCollection<Guid> ids);
12 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUsersUnitOfWork.cs +9 −0
@@ -0,0 +1,9 @@
1 +using SplitApp.Shared.Kernel.Persistence;
2 +
3 +namespace SplitApp.Modules.Users.Application.Contracts;
4 +
5 +public interface IUsersUnitOfWork : IUnitOfWork
6 +{
7 + IUserRepository Users { get; }
8 + IRefreshTokenRepository RefreshTokens { get; }
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUserByIdHandler.cs +26 −0
@@ -0,0 +1,26 @@
1 +using MediatR;
2 +using SplitApp.Modules.Users.Application.Contracts;
3 +using SplitApp.Shared.Contracts.Users;
4 +using SplitApp.Shared.Contracts.Users.Queries;
5 +
6 +namespace SplitApp.Modules.Users.Application.Handlers;
7 +
8 +public class GetUserByIdHandler : IRequestHandler<GetUserByIdQuery, UserDto?>
9 +{
10 + private readonly IUserRepository _users;
11 +
12 + public GetUserByIdHandler(IUserRepository users)
13 + {
14 + _users = users;
15 + }
16 +
17 + public async Task<UserDto?> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
18 + {
19 + var user = await _users.GetByIdAsync(request.UserId);
20 + if (user == null) return null;
21 + return new UserDto(
22 + user.Id,
23 + $"{user.FirstName} {user.LastName}".Trim(),
24 + user.Email ?? "");
25 + }
26 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUsersByIdsHandler.cs +25 −0
@@ -0,0 +1,25 @@
1 +using MediatR;
2 +using SplitApp.Modules.Users.Application.Contracts;
3 +using SplitApp.Shared.Contracts.Users;
4 +using SplitApp.Shared.Contracts.Users.Queries;
5 +
6 +namespace SplitApp.Modules.Users.Application.Handlers;
7 +
8 +public class GetUsersByIdsHandler : IRequestHandler<GetUsersByIdsQuery, IReadOnlyList<UserDto>>
9 +{
10 + private readonly IUserRepository _users;
11 +
12 + public GetUsersByIdsHandler(IUserRepository users)
13 + {
14 + _users = users;
15 + }
16 +
17 + public async Task<IReadOnlyList<UserDto>> Handle(GetUsersByIdsQuery request, CancellationToken cancellationToken)
18 + {
19 + if (request.UserIds.Count == 0) return Array.Empty<UserDto>();
20 + var found = await _users.GetByIdsAsync(request.UserIds);
21 + return found
22 + .Select(u => new UserDto(u.Id, $"{u.FirstName} {u.LastName}".Trim(), u.Email ?? ""))
23 + .ToList();
24 + }
25 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IIdentityService.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Users.Application.Services;
2 +
3 +public interface IIdentityService
4 +{
5 + Task<IdentityServiceResult> RegisterAsync(RegisterRequest request);
6 + Task<IdentityServiceResult> LoginAsync(LoginRequest request);
7 + Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request);
8 + Task<IdentityServiceResult> LogoutAsync(LogoutRequest request);
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityService.cs +271 −0
@@ -0,0 +1,271 @@
1 +using System.IdentityModel.Tokens.Jwt;
2 +using System.Security.Claims;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Identity;
5 +using Microsoft.Extensions.Configuration;
6 +using SplitApp.Modules.Users.Application.Contracts;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Shared.Contracts.Users.Events;
9 +using SplitApp.Shared.Kernel.Auth;
10 +
11 +namespace SplitApp.Modules.Users.Application.Services;
12 +
13 +public class IdentityService : IIdentityService
14 +{
15 + private readonly IUsersUnitOfWork _uow;
16 + private readonly UserManager<AppUser> _userManager;
17 + private readonly IConfiguration _configuration;
18 + private readonly IMediator _mediator;
19 +
20 + public IdentityService(
21 + IUsersUnitOfWork uow,
22 + UserManager<AppUser> userManager,
23 + IConfiguration configuration,
24 + IMediator mediator)
25 + {
26 + _uow = uow;
27 + _userManager = userManager;
28 + _configuration = configuration;
29 + _mediator = mediator;
30 + }
31 +
32 + public async Task<IdentityServiceResult> RegisterAsync(RegisterRequest request)
33 + {
34 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
35 +
36 + var existing = await _userManager.FindByEmailAsync(request.Email);
37 + if (existing != null)
38 + {
39 + return IdentityServiceResult.Fail(
40 + $"User with email {request.Email} is already registered",
41 + IdentityServiceErrorKind.BadRequest);
42 + }
43 +
44 + var refreshToken = new AppRefreshToken();
45 + var appUser = new AppUser
46 + {
47 + Email = request.Email,
48 + UserName = request.Email,
49 + FirstName = request.FirstName,
50 + LastName = request.LastName,
51 + RefreshTokens = new List<AppRefreshToken> { refreshToken }
52 + };
53 + refreshToken.AppUser = appUser;
54 +
55 + var createResult = await _userManager.CreateAsync(appUser, request.Password);
56 + if (!createResult.Succeeded)
57 + {
58 + return IdentityServiceResult.Fail(
59 + createResult.Errors.First().Description,
60 + IdentityServiceErrorKind.BadRequest);
61 + }
62 +
63 + await _userManager.AddToRoleAsync(appUser, "user");
64 +
65 + var claimsResult = await _userManager.AddClaimsAsync(appUser, new List<Claim>
66 + {
67 + new(ClaimTypes.GivenName, appUser.FirstName),
68 + new(ClaimTypes.Surname, appUser.LastName)
69 + });
70 + if (!claimsResult.Succeeded)
71 + {
72 + return IdentityServiceResult.Fail(
73 + claimsResult.Errors.First().Description,
74 + IdentityServiceErrorKind.BadRequest);
75 + }
76 +
77 + var reloaded = await _userManager.FindByEmailAsync(appUser.Email);
78 + if (reloaded == null)
79 + {
80 + return IdentityServiceResult.Fail(
81 + $"User with email {request.Email} is not found after registration",
82 + IdentityServiceErrorKind.BadRequest);
83 + }
84 +
85 + var jwt = await GenerateJwtAsync(reloaded, expiresInSeconds);
86 +
87 + return IdentityServiceResult.Ok(new IdentityJwtPayload
88 + {
89 + Jwt = jwt,
90 + RefreshToken = refreshToken.RefreshToken,
91 + FirstName = reloaded.FirstName,
92 + LastName = reloaded.LastName
93 + });
94 + }
95 +
96 + public async Task<IdentityServiceResult> LoginAsync(LoginRequest request)
97 + {
98 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
99 +
100 + var appUser = await _userManager.FindByEmailAsync(request.Email);
101 + if (appUser == null)
102 + {
103 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
104 + }
105 +
106 + var passwordOk = await _userManager.CheckPasswordAsync(appUser, request.Password);
107 + if (!passwordOk)
108 + {
109 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
110 + }
111 +
112 + await _uow.RefreshTokens.RemoveExpiredForUserAsync(appUser.Id);
113 +
114 + var refreshToken = new AppRefreshToken
115 + {
116 + AppUserId = appUser.Id
117 + };
118 + _uow.RefreshTokens.Add(refreshToken);
119 + await _uow.SaveChangesAsync();
120 +
121 + var jwt = await GenerateJwtAsync(appUser, expiresInSeconds);
122 +
123 + return IdentityServiceResult.Ok(new IdentityJwtPayload
124 + {
125 + Jwt = jwt,
126 + RefreshToken = refreshToken.RefreshToken,
127 + FirstName = appUser.FirstName,
128 + LastName = appUser.LastName
129 + });
130 + }
131 +
132 + public async Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request)
133 + {
134 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
135 +
136 + JwtSecurityToken? jwt;
137 + try
138 + {
139 + jwt = new JwtSecurityTokenHandler().ReadJwtToken(request.Jwt);
140 + }
141 + catch (Exception)
142 + {
143 + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest);
144 + }
145 +
146 + if (jwt == null)
147 + {
148 + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest);
149 + }
150 +
151 + if (!IdentityHelpers.ValidateJWT(
152 + request.Jwt,
153 + _configuration.GetValue<string>("JWT:Key")!,
154 + _configuration.GetValue<string>("JWT:Issuer")!,
155 + _configuration.GetValue<string>("JWT:Audience")!))
156 + {
157 + return IdentityServiceResult.Fail("JWT validation fail", IdentityServiceErrorKind.BadRequest);
158 + }
159 +
160 + var userEmail = jwt.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
161 + if (userEmail == null)
162 + {
163 + return IdentityServiceResult.Fail("No email in jwt", IdentityServiceErrorKind.BadRequest);
164 + }
165 +
166 + var appUser = await _userManager.FindByEmailAsync(userEmail);
167 + if (appUser == null)
168 + {
169 + return IdentityServiceResult.Fail($"User with email {userEmail} not found", IdentityServiceErrorKind.NotFound);
170 + }
171 +
172 + var matchingTokens = (await _uow.RefreshTokens
173 + .GetUserActiveTokensAsync(appUser.Id, request.RefreshToken)).ToList();
174 +
175 + if (matchingTokens.Count == 0)
176 + {
177 + return IdentityServiceResult.Fail(
178 + "RefreshTokens collection is null or empty - 0",
179 + IdentityServiceErrorKind.NotFound);
180 + }
181 +
182 + if (matchingTokens.Count != 1)
183 + {
184 + return IdentityServiceResult.Fail(
185 + "More than one valid refresh token found",
186 + IdentityServiceErrorKind.NotFound);
187 + }
188 +
189 + var refreshToken = matchingTokens.First();
190 + if (refreshToken.RefreshToken == request.RefreshToken)
191 + {
192 + refreshToken.PreviousRefreshToken = refreshToken.RefreshToken;
193 + refreshToken.PreviousExpirationDT = DateTime.UtcNow.AddMinutes(1);
194 + refreshToken.RefreshToken = Guid.NewGuid().ToString();
195 + refreshToken.ExpirationDT = DateTime.UtcNow.AddDays(7);
196 + _uow.RefreshTokens.Update(refreshToken);
197 + await _uow.SaveChangesAsync();
198 + }
199 +
200 + var newJwt = await GenerateJwtAsync(appUser, expiresInSeconds);
201 +
202 + return IdentityServiceResult.Ok(new IdentityJwtPayload
203 + {
204 + Jwt = newJwt,
205 + RefreshToken = refreshToken.RefreshToken,
206 + FirstName = appUser.FirstName,
207 + LastName = appUser.LastName
208 + });
209 + }
210 +
211 + public async Task<IdentityServiceResult> LogoutAsync(LogoutRequest request)
212 + {
213 + var appUser = await _uow.Users.GetByIdAsync(request.UserId);
214 + if (appUser == null)
215 + {
216 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
217 + }
218 +
219 + var tokens = (await _uow.RefreshTokens
220 + .GetUserTokensByValueAsync(request.UserId, request.RefreshToken)).ToList();
221 +
222 + foreach (var token in tokens)
223 + {
224 + _uow.RefreshTokens.Remove(token);
225 + }
226 +
227 + var deleteCount = await _uow.SaveChangesAsync();
228 + return IdentityServiceResult.Logout(deleteCount);
229 + }
230 +
231 + private int ResolveExpiresInSeconds(int requested)
232 + {
233 + if (requested <= 0) requested = int.MaxValue;
234 + var configured = _configuration.GetValue<int>("JWT:ExpiresInSeconds");
235 + return requested < configured ? requested : configured;
236 + }
237 +
238 + private async Task<string> GenerateJwtAsync(AppUser user, int expiresInSeconds)
239 + {
240 + var claims = new List<Claim>
241 + {
242 + new(ClaimTypes.NameIdentifier, user.Id.ToString()),
243 + new(ClaimTypes.Email, user.Email ?? ""),
244 + new(ClaimTypes.Name, user.UserName ?? user.Email ?? ""),
245 + new(ClaimTypes.GivenName, user.FirstName),
246 + new(ClaimTypes.Surname, user.LastName)
247 + };
248 +
249 + var userClaims = await _userManager.GetClaimsAsync(user);
250 + foreach (var c in userClaims)
251 + {
252 + if (!claims.Any(existing => existing.Type == c.Type && existing.Value == c.Value))
253 + {
254 + claims.Add(c);
255 + }
256 + }
257 +
258 + var roles = await _userManager.GetRolesAsync(user);
259 + foreach (var role in roles)
260 + {
261 + claims.Add(new Claim(ClaimTypes.Role, role));
262 + }
263 +
264 + return IdentityHelpers.GenerateJwt(
265 + claims,
266 + _configuration.GetValue<string>("JWT:Key")!,
267 + _configuration.GetValue<string>("JWT:Issuer")!,
268 + _configuration.GetValue<string>("JWT:Audience")!,
269 + expiresInSeconds);
270 + }
271 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityServiceModels.cs +63 −0
@@ -0,0 +1,63 @@
1 +namespace SplitApp.Modules.Users.Application.Services;
2 +
3 +public class IdentityServiceResult
4 +{
5 + public bool Success { get; init; }
6 + public string? Error { get; init; }
7 + public IdentityServiceErrorKind ErrorKind { get; init; } = IdentityServiceErrorKind.None;
8 + public IdentityJwtPayload? Payload { get; init; }
9 + public int? TokensDeleted { get; init; }
10 +
11 + public static IdentityServiceResult Ok(IdentityJwtPayload payload) =>
12 + new() { Success = true, Payload = payload };
13 +
14 + public static IdentityServiceResult Logout(int tokensDeleted) =>
15 + new() { Success = true, TokensDeleted = tokensDeleted };
16 +
17 + public static IdentityServiceResult Fail(string error, IdentityServiceErrorKind kind) =>
18 + new() { Success = false, Error = error, ErrorKind = kind };
19 +}
20 +
21 +public enum IdentityServiceErrorKind
22 +{
23 + None = 0,
24 + BadRequest = 400,
25 + NotFound = 404
26 +}
27 +
28 +public class IdentityJwtPayload
29 +{
30 + public string Jwt { get; init; } = default!;
31 + public string RefreshToken { get; init; } = default!;
32 + public string FirstName { get; init; } = default!;
33 + public string LastName { get; init; } = default!;
34 +}
35 +
36 +public class RegisterRequest
37 +{
38 + public string Email { get; init; } = default!;
39 + public string Password { get; init; } = default!;
40 + public string FirstName { get; init; } = default!;
41 + public string LastName { get; init; } = default!;
42 + public int ExpiresInSeconds { get; init; }
43 +}
44 +
45 +public class LoginRequest
46 +{
47 + public string Email { get; init; } = default!;
48 + public string Password { get; init; } = default!;
49 + public int ExpiresInSeconds { get; init; }
50 +}
51 +
52 +public class RefreshRequest
53 +{
54 + public string Jwt { get; init; } = default!;
55 + public string RefreshToken { get; init; } = default!;
56 + public int ExpiresInSeconds { get; init; }
57 +}
58 +
59 +public class LogoutRequest
60 +{
61 + public Guid UserId { get; init; }
62 + public string RefreshToken { get; init; } = default!;
63 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/SplitApp.Modules.Users.Application.csproj +23 −0
@@ -0,0 +1,23 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + </ItemGroup>
16 +
17 + <PropertyGroup>
18 + <TargetFramework>net10.0</TargetFramework>
19 + <ImplicitUsings>enable</ImplicitUsings>
20 + <Nullable>enable</Nullable>
21 + </PropertyGroup>
22 +
23 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/UsersModuleMarker.cs +4 −0
@@ -0,0 +1,4 @@
1 +namespace SplitApp.Modules.Users.Application;
2 +
3 +/// <summary>Assembly marker for MediatR handler scanning. Must remain in the Application assembly.</summary>
4 +public sealed class UsersModuleMarker;
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRefreshToken.cs +20 −0
@@ -0,0 +1,20 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Shared.Kernel.Domain;
3 +
4 +namespace SplitApp.Modules.Users.Domain.Entities;
5 +
6 +public class AppRefreshToken : BaseEntity
7 +{
8 + [MaxLength(64)]
9 + public string RefreshToken { get; set; } = Guid.NewGuid().ToString();
10 +
11 + public DateTime ExpirationDT { get; set; } = DateTime.UtcNow.AddDays(7);
12 +
13 + [MaxLength(64)]
14 + public string? PreviousRefreshToken { get; set; }
15 +
16 + public DateTime PreviousExpirationDT { get; set; } = DateTime.UtcNow.AddDays(7);
17 +
18 + public Guid AppUserId { get; set; }
19 + public AppUser? AppUser { get; set; }
20 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRole.cs +8 −0
@@ -0,0 +1,8 @@
1 +using Microsoft.AspNetCore.Identity;
2 +using SplitApp.Shared.Kernel.Domain;
3 +
4 +namespace SplitApp.Modules.Users.Domain.Entities;
5 +
6 +public class AppRole : IdentityRole<Guid>, IBaseEntity
7 +{
8 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppUser.cs +16 −0
@@ -0,0 +1,16 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using Microsoft.AspNetCore.Identity;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Users.Domain.Entities;
6 +
7 +public class AppUser : IdentityUser<Guid>, IBaseEntity
8 +{
9 + [MaxLength(128)]
10 + public string FirstName { get; set; } = "";
11 +
12 + [MaxLength(128)]
13 + public string LastName { get; set; } = "";
14 +
15 + public ICollection<AppRefreshToken>? RefreshTokens { get; set; }
16 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/SplitApp.Modules.Users.Domain.csproj +17 −0
@@ -0,0 +1,17 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + </ItemGroup>
6 +
7 + <ItemGroup>
8 + <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.5" />
9 + </ItemGroup>
10 +
11 + <PropertyGroup>
12 + <TargetFramework>net10.0</TargetFramework>
13 + <ImplicitUsings>enable</ImplicitUsings>
14 + <Nullable>enable</Nullable>
15 + </PropertyGroup>
16 +
17 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.Designer.cs +359 −0
@@ -0,0 +1,359 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Migrations;
6 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
8 +using SplitApp.Modules.Users.Infrastructure.Persistence;
9 +
10 +#nullable disable
11 +
12 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Migrations
13 +{
14 + [DbContext(typeof(UsersDbContext))]
15 + [Migration("20260430134045_Init")]
16 + partial class Init
17 + {
18 + /// <inheritdoc />
19 + protected override void BuildTargetModel(ModelBuilder modelBuilder)
20 + {
21 +#pragma warning disable 612, 618
22 + modelBuilder
23 + .HasDefaultSchema("users")
24 + .HasAnnotation("ProductVersion", "10.0.5")
25 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
26 +
27 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
28 +
29 + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
30 + {
31 + b.Property<int>("Id")
32 + .ValueGeneratedOnAdd()
33 + .HasColumnType("integer");
34 +
35 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
36 +
37 + b.Property<string>("FriendlyName")
38 + .HasColumnType("text");
39 +
40 + b.Property<string>("Xml")
41 + .HasColumnType("text");
42 +
43 + b.HasKey("Id");
44 +
45 + b.ToTable("DataProtectionKeys", "users");
46 + });
47 +
48 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
49 + {
50 + b.Property<int>("Id")
51 + .ValueGeneratedOnAdd()
52 + .HasColumnType("integer");
53 +
54 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
55 +
56 + b.Property<string>("ClaimType")
57 + .HasColumnType("text");
58 +
59 + b.Property<string>("ClaimValue")
60 + .HasColumnType("text");
61 +
62 + b.Property<Guid>("RoleId")
63 + .HasColumnType("uuid");
64 +
65 + b.HasKey("Id");
66 +
67 + b.HasIndex("RoleId");
68 +
69 + b.ToTable("AspNetRoleClaims", "users");
70 + });
71 +
72 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
73 + {
74 + b.Property<int>("Id")
75 + .ValueGeneratedOnAdd()
76 + .HasColumnType("integer");
77 +
78 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
79 +
80 + b.Property<string>("ClaimType")
81 + .HasColumnType("text");
82 +
83 + b.Property<string>("ClaimValue")
84 + .HasColumnType("text");
85 +
86 + b.Property<Guid>("UserId")
87 + .HasColumnType("uuid");
88 +
89 + b.HasKey("Id");
90 +
91 + b.HasIndex("UserId");
92 +
93 + b.ToTable("AspNetUserClaims", "users");
94 + });
95 +
96 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
97 + {
98 + b.Property<string>("LoginProvider")
99 + .HasColumnType("text");
100 +
101 + b.Property<string>("ProviderKey")
102 + .HasColumnType("text");
103 +
104 + b.Property<string>("ProviderDisplayName")
105 + .HasColumnType("text");
106 +
107 + b.Property<Guid>("UserId")
108 + .HasColumnType("uuid");
109 +
110 + b.HasKey("LoginProvider", "ProviderKey");
111 +
112 + b.HasIndex("UserId");
113 +
114 + b.ToTable("AspNetUserLogins", "users");
115 + });
116 +
117 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
118 + {
119 + b.Property<Guid>("UserId")
120 + .HasColumnType("uuid");
121 +
122 + b.Property<Guid>("RoleId")
123 + .HasColumnType("uuid");
124 +
125 + b.HasKey("UserId", "RoleId");
126 +
127 + b.HasIndex("RoleId");
128 +
129 + b.ToTable("AspNetUserRoles", "users");
130 + });
131 +
132 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
133 + {
134 + b.Property<Guid>("UserId")
135 + .HasColumnType("uuid");
136 +
137 + b.Property<string>("LoginProvider")
138 + .HasColumnType("text");
139 +
140 + b.Property<string>("Name")
141 + .HasColumnType("text");
142 +
143 + b.Property<string>("Value")
144 + .HasColumnType("text");
145 +
146 + b.HasKey("UserId", "LoginProvider", "Name");
147 +
148 + b.ToTable("AspNetUserTokens", "users");
149 + });
150 +
151 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
152 + {
153 + b.Property<Guid>("Id")
154 + .ValueGeneratedOnAdd()
155 + .HasColumnType("uuid");
156 +
157 + b.Property<Guid>("AppUserId")
158 + .HasColumnType("uuid");
159 +
160 + b.Property<DateTime>("CreatedAt")
161 + .HasColumnType("timestamp with time zone");
162 +
163 + b.Property<DateTime>("ExpirationDT")
164 + .HasColumnType("timestamp with time zone");
165 +
166 + b.Property<DateTime>("PreviousExpirationDT")
167 + .HasColumnType("timestamp with time zone");
168 +
169 + b.Property<string>("PreviousRefreshToken")
170 + .HasMaxLength(64)
171 + .HasColumnType("character varying(64)");
172 +
173 + b.Property<string>("RefreshToken")
174 + .IsRequired()
175 + .HasMaxLength(64)
176 + .HasColumnType("character varying(64)");
177 +
178 + b.Property<DateTime>("UpdatedAt")
179 + .HasColumnType("timestamp with time zone");
180 +
181 + b.HasKey("Id");
182 +
183 + b.HasIndex("AppUserId");
184 +
185 + b.ToTable("RefreshTokens", "users");
186 + });
187 +
188 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRole", b =>
189 + {
190 + b.Property<Guid>("Id")
191 + .ValueGeneratedOnAdd()
192 + .HasColumnType("uuid");
193 +
194 + b.Property<string>("ConcurrencyStamp")
195 + .IsConcurrencyToken()
196 + .HasColumnType("text");
197 +
198 + b.Property<string>("Name")
199 + .HasMaxLength(256)
200 + .HasColumnType("character varying(256)");
201 +
202 + b.Property<string>("NormalizedName")
203 + .HasMaxLength(256)
204 + .HasColumnType("character varying(256)");
205 +
206 + b.HasKey("Id");
207 +
208 + b.HasIndex("NormalizedName")
209 + .IsUnique()
210 + .HasDatabaseName("RoleNameIndex");
211 +
212 + b.ToTable("AspNetRoles", "users");
213 + });
214 +
215 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
216 + {
217 + b.Property<Guid>("Id")
218 + .ValueGeneratedOnAdd()
219 + .HasColumnType("uuid");
220 +
221 + b.Property<int>("AccessFailedCount")
222 + .HasColumnType("integer");
223 +
224 + b.Property<string>("ConcurrencyStamp")
225 + .IsConcurrencyToken()
226 + .HasColumnType("text");
227 +
228 + b.Property<string>("Email")
229 + .HasMaxLength(256)
230 + .HasColumnType("character varying(256)");
231 +
232 + b.Property<bool>("EmailConfirmed")
233 + .HasColumnType("boolean");
234 +
235 + b.Property<string>("FirstName")
236 + .IsRequired()
237 + .HasMaxLength(128)
238 + .HasColumnType("character varying(128)");
239 +
240 + b.Property<string>("LastName")
241 + .IsRequired()
242 + .HasMaxLength(128)
243 + .HasColumnType("character varying(128)");
244 +
245 + b.Property<bool>("LockoutEnabled")
246 + .HasColumnType("boolean");
247 +
248 + b.Property<DateTimeOffset?>("LockoutEnd")
249 + .HasColumnType("timestamp with time zone");
250 +
251 + b.Property<string>("NormalizedEmail")
252 + .HasMaxLength(256)
253 + .HasColumnType("character varying(256)");
254 +
255 + b.Property<string>("NormalizedUserName")
256 + .HasMaxLength(256)
257 + .HasColumnType("character varying(256)");
258 +
259 + b.Property<string>("PasswordHash")
260 + .HasColumnType("text");
261 +
262 + b.Property<string>("PhoneNumber")
263 + .HasColumnType("text");
264 +
265 + b.Property<bool>("PhoneNumberConfirmed")
266 + .HasColumnType("boolean");
267 +
268 + b.Property<string>("SecurityStamp")
269 + .HasColumnType("text");
270 +
271 + b.Property<bool>("TwoFactorEnabled")
272 + .HasColumnType("boolean");
273 +
274 + b.Property<string>("UserName")
275 + .HasMaxLength(256)
276 + .HasColumnType("character varying(256)");
277 +
278 + b.HasKey("Id");
279 +
280 + b.HasIndex("NormalizedEmail")
281 + .HasDatabaseName("EmailIndex");
282 +
283 + b.HasIndex("NormalizedUserName")
284 + .IsUnique()
285 + .HasDatabaseName("UserNameIndex");
286 +
287 + b.ToTable("AspNetUsers", "users");
288 + });
289 +
290 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
291 + {
292 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
293 + .WithMany()
294 + .HasForeignKey("RoleId")
295 + .OnDelete(DeleteBehavior.Restrict)
296 + .IsRequired();
297 + });
298 +
299 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
300 + {
301 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
302 + .WithMany()
303 + .HasForeignKey("UserId")
304 + .OnDelete(DeleteBehavior.Restrict)
305 + .IsRequired();
306 + });
307 +
308 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
309 + {
310 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
311 + .WithMany()
312 + .HasForeignKey("UserId")
313 + .OnDelete(DeleteBehavior.Restrict)
314 + .IsRequired();
315 + });
316 +
317 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
318 + {
319 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
320 + .WithMany()
321 + .HasForeignKey("RoleId")
322 + .OnDelete(DeleteBehavior.Restrict)
323 + .IsRequired();
324 +
325 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
326 + .WithMany()
327 + .HasForeignKey("UserId")
328 + .OnDelete(DeleteBehavior.Restrict)
329 + .IsRequired();
330 + });
331 +
332 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
333 + {
334 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
335 + .WithMany()
336 + .HasForeignKey("UserId")
337 + .OnDelete(DeleteBehavior.Restrict)
338 + .IsRequired();
339 + });
340 +
341 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
342 + {
343 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", "AppUser")
344 + .WithMany("RefreshTokens")
345 + .HasForeignKey("AppUserId")
346 + .OnDelete(DeleteBehavior.Restrict)
347 + .IsRequired();
348 +
349 + b.Navigation("AppUser");
350 + });
351 +
352 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
353 + {
354 + b.Navigation("RefreshTokens");
355 + });
356 +#pragma warning restore 612, 618
357 + }
358 + }
359 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.cs +310 −0
@@ -0,0 +1,310 @@
1 +using System;
2 +using Microsoft.EntityFrameworkCore.Migrations;
3 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
4 +
5 +#nullable disable
6 +
7 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Migrations
8 +{
9 + /// <inheritdoc />
10 + public partial class Init : Migration
11 + {
12 + /// <inheritdoc />
13 + protected override void Up(MigrationBuilder migrationBuilder)
14 + {
15 + migrationBuilder.EnsureSchema(
16 + name: "users");
17 +
18 + migrationBuilder.CreateTable(
19 + name: "AspNetRoles",
20 + schema: "users",
21 + columns: table => new
22 + {
23 + Id = table.Column<Guid>(type: "uuid", nullable: false),
24 + Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
25 + NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
26 + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
27 + },
28 + constraints: table =>
29 + {
30 + table.PrimaryKey("PK_AspNetRoles", x => x.Id);
31 + });
32 +
33 + migrationBuilder.CreateTable(
34 + name: "AspNetUsers",
35 + schema: "users",
36 + columns: table => new
37 + {
38 + Id = table.Column<Guid>(type: "uuid", nullable: false),
39 + FirstName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
40 + LastName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
41 + UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
42 + NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
43 + Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
44 + NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
45 + EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
46 + PasswordHash = table.Column<string>(type: "text", nullable: true),
47 + SecurityStamp = table.Column<string>(type: "text", nullable: true),
48 + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
49 + PhoneNumber = table.Column<string>(type: "text", nullable: true),
50 + PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
51 + TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
52 + LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
53 + LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
54 + AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
55 + },
56 + constraints: table =>
57 + {
58 + table.PrimaryKey("PK_AspNetUsers", x => x.Id);
59 + });
60 +
61 + migrationBuilder.CreateTable(
62 + name: "DataProtectionKeys",
63 + schema: "users",
64 + columns: table => new
65 + {
66 + Id = table.Column<int>(type: "integer", nullable: false)
67 + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
68 + FriendlyName = table.Column<string>(type: "text", nullable: true),
69 + Xml = table.Column<string>(type: "text", nullable: true)
70 + },
71 + constraints: table =>
72 + {
73 + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
74 + });
75 +
76 + migrationBuilder.CreateTable(
77 + name: "AspNetRoleClaims",
78 + schema: "users",
79 + columns: table => new
80 + {
81 + Id = table.Column<int>(type: "integer", nullable: false)
82 + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
83 + RoleId = table.Column<Guid>(type: "uuid", nullable: false),
84 + ClaimType = table.Column<string>(type: "text", nullable: true),
85 + ClaimValue = table.Column<string>(type: "text", nullable: true)
86 + },
87 + constraints: table =>
88 + {
89 + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
90 + table.ForeignKey(
91 + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
92 + column: x => x.RoleId,
93 + principalSchema: "users",
94 + principalTable: "AspNetRoles",
95 + principalColumn: "Id",
96 + onDelete: ReferentialAction.Restrict);
97 + });
98 +
99 + migrationBuilder.CreateTable(
100 + name: "AspNetUserClaims",
101 + schema: "users",
102 + columns: table => new
103 + {
104 + Id = table.Column<int>(type: "integer", nullable: false)
105 + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
106 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
107 + ClaimType = table.Column<string>(type: "text", nullable: true),
108 + ClaimValue = table.Column<string>(type: "text", nullable: true)
109 + },
110 + constraints: table =>
111 + {
112 + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
113 + table.ForeignKey(
114 + name: "FK_AspNetUserClaims_AspNetUsers_UserId",
115 + column: x => x.UserId,
116 + principalSchema: "users",
117 + principalTable: "AspNetUsers",
118 + principalColumn: "Id",
119 + onDelete: ReferentialAction.Restrict);
120 + });
121 +
122 + migrationBuilder.CreateTable(
123 + name: "AspNetUserLogins",
124 + schema: "users",
125 + columns: table => new
126 + {
127 + LoginProvider = table.Column<string>(type: "text", nullable: false),
128 + ProviderKey = table.Column<string>(type: "text", nullable: false),
129 + ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
130 + UserId = table.Column<Guid>(type: "uuid", nullable: false)
131 + },
132 + constraints: table =>
133 + {
134 + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
135 + table.ForeignKey(
136 + name: "FK_AspNetUserLogins_AspNetUsers_UserId",
137 + column: x => x.UserId,
138 + principalSchema: "users",
139 + principalTable: "AspNetUsers",
140 + principalColumn: "Id",
141 + onDelete: ReferentialAction.Restrict);
142 + });
143 +
144 + migrationBuilder.CreateTable(
145 + name: "AspNetUserRoles",
146 + schema: "users",
147 + columns: table => new
148 + {
149 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
150 + RoleId = table.Column<Guid>(type: "uuid", nullable: false)
151 + },
152 + constraints: table =>
153 + {
154 + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
155 + table.ForeignKey(
156 + name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
157 + column: x => x.RoleId,
158 + principalSchema: "users",
159 + principalTable: "AspNetRoles",
160 + principalColumn: "Id",
161 + onDelete: ReferentialAction.Restrict);
162 + table.ForeignKey(
163 + name: "FK_AspNetUserRoles_AspNetUsers_UserId",
164 + column: x => x.UserId,
165 + principalSchema: "users",
166 + principalTable: "AspNetUsers",
167 + principalColumn: "Id",
168 + onDelete: ReferentialAction.Restrict);
169 + });
170 +
171 + migrationBuilder.CreateTable(
172 + name: "AspNetUserTokens",
173 + schema: "users",
174 + columns: table => new
175 + {
176 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
177 + LoginProvider = table.Column<string>(type: "text", nullable: false),
178 + Name = table.Column<string>(type: "text", nullable: false),
179 + Value = table.Column<string>(type: "text", nullable: true)
180 + },
181 + constraints: table =>
182 + {
183 + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
184 + table.ForeignKey(
185 + name: "FK_AspNetUserTokens_AspNetUsers_UserId",
186 + column: x => x.UserId,
187 + principalSchema: "users",
188 + principalTable: "AspNetUsers",
189 + principalColumn: "Id",
190 + onDelete: ReferentialAction.Restrict);
191 + });
192 +
193 + migrationBuilder.CreateTable(
194 + name: "RefreshTokens",
195 + schema: "users",
196 + columns: table => new
197 + {
198 + Id = table.Column<Guid>(type: "uuid", nullable: false),
199 + RefreshToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
200 + ExpirationDT = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
201 + PreviousRefreshToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
202 + PreviousExpirationDT = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
203 + AppUserId = table.Column<Guid>(type: "uuid", nullable: false),
204 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
205 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
206 + },
207 + constraints: table =>
208 + {
209 + table.PrimaryKey("PK_RefreshTokens", x => x.Id);
210 + table.ForeignKey(
211 + name: "FK_RefreshTokens_AspNetUsers_AppUserId",
212 + column: x => x.AppUserId,
213 + principalSchema: "users",
214 + principalTable: "AspNetUsers",
215 + principalColumn: "Id",
216 + onDelete: ReferentialAction.Restrict);
217 + });
218 +
219 + migrationBuilder.CreateIndex(
220 + name: "IX_AspNetRoleClaims_RoleId",
221 + schema: "users",
222 + table: "AspNetRoleClaims",
223 + column: "RoleId");
224 +
225 + migrationBuilder.CreateIndex(
226 + name: "RoleNameIndex",
227 + schema: "users",
228 + table: "AspNetRoles",
229 + column: "NormalizedName",
230 + unique: true);
231 +
232 + migrationBuilder.CreateIndex(
233 + name: "IX_AspNetUserClaims_UserId",
234 + schema: "users",
235 + table: "AspNetUserClaims",
236 + column: "UserId");
237 +
238 + migrationBuilder.CreateIndex(
239 + name: "IX_AspNetUserLogins_UserId",
240 + schema: "users",
241 + table: "AspNetUserLogins",
242 + column: "UserId");
243 +
244 + migrationBuilder.CreateIndex(
245 + name: "IX_AspNetUserRoles_RoleId",
246 + schema: "users",
247 + table: "AspNetUserRoles",
248 + column: "RoleId");
249 +
250 + migrationBuilder.CreateIndex(
251 + name: "EmailIndex",
252 + schema: "users",
253 + table: "AspNetUsers",
254 + column: "NormalizedEmail");
255 +
256 + migrationBuilder.CreateIndex(
257 + name: "UserNameIndex",
258 + schema: "users",
259 + table: "AspNetUsers",
260 + column: "NormalizedUserName",
261 + unique: true);
262 +
263 + migrationBuilder.CreateIndex(
264 + name: "IX_RefreshTokens_AppUserId",
265 + schema: "users",
266 + table: "RefreshTokens",
267 + column: "AppUserId");
268 + }
269 +
270 + /// <inheritdoc />
271 + protected override void Down(MigrationBuilder migrationBuilder)
272 + {
273 + migrationBuilder.DropTable(
274 + name: "AspNetRoleClaims",
275 + schema: "users");
276 +
277 + migrationBuilder.DropTable(
278 + name: "AspNetUserClaims",
279 + schema: "users");
280 +
281 + migrationBuilder.DropTable(
282 + name: "AspNetUserLogins",
283 + schema: "users");
284 +
285 + migrationBuilder.DropTable(
286 + name: "AspNetUserRoles",
287 + schema: "users");
288 +
289 + migrationBuilder.DropTable(
290 + name: "AspNetUserTokens",
291 + schema: "users");
292 +
293 + migrationBuilder.DropTable(
294 + name: "DataProtectionKeys",
295 + schema: "users");
296 +
297 + migrationBuilder.DropTable(
298 + name: "RefreshTokens",
299 + schema: "users");
300 +
301 + migrationBuilder.DropTable(
302 + name: "AspNetRoles",
303 + schema: "users");
304 +
305 + migrationBuilder.DropTable(
306 + name: "AspNetUsers",
307 + schema: "users");
308 + }
309 + }
310 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs +356 −0
@@ -0,0 +1,356 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
6 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
7 +using SplitApp.Modules.Users.Infrastructure.Persistence;
8 +
9 +#nullable disable
10 +
11 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Migrations
12 +{
13 + [DbContext(typeof(UsersDbContext))]
14 + partial class UsersDbContextModelSnapshot : ModelSnapshot
15 + {
16 + protected override void BuildModel(ModelBuilder modelBuilder)
17 + {
18 +#pragma warning disable 612, 618
19 + modelBuilder
20 + .HasDefaultSchema("users")
21 + .HasAnnotation("ProductVersion", "10.0.5")
22 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
23 +
24 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
25 +
26 + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
27 + {
28 + b.Property<int>("Id")
29 + .ValueGeneratedOnAdd()
30 + .HasColumnType("integer");
31 +
32 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
33 +
34 + b.Property<string>("FriendlyName")
35 + .HasColumnType("text");
36 +
37 + b.Property<string>("Xml")
38 + .HasColumnType("text");
39 +
40 + b.HasKey("Id");
41 +
42 + b.ToTable("DataProtectionKeys", "users");
43 + });
44 +
45 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
46 + {
47 + b.Property<int>("Id")
48 + .ValueGeneratedOnAdd()
49 + .HasColumnType("integer");
50 +
51 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
52 +
53 + b.Property<string>("ClaimType")
54 + .HasColumnType("text");
55 +
56 + b.Property<string>("ClaimValue")
57 + .HasColumnType("text");
58 +
59 + b.Property<Guid>("RoleId")
60 + .HasColumnType("uuid");
61 +
62 + b.HasKey("Id");
63 +
64 + b.HasIndex("RoleId");
65 +
66 + b.ToTable("AspNetRoleClaims", "users");
67 + });
68 +
69 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
70 + {
71 + b.Property<int>("Id")
72 + .ValueGeneratedOnAdd()
73 + .HasColumnType("integer");
74 +
75 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
76 +
77 + b.Property<string>("ClaimType")
78 + .HasColumnType("text");
79 +
80 + b.Property<string>("ClaimValue")
81 + .HasColumnType("text");
82 +
83 + b.Property<Guid>("UserId")
84 + .HasColumnType("uuid");
85 +
86 + b.HasKey("Id");
87 +
88 + b.HasIndex("UserId");
89 +
90 + b.ToTable("AspNetUserClaims", "users");
91 + });
92 +
93 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
94 + {
95 + b.Property<string>("LoginProvider")
96 + .HasColumnType("text");
97 +
98 + b.Property<string>("ProviderKey")
99 + .HasColumnType("text");
100 +
101 + b.Property<string>("ProviderDisplayName")
102 + .HasColumnType("text");
103 +
104 + b.Property<Guid>("UserId")
105 + .HasColumnType("uuid");
106 +
107 + b.HasKey("LoginProvider", "ProviderKey");
108 +
109 + b.HasIndex("UserId");
110 +
111 + b.ToTable("AspNetUserLogins", "users");
112 + });
113 +
114 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
115 + {
116 + b.Property<Guid>("UserId")
117 + .HasColumnType("uuid");
118 +
119 + b.Property<Guid>("RoleId")
120 + .HasColumnType("uuid");
121 +
122 + b.HasKey("UserId", "RoleId");
123 +
124 + b.HasIndex("RoleId");
125 +
126 + b.ToTable("AspNetUserRoles", "users");
127 + });
128 +
129 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
130 + {
131 + b.Property<Guid>("UserId")
132 + .HasColumnType("uuid");
133 +
134 + b.Property<string>("LoginProvider")
135 + .HasColumnType("text");
136 +
137 + b.Property<string>("Name")
138 + .HasColumnType("text");
139 +
140 + b.Property<string>("Value")
141 + .HasColumnType("text");
142 +
143 + b.HasKey("UserId", "LoginProvider", "Name");
144 +
145 + b.ToTable("AspNetUserTokens", "users");
146 + });
147 +
148 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
149 + {
150 + b.Property<Guid>("Id")
151 + .ValueGeneratedOnAdd()
152 + .HasColumnType("uuid");
153 +
154 + b.Property<Guid>("AppUserId")
155 + .HasColumnType("uuid");
156 +
157 + b.Property<DateTime>("CreatedAt")
158 + .HasColumnType("timestamp with time zone");
159 +
160 + b.Property<DateTime>("ExpirationDT")
161 + .HasColumnType("timestamp with time zone");
162 +
163 + b.Property<DateTime>("PreviousExpirationDT")
164 + .HasColumnType("timestamp with time zone");
165 +
166 + b.Property<string>("PreviousRefreshToken")
167 + .HasMaxLength(64)
168 + .HasColumnType("character varying(64)");
169 +
170 + b.Property<string>("RefreshToken")
171 + .IsRequired()
172 + .HasMaxLength(64)
173 + .HasColumnType("character varying(64)");
174 +
175 + b.Property<DateTime>("UpdatedAt")
176 + .HasColumnType("timestamp with time zone");
177 +
178 + b.HasKey("Id");
179 +
180 + b.HasIndex("AppUserId");
181 +
182 + b.ToTable("RefreshTokens", "users");
183 + });
184 +
185 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRole", b =>
186 + {
187 + b.Property<Guid>("Id")
188 + .ValueGeneratedOnAdd()
189 + .HasColumnType("uuid");
190 +
191 + b.Property<string>("ConcurrencyStamp")
192 + .IsConcurrencyToken()
193 + .HasColumnType("text");
194 +
195 + b.Property<string>("Name")
196 + .HasMaxLength(256)
197 + .HasColumnType("character varying(256)");
198 +
199 + b.Property<string>("NormalizedName")
200 + .HasMaxLength(256)
201 + .HasColumnType("character varying(256)");
202 +
203 + b.HasKey("Id");
204 +
205 + b.HasIndex("NormalizedName")
206 + .IsUnique()
207 + .HasDatabaseName("RoleNameIndex");
208 +
209 + b.ToTable("AspNetRoles", "users");
210 + });
211 +
212 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
213 + {
214 + b.Property<Guid>("Id")
215 + .ValueGeneratedOnAdd()
216 + .HasColumnType("uuid");
217 +
218 + b.Property<int>("AccessFailedCount")
219 + .HasColumnType("integer");
220 +
221 + b.Property<string>("ConcurrencyStamp")
222 + .IsConcurrencyToken()
223 + .HasColumnType("text");
224 +
225 + b.Property<string>("Email")
226 + .HasMaxLength(256)
227 + .HasColumnType("character varying(256)");
228 +
229 + b.Property<bool>("EmailConfirmed")
230 + .HasColumnType("boolean");
231 +
232 + b.Property<string>("FirstName")
233 + .IsRequired()
234 + .HasMaxLength(128)
235 + .HasColumnType("character varying(128)");
236 +
237 + b.Property<string>("LastName")
238 + .IsRequired()
239 + .HasMaxLength(128)
240 + .HasColumnType("character varying(128)");
241 +
242 + b.Property<bool>("LockoutEnabled")
243 + .HasColumnType("boolean");
244 +
245 + b.Property<DateTimeOffset?>("LockoutEnd")
246 + .HasColumnType("timestamp with time zone");
247 +
248 + b.Property<string>("NormalizedEmail")
249 + .HasMaxLength(256)
250 + .HasColumnType("character varying(256)");
251 +
252 + b.Property<string>("NormalizedUserName")
253 + .HasMaxLength(256)
254 + .HasColumnType("character varying(256)");
255 +
256 + b.Property<string>("PasswordHash")
257 + .HasColumnType("text");
258 +
259 + b.Property<string>("PhoneNumber")
260 + .HasColumnType("text");
261 +
262 + b.Property<bool>("PhoneNumberConfirmed")
263 + .HasColumnType("boolean");
264 +
265 + b.Property<string>("SecurityStamp")
266 + .HasColumnType("text");
267 +
268 + b.Property<bool>("TwoFactorEnabled")
269 + .HasColumnType("boolean");
270 +
271 + b.Property<string>("UserName")
272 + .HasMaxLength(256)
273 + .HasColumnType("character varying(256)");
274 +
275 + b.HasKey("Id");
276 +
277 + b.HasIndex("NormalizedEmail")
278 + .HasDatabaseName("EmailIndex");
279 +
280 + b.HasIndex("NormalizedUserName")
281 + .IsUnique()
282 + .HasDatabaseName("UserNameIndex");
283 +
284 + b.ToTable("AspNetUsers", "users");
285 + });
286 +
287 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
288 + {
289 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
290 + .WithMany()
291 + .HasForeignKey("RoleId")
292 + .OnDelete(DeleteBehavior.Restrict)
293 + .IsRequired();
294 + });
295 +
296 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
297 + {
298 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
299 + .WithMany()
300 + .HasForeignKey("UserId")
301 + .OnDelete(DeleteBehavior.Restrict)
302 + .IsRequired();
303 + });
304 +
305 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
306 + {
307 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
308 + .WithMany()
309 + .HasForeignKey("UserId")
310 + .OnDelete(DeleteBehavior.Restrict)
311 + .IsRequired();
312 + });
313 +
314 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
315 + {
316 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
317 + .WithMany()
318 + .HasForeignKey("RoleId")
319 + .OnDelete(DeleteBehavior.Restrict)
320 + .IsRequired();
321 +
322 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
323 + .WithMany()
324 + .HasForeignKey("UserId")
325 + .OnDelete(DeleteBehavior.Restrict)
326 + .IsRequired();
327 + });
328 +
329 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
330 + {
331 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
332 + .WithMany()
333 + .HasForeignKey("UserId")
334 + .OnDelete(DeleteBehavior.Restrict)
335 + .IsRequired();
336 + });
337 +
338 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
339 + {
340 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", "AppUser")
341 + .WithMany("RefreshTokens")
342 + .HasForeignKey("AppUserId")
343 + .OnDelete(DeleteBehavior.Restrict)
344 + .IsRequired();
345 +
346 + b.Navigation("AppUser");
347 + });
348 +
349 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
350 + {
351 + b.Navigation("RefreshTokens");
352 + });
353 +#pragma warning restore 612, 618
354 + }
355 + }
356 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/BaseRepository.cs +29 −0
@@ -0,0 +1,29 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
6 +
7 +public class BaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity
8 +{
9 + protected readonly UsersDbContext DbContext;
10 + protected readonly DbSet<TEntity> DbSet;
11 +
12 + public BaseRepository(UsersDbContext dbContext)
13 + {
14 + DbContext = dbContext;
15 + DbSet = dbContext.Set<TEntity>();
16 + }
17 +
18 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync();
19 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id);
20 + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity;
21 + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity;
22 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
23 + {
24 + var entity = await GetByIdAsync(id);
25 + if (entity == null) return null;
26 + return DbSet.Remove(entity).Entity;
27 + }
28 + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id);
29 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs +42 −0
@@ -0,0 +1,42 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Users.Application.Contracts;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +
5 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
6 +
7 +public class RefreshTokenRepository : BaseRepository<AppRefreshToken>, IRefreshTokenRepository
8 +{
9 + public RefreshTokenRepository(UsersDbContext dbContext) : base(dbContext)
10 + {
11 + }
12 +
13 + public async Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue)
14 + {
15 + var now = DateTime.UtcNow;
16 + return await DbContext.RefreshTokens
17 + .Where(t => t.AppUserId == userId &&
18 + ((t.RefreshToken == refreshTokenValue && t.ExpirationDT > now) ||
19 + (t.PreviousRefreshToken == refreshTokenValue && t.PreviousExpirationDT > now)))
20 + .ToListAsync();
21 + }
22 +
23 + public async Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue)
24 + {
25 + return await DbContext.RefreshTokens
26 + .Where(t => t.AppUserId == userId &&
27 + (t.RefreshToken == refreshTokenValue || t.PreviousRefreshToken == refreshTokenValue))
28 + .ToListAsync();
29 + }
30 +
31 + public async Task<int> RemoveExpiredForUserAsync(Guid userId)
32 + {
33 + return await DbContext.RefreshTokens
34 + .Where(t => t.AppUserId == userId && t.ExpirationDT < DateTime.UtcNow)
35 + .ExecuteDeleteAsync();
36 + }
37 +
38 + public void Remove(AppRefreshToken token)
39 + {
40 + DbContext.RefreshTokens.Remove(token);
41 + }
42 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/UserRepository.cs +40 −0
@@ -0,0 +1,40 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Users.Application.Contracts;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +
5 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
6 +
7 +public class UserRepository : BaseRepository<AppUser>, IUserRepository
8 +{
9 + public UserRepository(UsersDbContext dbContext) : base(dbContext)
10 + {
11 + }
12 +
13 + public async Task<int> CountAsync()
14 + {
15 + return await DbContext.Users.CountAsync();
16 + }
17 +
18 + public async Task<IEnumerable<AppUser>> GetRecentAsync(int take)
19 + {
20 + return await DbContext.Users
21 + .OrderByDescending(u => u.Id)
22 + .Take(take)
23 + .ToListAsync();
24 + }
25 +
26 + public async Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId)
27 + {
28 + return await DbContext.Users
29 + .Include(u => u.RefreshTokens)
30 + .FirstOrDefaultAsync(u => u.Id == userId);
31 + }
32 +
33 + public async Task<IReadOnlyList<AppUser>> GetByIdsAsync(IReadOnlyCollection<Guid> ids)
34 + {
35 + if (ids.Count == 0) return Array.Empty<AppUser>();
36 + return await DbContext.Users
37 + .Where(u => ids.Contains(u.Id))
38 + .ToListAsync();
39 + }
40 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersDbContext.cs +66 −0
@@ -0,0 +1,66 @@
1 +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
2 +using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
3 +using Microsoft.EntityFrameworkCore;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Users.Infrastructure.Persistence;
8 +
9 +public class UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>, IDataProtectionKeyContext
10 +{
11 + public DbSet<AppRefreshToken> RefreshTokens { get; set; } = default!;
12 + public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = default!;
13 +
14 + public UsersDbContext(DbContextOptions<UsersDbContext> options) : base(options)
15 + {
16 + }
17 +
18 + public override int SaveChanges()
19 + {
20 + UpdateTimestamps();
21 + return base.SaveChanges();
22 + }
23 +
24 + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
25 + {
26 + UpdateTimestamps();
27 + return base.SaveChangesAsync(cancellationToken);
28 + }
29 +
30 + private void UpdateTimestamps()
31 + {
32 + var entries = ChangeTracker.Entries<BaseEntity>();
33 + foreach (var entry in entries)
34 + {
35 + if (entry.State == EntityState.Modified)
36 + {
37 + entry.Entity.UpdatedAt = DateTime.UtcNow;
38 + }
39 + }
40 + }
41 +
42 + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
43 + {
44 + base.ConfigureConventions(configurationBuilder);
45 + configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
46 + }
47 +
48 + protected override void OnModelCreating(ModelBuilder builder)
49 + {
50 + base.OnModelCreating(builder);
51 +
52 + builder.HasDefaultSchema("users");
53 +
54 + foreach (var relationship in builder.Model
55 + .GetEntityTypes()
56 + .SelectMany(e => e.GetForeignKeys()))
57 + {
58 + relationship.DeleteBehavior = DeleteBehavior.Restrict;
59 + }
60 +
61 + builder.Entity<AppRefreshToken>()
62 + .HasOne(rt => rt.AppUser)
63 + .WithMany(u => u.RefreshTokens)
64 + .HasForeignKey(rt => rt.AppUserId);
65 + }
66 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersUnitOfWork.cs +23 −0
@@ -0,0 +1,23 @@
1 +using SplitApp.Modules.Users.Application.Contracts;
2 +
3 +namespace SplitApp.Modules.Users.Infrastructure.Persistence;
4 +
5 +public class UsersUnitOfWork : IUsersUnitOfWork
6 +{
7 + private readonly UsersDbContext _db;
8 +
9 + public UsersUnitOfWork(
10 + UsersDbContext db,
11 + IUserRepository users,
12 + IRefreshTokenRepository refreshTokens)
13 + {
14 + _db = db;
15 + Users = users;
16 + RefreshTokens = refreshTokens;
17 + }
18 +
19 + public IUserRepository Users { get; }
20 + public IRefreshTokenRepository RefreshTokens { get; }
21 +
22 + public Task<int> SaveChangesAsync() => _db.SaveChangesAsync();
23 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
@@ -0,0 +1,14 @@
1 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2 +
3 +namespace SplitApp.Modules.Users.Infrastructure.Persistence;
4 +
5 +public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
6 +{
7 + public UtcDateTimeConverter() : base(
8 + v => v.Kind == DateTimeKind.Unspecified
9 + ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
10 + : v.ToUniversalTime(),
11 + v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
12 + {
13 + }
14 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj +34 −0
@@ -0,0 +1,34 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
5 + <ProjectReference Include="..\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
16 + <PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
18 + <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" />
19 + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
20 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
21 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
22 + <PrivateAssets>all</PrivateAssets>
23 + </PackageReference>
24 + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
25 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
26 + </ItemGroup>
27 +
28 + <PropertyGroup>
29 + <TargetFramework>net10.0</TargetFramework>
30 + <ImplicitUsings>enable</ImplicitUsings>
31 + <Nullable>enable</Nullable>
32 + </PropertyGroup>
33 +
34 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/UsersModuleExtensions.cs +141 −0
@@ -0,0 +1,141 @@
1 +using System.Text;
2 +using Microsoft.AspNetCore.Authentication.JwtBearer;
3 +using Microsoft.AspNetCore.Builder;
4 +using Microsoft.AspNetCore.DataProtection;
5 +using Microsoft.AspNetCore.Identity;
6 +using Microsoft.EntityFrameworkCore;
7 +using Microsoft.Extensions.Configuration;
8 +using Microsoft.Extensions.DependencyInjection;
9 +using Microsoft.IdentityModel.Tokens;
10 +using SplitApp.Modules.Users.Application;
11 +using SplitApp.Modules.Users.Application.Contracts;
12 +using SplitApp.Modules.Users.Application.Services;
13 +using SplitApp.Modules.Users.Domain.Entities;
14 +using SplitApp.Modules.Users.Infrastructure.Persistence;
15 +using SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
16 +
17 +namespace SplitApp.Modules.Users.Infrastructure;
18 +
19 +public static class UsersModuleExtensions
20 +{
21 + public static IServiceCollection AddUsersModule(
22 + this IServiceCollection services,
23 + IConfiguration configuration)
24 + {
25 + services.AddDbContext<UsersDbContext>(opt =>
26 + {
27 + opt.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
28 + });
29 +
30 + services.AddIdentity<AppUser, AppRole>(options =>
31 + {
32 + options.Password.RequireDigit = false;
33 + options.Password.RequireLowercase = false;
34 + options.Password.RequireNonAlphanumeric = false;
35 + options.Password.RequireUppercase = false;
36 + options.Password.RequiredLength = 6;
37 + options.User.RequireUniqueEmail = true;
38 + })
39 + .AddDefaultUI()
40 + .AddEntityFrameworkStores<UsersDbContext>()
41 + .AddDefaultTokenProviders();
42 +
43 + services.AddDataProtection().PersistKeysToDbContext<UsersDbContext>();
44 +
45 + services.AddAuthentication()
46 + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
47 + {
48 + options.RequireHttpsMetadata = false;
49 + options.SaveToken = false;
50 + options.TokenValidationParameters = new TokenValidationParameters
51 + {
52 + ValidateIssuer = true,
53 + ValidateAudience = true,
54 + ValidateLifetime = true,
55 + ValidateIssuerSigningKey = true,
56 + ValidIssuer = configuration["JWT:Issuer"],
57 + ValidAudience = configuration["JWT:Audience"],
58 + IssuerSigningKey = new SymmetricSecurityKey(
59 + Encoding.UTF8.GetBytes(configuration["JWT:Key"]!)),
60 + ClockSkew = TimeSpan.Zero
61 + };
62 + });
63 +
64 + services.AddScoped<IUserRepository, UserRepository>();
65 + services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
66 + services.AddScoped<IUsersUnitOfWork, UsersUnitOfWork>();
67 + services.AddScoped<IIdentityService, IdentityService>();
68 +
69 + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<UsersModuleMarker>());
70 +
71 + return services;
72 + }
73 +
74 + public static IApplicationBuilder UseUsersModule(this IApplicationBuilder app)
75 + {
76 + using var scope = app.ApplicationServices.CreateScope();
77 + var db = scope.ServiceProvider.GetRequiredService<UsersDbContext>();
78 + db.Database.Migrate();
79 +
80 + var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
81 + foreach (var roleName in new[] { "admin", "user" })
82 + {
83 + if (!roleManager.RoleExistsAsync(roleName).GetAwaiter().GetResult())
84 + {
85 + roleManager.CreateAsync(new AppRole { Name = roleName }).GetAwaiter().GetResult();
86 + }
87 + }
88 +
89 + var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
90 +
91 + // The demo password for the ordinary accounts is in the source on
92 + // purpose: the running instance is a demo and anyone reading the code is
93 + // meant to be able to sign in and look around.
94 + //
95 + // The administrator is not. That account can change other people's data,
96 + // and this source is shared read-only with people outside the project, so
97 + // its password comes from SEED_ADMIN_PASSWORD and there is no default.
98 + // No variable, no administrator: seeding skips the account rather than
99 + // falling back to something guessable.
100 + const string demoPassword = "Kala.12345";
101 + var adminPassword = Environment.GetEnvironmentVariable("SEED_ADMIN_PASSWORD");
102 +
103 + var seedUsers = new[]
104 + {
105 + (Email: "user@taltech.ee", Password: demoPassword, FirstName: "Test", LastName: "User", Roles: new[] { "user" }),
106 + (Email: "alice@taltech.ee", Password: demoPassword, FirstName: "Alice", LastName: "Johnson", Roles: new[] { "user" }),
107 + (Email: "bob@taltech.ee", Password: demoPassword, FirstName: "Bob", LastName: "Smith", Roles: new[] { "user" }),
108 + (Email: "charlie@taltech.ee", Password: demoPassword, FirstName: "Charlie", LastName: "Brown", Roles: new[] { "user" }),
109 + (Email: "diana@taltech.ee", Password: demoPassword, FirstName: "Diana", LastName: "Miller", Roles: new[] { "user" }),
110 + };
111 +
112 + if (!string.IsNullOrWhiteSpace(adminPassword))
113 + {
114 + seedUsers =
115 + [
116 + (Email: "admin@taltech.ee", Password: adminPassword, FirstName: "Admin", LastName: "User", Roles: new[] { "admin" }),
117 + .. seedUsers,
118 + ];
119 + }
120 +
121 + foreach (var seed in seedUsers)
122 + {
123 + var existing = userManager.FindByEmailAsync(seed.Email).GetAwaiter().GetResult();
124 + if (existing != null) continue;
125 +
126 + var user = new AppUser
127 + {
128 + UserName = seed.Email,
129 + Email = seed.Email,
130 + EmailConfirmed = true,
131 + FirstName = seed.FirstName,
132 + LastName = seed.LastName,
133 + };
134 + var result = userManager.CreateAsync(user, seed.Password).GetAwaiter().GetResult();
135 + if (!result.Succeeded) continue;
136 + userManager.AddToRolesAsync(user, seed.Roles).GetAwaiter().GetResult();
137 + }
138 +
139 + return app;
140 + }
141 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/CalculateSettlementCommand.cs +11 −0
@@ -0,0 +1,11 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Commands;
4 +
5 +/// <summary>Computes per-user balances for a trip, persists a SettlementPlan with
6 +/// minimum-payment SettlementPayments, and returns the new plan id (or null if
7 +/// nobody owes anybody anything). Same logic as POST /Settlements/trip/{id}/calculate
8 +/// — exposed as a MediatR command so the Trips module can chain it from
9 +/// Finalize without a direct project reference to Expenses.</summary>
10 +public record CalculateSettlementCommand(Guid TripId, Guid CreatedByUserId)
11 + : IRequest<Guid?>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/RemoveSettlementPlanCommand.cs +8 −0
@@ -0,0 +1,8 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Commands;
4 +
5 +/// <summary>Deletes the latest settlement plan + payments for a trip.
6 +/// Returns false if any payment in the plan is already Confirmed (caller should
7 +/// surface a "payments-confirmed" error). Used by Trip Reopen flow.</summary>
8 +public record RemoveSettlementPlanCommand(Guid TripId) : IRequest<bool>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/CurrencyDto.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Contracts.Expenses;
2 +
3 +public record CurrencyDto(
4 + Guid Id,
5 + string Code,
6 + string Symbol
7 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/ExpenseSettledEvent.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Events;
4 +
5 +public record ExpenseSettledEvent(Guid ExpenseId, Guid TripId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/SettlementPlanCompletedEvent.cs +10 −0
@@ -0,0 +1,10 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Events;
4 +
5 +/// <summary>
6 +/// Published by the Expenses module when every payment in a settlement plan has been
7 +/// confirmed. The Trips module subscribes to advance trips out of the "Finalizing"
8 +/// state without the Expenses module needing to know about Trip lifecycle.
9 +/// </summary>
10 +public record SettlementPlanCompletedEvent(Guid TripId, Guid SettlementPlanId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetBudgetCategorySpentQuery.cs +6 −0
@@ -0,0 +1,6 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Queries;
4 +
5 +public record GetBudgetCategorySpentQuery(Guid TripId)
6 + : IRequest<IReadOnlyDictionary<Guid, decimal>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetCurrenciesByIdsQuery.cs +6 −0
@@ -0,0 +1,6 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Queries;
4 +
5 +public record GetCurrenciesByIdsQuery(IReadOnlyCollection<Guid> CurrencyIds)
6 + : IRequest<IReadOnlyList<CurrencyDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetTripExpenseTotalsQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Queries;
4 +
5 +public record GetTripExpenseTotalsQuery(Guid TripId) : IRequest<TripExpenseTotalsDto>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/TripExpenseTotalsDto.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Contracts.Expenses;
2 +
3 +public record TripExpenseTotalsDto(
4 + Guid TripId,
5 + IReadOnlyDictionary<string, decimal> TotalsByCurrency,
6 + int ExpenseCount
7 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/SplitApp.Shared.Contracts.csproj +17 −0
@@ -0,0 +1,17 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + </PropertyGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="MediatR.Contracts" Version="2.0.1" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <ProjectReference Include="..\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
15 + </ItemGroup>
16 +
17 +</Project>
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/BudgetCategoryNameDto.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Contracts.Trips;
2 +
3 +public record BudgetCategoryNameDto(
4 + Guid Id,
5 + string Name
6 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Events/TripDeletedEvent.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Events;
4 +
5 +public record TripDeletedEvent(Guid TripId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetBudgetCategoryNamesByIdsQuery.cs +6 −0
@@ -0,0 +1,6 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record GetBudgetCategoryNamesByIdsQuery(IReadOnlyCollection<Guid> CategoryIds)
6 + : IRequest<IReadOnlyList<BudgetCategoryNameDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripByIdQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record GetTripByIdQuery(Guid TripId) : IRequest<TripSummaryDto?>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripParticipantsQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record GetTripParticipantsQuery(Guid TripId) : IRequest<IReadOnlyList<TripParticipantDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/IsTripParticipantQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record IsTripParticipantQuery(Guid TripId, Guid UserId) : IRequest<bool>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripParticipantDto.cs +11 −0
@@ -0,0 +1,11 @@
1 +namespace SplitApp.Shared.Contracts.Trips;
2 +
3 +public record TripParticipantDto(
4 + Guid Id,
5 + Guid TripId,
6 + Guid UserId,
7 + string Role,
8 + string? Nickname,
9 + DateTime JoinedAt,
10 + bool IsActive
11 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripSummaryDto.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Shared.Contracts.Trips;
2 +
3 +public record TripSummaryDto(
4 + Guid Id,
5 + Guid OwnerId,
6 + string Name,
7 + Guid DefaultCurrencyId
8 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Events/UserDeletedEvent.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Users.Events;
4 +
5 +public record UserDeletedEvent(Guid UserId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUserByIdQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Users.Queries;
4 +
5 +public record GetUserByIdQuery(Guid UserId) : IRequest<UserDto?>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUsersByIdsQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Users.Queries;
4 +
5 +public record GetUsersByIdsQuery(IReadOnlyCollection<Guid> UserIds) : IRequest<IReadOnlyList<UserDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/UserDto.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Contracts.Users;
2 +
3 +public record UserDto(
4 + Guid Id,
5 + string DisplayName,
6 + string Email
7 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Auth/IdentityHelpers.cs +59 −0
@@ -0,0 +1,59 @@
1 +using System.IdentityModel.Tokens.Jwt;
2 +using System.Security.Claims;
3 +using System.Text;
4 +using Microsoft.IdentityModel.Tokens;
5 +
6 +namespace SplitApp.Shared.Kernel.Auth;
7 +
8 +public static class IdentityHelpers
9 +{
10 + public static string GenerateJwt(
11 + IEnumerable<Claim> claims,
12 + string key,
13 + string issuer,
14 + string audience,
15 + int expiresInSeconds)
16 + {
17 + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
18 + var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
19 + var expires = DateTime.UtcNow.AddSeconds(expiresInSeconds);
20 + var token = new JwtSecurityToken(
21 + issuer: issuer,
22 + audience: audience,
23 + claims: claims,
24 + expires: expires,
25 + signingCredentials: signingCredentials
26 + );
27 + return new JwtSecurityTokenHandler().WriteToken(token);
28 + }
29 +
30 + /// <summary>
31 + /// Validate JWT token signature and issuer/audience.
32 + /// Ignores expiration - used during token refresh where the JWT is allowed to be expired.
33 + /// </summary>
34 + public static bool ValidateJWT(
35 + string jwt,
36 + string key,
37 + string issuer,
38 + string audience)
39 + {
40 + var tokenHandler = new JwtSecurityTokenHandler();
41 + var validationParameters = new TokenValidationParameters
42 + {
43 + ValidIssuer = issuer,
44 + ValidAudience = audience,
45 + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
46 + ValidateLifetime = false
47 + };
48 +
49 + try
50 + {
51 + tokenHandler.ValidateToken(jwt, validationParameters, out _);
52 + return true;
53 + }
54 + catch (Exception)
55 + {
56 + return false;
57 + }
58 + }
59 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/BaseEntity.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Shared.Kernel.Domain;
2 +
3 +public abstract class BaseEntity : IBaseEntity
4 +{
5 + public Guid Id { get; set; } = Guid.NewGuid();
6 + public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
7 + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
8 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/IBaseEntity.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Kernel.Domain;
2 +
3 +public interface IBaseEntity
4 +{
5 + public Guid Id { get; set; }
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Localization/LangStr.cs +73 −0
@@ -0,0 +1,73 @@
1 +namespace SplitApp.Shared.Kernel.Localization;
2 +
3 +public class LangStr : Dictionary<string, string>
4 +{
5 + public static string DefaultCulture { get; set; } = "en";
6 +
7 + public new string this[string key]
8 + {
9 + get => base[key];
10 + set => base[key] = value;
11 + }
12 +
13 + public LangStr()
14 + {
15 + }
16 +
17 + public LangStr(string value) : this(value, Thread.CurrentThread.CurrentUICulture.Name)
18 + {
19 + }
20 +
21 + public LangStr(string value, string culture)
22 + {
23 + if (culture.Length < 1) throw new ApplicationException("Culture is required!");
24 +
25 + var neutralCulture = culture.Split('-')[0];
26 + this[neutralCulture] = value;
27 +
28 + if (!ContainsKey(DefaultCulture))
29 + {
30 + this[DefaultCulture] = value;
31 + }
32 + }
33 +
34 + public string? Translate(string? culture = null)
35 + {
36 + if (Count == 0) return null;
37 + culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name;
38 +
39 + if (ContainsKey(culture))
40 + {
41 + return this[culture];
42 + }
43 +
44 + var neutralCulture = culture.Split('-')[0];
45 + if (ContainsKey(neutralCulture))
46 + {
47 + return this[neutralCulture];
48 + }
49 +
50 + if (ContainsKey(DefaultCulture))
51 + {
52 + return this[DefaultCulture];
53 + }
54 +
55 + return null;
56 + }
57 +
58 + public void SetTranslation(string value, string? culture = null)
59 + {
60 + culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name;
61 + var neutralCulture = culture.Split('-')[0];
62 + this[neutralCulture] = value;
63 + }
64 +
65 + public override string ToString()
66 + {
67 + return Translate() ?? "????";
68 + }
69 +
70 + public static implicit operator string(LangStr? langStr) => langStr?.ToString() ?? "null";
71 +
72 + public static implicit operator LangStr(string value) => new LangStr(value);
73 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IBaseRepository.cs +13 −0
@@ -0,0 +1,13 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +
3 +namespace SplitApp.Shared.Kernel.Persistence;
4 +
5 +public interface IBaseRepository<TEntity> where TEntity : class, IBaseEntity
6 +{
7 + Task<IEnumerable<TEntity>> GetAllAsync();
8 + Task<TEntity?> GetByIdAsync(Guid id);
9 + TEntity Add(TEntity entity);
10 + TEntity Update(TEntity entity);
11 + Task<TEntity?> RemoveAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IUnitOfWork.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Kernel.Persistence;
2 +
3 +public interface IUnitOfWork
4 +{
5 + Task<int> SaveChangesAsync();
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/SplitApp.Shared.Kernel.csproj +14 −0
@@ -0,0 +1,14 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + </PropertyGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.1" />
11 + <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.1" />
12 + </ItemGroup>
13 +
14 +</Project>
added SplitApp.Modular/src/SplitApp.WebApp/Application/Contracts/IAppUnitOfWork.cs +100 −0
@@ -0,0 +1,100 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +using SplitApp.Shared.Kernel.Domain;
5 +using SplitApp.Shared.Kernel.Persistence;
6 +
7 +namespace SplitApp.WebApp.Application.Contracts;
8 +
9 +/// <summary>
10 +/// Composition-root unit-of-work facade. Aggregates the three module DbContexts
11 +/// so legacy phase 2 BLL services (lifted into WebApp/Application) keep working.
12 +/// Modules themselves never see this — they own their own UoWs.
13 +/// </summary>
14 +public interface IAppUnitOfWork : IUnitOfWork
15 +{
16 + ITripRepository Trips { get; }
17 + IExpenseRepository Expenses { get; }
18 + ITripParticipantRepository TripParticipants { get; }
19 + ITripInvitationRepository TripInvitations { get; }
20 + ISettlementPlanRepository SettlementPlans { get; }
21 + ISettlementPaymentRepository SettlementPayments { get; }
22 + ITripPollRepository TripPolls { get; }
23 + ITripWishlistItemRepository TripWishlistItems { get; }
24 + ISplitPresetRepository SplitPresets { get; }
25 + IBudgetCategoryRepository BudgetCategories { get; }
26 + IRefreshTokenRepository RefreshTokens { get; }
27 + IUserRepository Users { get; }
28 + IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity;
29 +}
30 +
31 +public interface ITripRepository : IBaseRepository<Trip>
32 +{
33 + Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId);
34 + Task<Trip?> GetByIdWithDetailsAsync(Guid id);
35 +}
36 +
37 +public interface IExpenseRepository : IBaseRepository<Expense>
38 +{
39 + Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId);
40 + Task<Expense?> GetByIdWithDetailsAsync(Guid id);
41 +}
42 +
43 +public interface ITripParticipantRepository : IBaseRepository<TripParticipant>
44 +{
45 + Task<bool> IsParticipantAsync(Guid tripId, Guid userId);
46 + Task<bool> IsOrganizerAsync(Guid tripId, Guid userId);
47 + Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId);
48 +}
49 +
50 +public interface ITripInvitationRepository : IBaseRepository<TripInvitation>
51 +{
52 + Task<TripInvitation?> GetByTokenAsync(string token);
53 + Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId);
54 +}
55 +
56 +public interface ISettlementPlanRepository : IBaseRepository<SettlementPlan>
57 +{
58 + Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId);
59 + Task DeletePlanWithPaymentsAsync(Guid planId);
60 +}
61 +
62 +public interface ISettlementPaymentRepository : IBaseRepository<SettlementPayment>
63 +{
64 +}
65 +
66 +public interface ITripPollRepository : IBaseRepository<TripPoll>
67 +{
68 + Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId);
69 + Task<TripPoll?> GetByIdWithDetailsAsync(Guid id);
70 +}
71 +
72 +public interface ITripWishlistItemRepository : IBaseRepository<TripWishlistItem>
73 +{
74 + Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId);
75 +}
76 +
77 +public interface ISplitPresetRepository : IBaseRepository<SplitPreset>
78 +{
79 + Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId);
80 +}
81 +
82 +public interface IBudgetCategoryRepository : IBaseRepository<BudgetCategory>
83 +{
84 + Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId);
85 +}
86 +
87 +public interface IRefreshTokenRepository : IBaseRepository<AppRefreshToken>
88 +{
89 + Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue);
90 + Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue);
91 + Task<int> RemoveExpiredForUserAsync(Guid userId);
92 + void Remove(AppRefreshToken token);
93 +}
94 +
95 +public interface IUserRepository : IBaseRepository<AppUser>
96 +{
97 + Task<int> CountAsync();
98 + Task<IEnumerable<AppUser>> GetRecentAsync(int take);
99 + Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId);
100 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/AppUserBllDto.cs +11 −0
@@ -0,0 +1,11 @@
1 +namespace SplitApp.WebApp.Application.DTO;
2 +
3 +public class AppUserBllDto
4 +{
5 + public Guid Id { get; set; }
6 + public string FirstName { get; set; } = "";
7 + public string LastName { get; set; } = "";
8 + public string? Email { get; set; }
9 +
10 + public string FullName => $"{FirstName} {LastName}".Trim();
11 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BalanceBllDto.cs +10 −0
@@ -0,0 +1,10 @@
1 +namespace SplitApp.WebApp.Application.DTO;
2 +
3 +public class BalanceBllDto
4 +{
5 + public Guid UserId { get; set; }
6 + public string UserFullName { get; set; } = default!;
7 + public decimal Balance { get; set; }
8 + public decimal TotalPaid { get; set; }
9 + public decimal TotalOwed { get; set; }
10 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BudgetCategoryBllDto.cs +22 −0
@@ -0,0 +1,22 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class BudgetCategoryBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid TripId { get; set; }
13 + public TripBllDto? Trip { get; set; }
14 + public string? TripName => Trip?.Name;
15 +
16 + public LangStr Name { get; set; } = new();
17 + public string? IconName { get; set; }
18 + public decimal? PlannedAmount { get; set; }
19 + public int DisplayOrder { get; set; }
20 + public int ExpenseCount { get; set; }
21 + public decimal SpentAmount { get; set; }
22 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/CurrencyBllDto.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class CurrencyBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public string Code { get; set; } = default!;
13 + public LangStr Name { get; set; } = new();
14 + public string Symbol { get; set; } = default!;
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseBllDto.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class ExpenseBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid TripId { get; set; }
18 + public TripBllDto? Trip { get; set; }
19 + public string? TripName => Trip?.Name;
20 +
21 + public Guid PaidByUserId { get; set; }
22 + public AppUserBllDto? PaidByUser { get; set; }
23 + public string? PaidByUserFullName => PaidByUser?.FullName;
24 + public string? PaidByUserEmail => PaidByUser?.Email;
25 +
26 + public Guid? BudgetCategoryId { get; set; }
27 + public BudgetCategoryBllDto? BudgetCategory { get; set; }
28 +
29 + public Guid? CurrencyId { get; set; }
30 + public CurrencyBllDto? Currency { get; set; }
31 +
32 + public decimal Amount { get; set; }
33 + public string? Description { get; set; }
34 + public DateTime ExpenseDate { get; set; }
35 + public ESplitMethod SplitMethod { get; set; }
36 +
37 + public ICollection<ExpenseSplitBllDto>? Splits { get; set; }
38 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseSplitBllDto.cs +19 −0
@@ -0,0 +1,19 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class ExpenseSplitBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid ExpenseId { get; set; }
13 + public Guid UserId { get; set; }
14 + public string? UserFullName { get; set; }
15 + public string? UserEmail { get; set; }
16 +
17 + public decimal Amount { get; set; }
18 + public decimal? Percentage { get; set; }
19 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPaymentBllDto.cs +31 −0
@@ -0,0 +1,31 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class SettlementPaymentBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid SettlementPlanId { get; set; }
18 +
19 + public Guid FromUserId { get; set; }
20 + public AppUserBllDto? FromUser { get; set; }
21 + public string? FromUserFullName => FromUser?.FullName;
22 +
23 + public Guid ToUserId { get; set; }
24 + public AppUserBllDto? ToUser { get; set; }
25 + public string? ToUserFullName => ToUser?.FullName;
26 +
27 + public decimal Amount { get; set; }
28 + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending;
29 + public DateTime? MarkedPaidAt { get; set; }
30 + public DateTime? ConfirmedAt { get; set; }
31 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPlanBllDto.cs +30 −0
@@ -0,0 +1,30 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class SettlementPlanBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid TripId { get; set; }
18 + public TripBllDto? Trip { get; set; }
19 + public string? TripName => Trip?.Name;
20 +
21 + public Guid CreatedByUserId { get; set; }
22 + public AppUserBllDto? CreatedByUser { get; set; }
23 + public string? CreatedByUserFullName => CreatedByUser?.FullName;
24 +
25 + public decimal TotalAmount { get; set; }
26 + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending;
27 + public DateTime? CompletedAt { get; set; }
28 +
29 + public ICollection<SettlementPaymentBllDto>? Payments { get; set; }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SplitPresetBllDto.cs +42 −0
@@ -0,0 +1,42 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class SplitPresetBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid TripId { get; set; }
18 + public TripBllDto? Trip { get; set; }
19 + public string? TripName => Trip?.Name;
20 +
21 + public string Name { get; set; } = default!;
22 + public ESplitMethod SplitMethod { get; set; }
23 +
24 + public Guid CreatedById { get; set; }
25 + public AppUserBllDto? CreatedBy { get; set; }
26 + public string? CreatedByFullName => CreatedBy?.FullName;
27 +
28 + public ICollection<SplitPresetMemberBllDto>? Members { get; set; }
29 +}
30 +
31 +public class SplitPresetMemberBllDto
32 +{
33 + public Guid Id { get; set; }
34 + public DateTime CreatedAt { get; set; }
35 + public DateTime UpdatedAt { get; set; }
36 +
37 + public Guid SplitPresetId { get; set; }
38 + public Guid UserId { get; set; }
39 + public string? UserFullName { get; set; }
40 + public decimal? ShareWeight { get; set; }
41 + public decimal? Percentage { get; set; }
42 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripBllDto.cs +39 −0
@@ -0,0 +1,39 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class TripBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public string Name { get; set; } = default!;
18 + public string? Description { get; set; }
19 + public string? Destination { get; set; }
20 + public DateTime? StartDate { get; set; }
21 + public DateTime? EndDate { get; set; }
22 + public ETripStatus Status { get; set; } = ETripStatus.Active;
23 +
24 + public Guid DefaultCurrencyId { get; set; }
25 + public CurrencyBllDto? DefaultCurrency { get; set; }
26 +
27 + public Guid CreatedById { get; set; }
28 + public AppUserBllDto? CreatedBy { get; set; }
29 + public string? CreatedByFullName => CreatedBy?.FullName;
30 + public string? CreatedByEmail => CreatedBy?.Email;
31 +
32 + public ICollection<TripParticipantBllDto>? Participants { get; set; }
33 + public ICollection<ExpenseBllDto>? Expenses { get; set; }
34 + public ICollection<BudgetCategoryBllDto>? BudgetCategories { get; set; }
35 + public ICollection<TripWishlistItemBllDto>? WishlistItems { get; set; }
36 + public ICollection<TripPollBllDto>? Polls { get; set; }
37 + public ICollection<TripInvitationBllDto>? Invitations { get; set; }
38 + public ICollection<SettlementPlanBllDto>? SettlementPlans { get; set; }
39 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripInvitationBllDto.cs +30 −0
@@ -0,0 +1,30 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class TripInvitationBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid TripId { get; set; }
18 + public TripBllDto? Trip { get; set; }
19 + public string? TripName => Trip?.Name;
20 +
21 + public Guid InvitedByUserId { get; set; }
22 + public AppUserBllDto? InvitedByUser { get; set; }
23 + public string? InvitedByUserFullName => InvitedByUser?.FullName;
24 + public string? InvitedByUserEmail => InvitedByUser?.Email;
25 +
26 + public string Token { get; set; } = default!;
27 + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending;
28 + public DateTime ExpiresAt { get; set; }
29 + public DateTime? RespondedAt { get; set; }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripParticipantBllDto.cs +30 −0
@@ -0,0 +1,30 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class TripParticipantBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid TripId { get; set; }
18 + public TripBllDto? Trip { get; set; }
19 + public Guid UserId { get; set; }
20 + public AppUserBllDto? User { get; set; }
21 + public string? UserFirstName => User?.FirstName;
22 + public string? UserLastName => User?.LastName;
23 + public string? UserEmail => User?.Email;
24 +
25 + public EParticipantRole Role { get; set; } = EParticipantRole.Participant;
26 + public string? Nickname { get; set; }
27 + public DateTime JoinedAt { get; set; }
28 + public DateTime? LeftAt { get; set; }
29 + public bool IsActive { get; set; } = true;
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollBllDto.cs +26 −0
@@ -0,0 +1,26 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class TripPollBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid TripId { get; set; }
13 + public TripBllDto? Trip { get; set; }
14 + public string? TripName => Trip?.Name;
15 +
16 + public Guid CreatedByUserId { get; set; }
17 + public AppUserBllDto? CreatedByUser { get; set; }
18 + public string? CreatedByUserFullName => CreatedByUser?.FullName;
19 +
20 + public string Question { get; set; } = default!;
21 + public bool AllowMultipleVotes { get; set; }
22 + public bool IsAnonymous { get; set; }
23 + public DateTime? ClosedAt { get; set; }
24 +
25 + public ICollection<TripPollOptionBllDto>? Options { get; set; }
26 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollOptionBllDto.cs +18 −0
@@ -0,0 +1,18 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class TripPollOptionBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid PollId { get; set; }
13 + public string Text { get; set; } = default!;
14 + public int DisplayOrder { get; set; }
15 + public int VoteCount { get; set; }
16 + public List<Guid> VoterUserIds { get; set; } = new();
17 + public List<AppUserBllDto> Voters { get; set; } = new();
18 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripWishlistItemBllDto.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Modules.Users.Domain.Entities;
6 +using SplitApp.Shared.Kernel.Domain;
7 +using SplitApp.Shared.Kernel.Localization;
8 +
9 +namespace SplitApp.WebApp.Application.DTO;
10 +
11 +public class TripWishlistItemBllDto
12 +{
13 + public Guid Id { get; set; }
14 + public DateTime CreatedAt { get; set; }
15 + public DateTime UpdatedAt { get; set; }
16 +
17 + public Guid TripId { get; set; }
18 + public TripBllDto? Trip { get; set; }
19 + public string? TripName => Trip?.Name;
20 +
21 + public Guid AddedByUserId { get; set; }
22 + public AppUserBllDto? AddedByUser { get; set; }
23 + public string? AddedByUserFullName => AddedByUser?.FullName;
24 +
25 + public string Title { get; set; } = default!;
26 + public string? Description { get; set; }
27 + public EWishlistCategory Category { get; set; }
28 + public EWishlistPriority Priority { get; set; }
29 + public decimal? EstimatedCost { get; set; }
30 + public string? Url { get; set; }
31 + public string? Location { get; set; }
32 + public bool IsCompleted { get; set; }
33 + public DateTime? CompletedAt { get; set; }
34 + public int DisplayOrder { get; set; }
35 +
36 + public int VoteCount { get; set; }
37 + public List<Guid> VoterUserIds { get; set; } = new();
38 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Helpers/CurrencyConverter.cs +30 −0
@@ -0,0 +1,30 @@
1 +namespace SplitApp.WebApp.Application.Helpers;
2 +
3 +public static class CurrencyConverter
4 +{
5 + // Rates: 1 unit of currency = X EUR (approximate, hardcoded)
6 + private static readonly Dictionary<string, decimal> RatesToEur = new()
7 + {
8 + { "EUR", 1.0m },
9 + { "USD", 0.92m },
10 + { "GBP", 1.16m },
11 + { "SEK", 0.087m },
12 + { "NOK", 0.086m },
13 + };
14 +
15 + /// <summary>
16 + /// Convert amount from one currency to another.
17 + /// Falls back to 1:1 if either currency code is unknown.
18 + /// </summary>
19 + public static decimal Convert(decimal amount, string fromCurrencyCode, string toCurrencyCode)
20 + {
21 + if (fromCurrencyCode == toCurrencyCode) return amount;
22 +
23 + if (!RatesToEur.TryGetValue(fromCurrencyCode, out var fromRate)) return amount;
24 + if (!RatesToEur.TryGetValue(toCurrencyCode, out var toRate)) return amount;
25 +
26 + // Convert: amount in "from" -> EUR -> "to"
27 + var amountInEur = amount * fromRate;
28 + return Math.Round(amountInEur / toRate, 2);
29 + }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/AppUserBllDtoFactory.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class AppUserBllDtoFactory
7 +{
8 + public static AppUserBllDto? Create(AppUser? entity) => entity == null ? null : new AppUserBllDto
9 + {
10 + Id = entity.Id,
11 + FirstName = entity.FirstName,
12 + LastName = entity.LastName,
13 + Email = entity.Email
14 + };
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/BudgetCategoryBllDtoFactory.cs +42 −0
@@ -0,0 +1,42 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class BudgetCategoryBllDtoFactory
7 +{
8 + public static BudgetCategoryBllDto Create(BudgetCategory entity, decimal spentAmount = 0) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + Name = entity.Name,
16 + IconName = entity.IconName,
17 + PlannedAmount = entity.PlannedAmount,
18 + DisplayOrder = entity.DisplayOrder,
19 + ExpenseCount = 0,
20 + SpentAmount = spentAmount
21 + };
22 +
23 + public static List<BudgetCategoryBllDto> CreateList(IEnumerable<BudgetCategory> entities)
24 + => entities.Select(e => Create(e)).ToList();
25 +
26 + public static List<BudgetCategoryBllDto> CreateList(
27 + IEnumerable<BudgetCategory> entities,
28 + IReadOnlyDictionary<Guid, decimal> spentByCategoryId)
29 + => entities
30 + .Select(e => Create(e, spentByCategoryId.TryGetValue(e.Id, out var s) ? s : 0))
31 + .ToList();
32 +
33 + public static BudgetCategory ToEntity(BudgetCategoryBllDto dto) => new()
34 + {
35 + Id = dto.Id,
36 + TripId = dto.TripId,
37 + Name = dto.Name,
38 + IconName = dto.IconName,
39 + PlannedAmount = dto.PlannedAmount,
40 + DisplayOrder = dto.DisplayOrder
41 + };
42 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/CurrencyBllDtoFactory.cs +32 −0
@@ -0,0 +1,32 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +
8 +namespace SplitApp.WebApp.Application.Mappers;
9 +
10 +public static class CurrencyBllDtoFactory
11 +{
12 + public static CurrencyBllDto Create(Currency entity) => new()
13 + {
14 + Id = entity.Id,
15 + CreatedAt = entity.CreatedAt,
16 + UpdatedAt = entity.UpdatedAt,
17 + Code = entity.Code,
18 + Name = entity.Name,
19 + Symbol = entity.Symbol
20 + };
21 +
22 + public static List<CurrencyBllDto> CreateList(IEnumerable<Currency> entities)
23 + => entities.Select(Create).ToList();
24 +
25 + public static Currency ToEntity(CurrencyBllDto dto) => new()
26 + {
27 + Id = dto.Id,
28 + Code = dto.Code,
29 + Name = dto.Name,
30 + Symbol = dto.Symbol
31 + };
32 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/ExpenseBllDtoFactory.cs +71 −0
@@ -0,0 +1,71 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.WebApp.Application.DTO;
4 +
5 +namespace SplitApp.WebApp.Application.Mappers;
6 +
7 +public static class ExpenseBllDtoFactory
8 +{
9 + public static ExpenseBllDto Create(Expense entity, bool includeSplits = false) => new()
10 + {
11 + Id = entity.Id,
12 + CreatedAt = entity.CreatedAt,
13 + UpdatedAt = entity.UpdatedAt,
14 + TripId = entity.TripId,
15 + // entity.Trip is `object?` (Expenses module avoids direct ref to Trip type) — cast back here.
16 + Trip = entity.Trip is Trip trip ? TripBllDtoFactory.Create(trip) : null,
17 + PaidByUserId = entity.PaidByUserId,
18 + PaidByUser = AppUserBllDtoFactory.Create(entity.PaidByUser),
19 + BudgetCategoryId = entity.BudgetCategoryId,
20 + CurrencyId = entity.CurrencyId,
21 + Currency = entity.Currency != null ? CurrencyBllDtoFactory.Create(entity.Currency) : null,
22 + Amount = entity.Amount,
23 + Description = entity.Description,
24 + ExpenseDate = entity.ExpenseDate,
25 + SplitMethod = entity.SplitMethod,
26 + Splits = includeSplits && entity.Splits != null
27 + ? entity.Splits.Select(ExpenseSplitBllDtoFactory.Create).ToList()
28 + : null
29 + };
30 +
31 + public static List<ExpenseBllDto> CreateList(IEnumerable<Expense> entities, bool includeSplits = false)
32 + => entities.Select(e => Create(e, includeSplits)).ToList();
33 +
34 + public static Expense ToEntity(ExpenseBllDto dto) => new()
35 + {
36 + Id = dto.Id,
37 + TripId = dto.TripId,
38 + PaidByUserId = dto.PaidByUserId,
39 + BudgetCategoryId = dto.BudgetCategoryId,
40 + CurrencyId = dto.CurrencyId,
41 + Amount = dto.Amount,
42 + Description = dto.Description,
43 + ExpenseDate = dto.ExpenseDate,
44 + SplitMethod = dto.SplitMethod
45 + };
46 +}
47 +
48 +public static class ExpenseSplitBllDtoFactory
49 +{
50 + public static ExpenseSplitBllDto Create(ExpenseSplit entity) => new()
51 + {
52 + Id = entity.Id,
53 + CreatedAt = entity.CreatedAt,
54 + UpdatedAt = entity.UpdatedAt,
55 + ExpenseId = entity.ExpenseId,
56 + UserId = entity.UserId,
57 + UserFullName = entity.User != null ? $"{entity.User.FirstName} {entity.User.LastName}".Trim() : null,
58 + UserEmail = entity.User?.Email,
59 + Amount = entity.Amount,
60 + Percentage = entity.Percentage
61 + };
62 +
63 + public static ExpenseSplit ToEntity(ExpenseSplitBllDto dto) => new()
64 + {
65 + Id = dto.Id,
66 + ExpenseId = dto.ExpenseId,
67 + UserId = dto.UserId,
68 + Amount = dto.Amount,
69 + Percentage = dto.Percentage
70 + };
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/InvitationBllDtoFactory.cs +36 −0
@@ -0,0 +1,36 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class InvitationBllDtoFactory
7 +{
8 + public static TripInvitationBllDto Create(TripInvitation entity) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + InvitedByUserId = entity.InvitedByUserId,
16 + InvitedByUser = AppUserBllDtoFactory.Create(entity.InvitedByUser),
17 + Token = entity.Token,
18 + Status = entity.Status,
19 + ExpiresAt = entity.ExpiresAt,
20 + RespondedAt = entity.RespondedAt
21 + };
22 +
23 + public static List<TripInvitationBllDto> CreateList(IEnumerable<TripInvitation> entities)
24 + => entities.Select(Create).ToList();
25 +
26 + public static TripInvitation ToEntity(TripInvitationBllDto dto) => new()
27 + {
28 + Id = dto.Id,
29 + TripId = dto.TripId,
30 + InvitedByUserId = dto.InvitedByUserId,
31 + Token = dto.Token,
32 + Status = dto.Status,
33 + ExpiresAt = dto.ExpiresAt,
34 + RespondedAt = dto.RespondedAt
35 + };
36 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/PollBllDtoFactory.cs +63 −0
@@ -0,0 +1,63 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class PollBllDtoFactory
7 +{
8 + public static TripPollBllDto Create(TripPoll entity, bool includeOptions = false) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + CreatedByUserId = entity.CreatedByUserId,
16 + CreatedByUser = AppUserBllDtoFactory.Create(entity.CreatedByUser),
17 + Question = entity.Question,
18 + AllowMultipleVotes = entity.AllowMultipleVotes,
19 + IsAnonymous = entity.IsAnonymous,
20 + ClosedAt = entity.ClosedAt,
21 + Options = includeOptions && entity.Options != null
22 + ? entity.Options.OrderBy(o => o.DisplayOrder).Select(PollOptionBllDtoFactory.Create).ToList()
23 + : null
24 + };
25 +
26 + public static List<TripPollBllDto> CreateList(IEnumerable<TripPoll> entities, bool includeOptions = false)
27 + => entities.Select(p => Create(p, includeOptions)).ToList();
28 +
29 + public static TripPoll ToEntity(TripPollBllDto dto) => new()
30 + {
31 + Id = dto.Id,
32 + TripId = dto.TripId,
33 + CreatedByUserId = dto.CreatedByUserId,
34 + Question = dto.Question,
35 + AllowMultipleVotes = dto.AllowMultipleVotes,
36 + IsAnonymous = dto.IsAnonymous,
37 + ClosedAt = dto.ClosedAt
38 + };
39 +}
40 +
41 +public static class PollOptionBllDtoFactory
42 +{
43 + public static TripPollOptionBllDto Create(TripPollOption entity) => new()
44 + {
45 + Id = entity.Id,
46 + CreatedAt = entity.CreatedAt,
47 + UpdatedAt = entity.UpdatedAt,
48 + PollId = entity.PollId,
49 + Text = entity.Text,
50 + DisplayOrder = entity.DisplayOrder,
51 + VoteCount = entity.Votes?.Count ?? 0,
52 + VoterUserIds = entity.Votes?.Select(v => v.UserId).ToList() ?? new List<Guid>(),
53 + Voters = new List<AppUserBllDto>()
54 + };
55 +
56 + public static TripPollOption ToEntity(TripPollOptionBllDto dto) => new()
57 + {
58 + Id = dto.Id,
59 + PollId = dto.PollId,
60 + Text = dto.Text,
61 + DisplayOrder = dto.DisplayOrder
62 + };
63 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SettlementBllDtoFactory.cs +73 −0
@@ -0,0 +1,73 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.WebApp.Application.DTO;
4 +
5 +namespace SplitApp.WebApp.Application.Mappers;
6 +
7 +public static class SettlementBllDtoFactory
8 +{
9 + public static SettlementPlanBllDto Create(SettlementPlan entity, bool includePayments = false) => new()
10 + {
11 + Id = entity.Id,
12 + CreatedAt = entity.CreatedAt,
13 + UpdatedAt = entity.UpdatedAt,
14 + TripId = entity.TripId,
15 + // entity.Trip is `object?` (Expenses module avoids direct ref to Trip type) — cast back here.
16 + Trip = entity.Trip is Trip trip ? TripBllDtoFactory.Create(trip) : null,
17 + CreatedByUserId = entity.CreatedByUserId,
18 + CreatedByUser = AppUserBllDtoFactory.Create(entity.CreatedByUser),
19 + TotalAmount = entity.TotalAmount,
20 + Status = entity.Status,
21 + CompletedAt = entity.CompletedAt,
22 + Payments = includePayments && entity.Payments != null
23 + ? entity.Payments.Select(SettlementPaymentBllDtoFactory.Create).ToList()
24 + : null
25 + };
26 +
27 + public static List<SettlementPlanBllDto> CreateList(IEnumerable<SettlementPlan> entities, bool includePayments = false)
28 + => entities.Select(p => Create(p, includePayments)).ToList();
29 +
30 + public static SettlementPlan ToEntity(SettlementPlanBllDto dto) => new()
31 + {
32 + Id = dto.Id,
33 + TripId = dto.TripId,
34 + CreatedByUserId = dto.CreatedByUserId,
35 + TotalAmount = dto.TotalAmount,
36 + Status = dto.Status,
37 + CompletedAt = dto.CompletedAt
38 + };
39 +}
40 +
41 +public static class SettlementPaymentBllDtoFactory
42 +{
43 + public static SettlementPaymentBllDto Create(SettlementPayment entity) => new()
44 + {
45 + Id = entity.Id,
46 + CreatedAt = entity.CreatedAt,
47 + UpdatedAt = entity.UpdatedAt,
48 + SettlementPlanId = entity.SettlementPlanId,
49 + FromUserId = entity.FromUserId,
50 + FromUser = AppUserBllDtoFactory.Create(entity.FromUser),
51 + ToUserId = entity.ToUserId,
52 + ToUser = AppUserBllDtoFactory.Create(entity.ToUser),
53 + Amount = entity.Amount,
54 + Status = entity.Status,
55 + MarkedPaidAt = entity.MarkedPaidAt,
56 + ConfirmedAt = entity.ConfirmedAt
57 + };
58 +
59 + public static List<SettlementPaymentBllDto> CreateList(IEnumerable<SettlementPayment> entities)
60 + => entities.Select(Create).ToList();
61 +
62 + public static SettlementPayment ToEntity(SettlementPaymentBllDto dto) => new()
63 + {
64 + Id = dto.Id,
65 + SettlementPlanId = dto.SettlementPlanId,
66 + FromUserId = dto.FromUserId,
67 + ToUserId = dto.ToUserId,
68 + Amount = dto.Amount,
69 + Status = dto.Status,
70 + MarkedPaidAt = dto.MarkedPaidAt,
71 + ConfirmedAt = dto.ConfirmedAt
72 + };
73 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SplitPresetBllDtoFactory.cs +61 −0
@@ -0,0 +1,61 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.WebApp.Application.DTO;
4 +
5 +namespace SplitApp.WebApp.Application.Mappers;
6 +
7 +public static class SplitPresetBllDtoFactory
8 +{
9 + public static SplitPresetBllDto Create(SplitPreset entity, bool includeMembers = false) => new()
10 + {
11 + Id = entity.Id,
12 + CreatedAt = entity.CreatedAt,
13 + UpdatedAt = entity.UpdatedAt,
14 + TripId = entity.TripId,
15 + // entity.Trip is `object?` (Expenses module avoids direct ref to Trip type) — cast back here.
16 + Trip = entity.Trip is Trip trip ? TripBllDtoFactory.Create(trip) : null,
17 + Name = entity.Name,
18 + SplitMethod = entity.SplitMethod,
19 + CreatedById = entity.CreatedById,
20 + CreatedBy = AppUserBllDtoFactory.Create(entity.CreatedBy),
21 + Members = includeMembers && entity.Members != null
22 + ? entity.Members.Select(SplitPresetMemberBllDtoFactory.Create).ToList()
23 + : null
24 + };
25 +
26 + public static List<SplitPresetBllDto> CreateList(IEnumerable<SplitPreset> entities, bool includeMembers = false)
27 + => entities.Select(p => Create(p, includeMembers)).ToList();
28 +
29 + public static SplitPreset ToEntity(SplitPresetBllDto dto) => new()
30 + {
31 + Id = dto.Id,
32 + TripId = dto.TripId,
33 + Name = dto.Name,
34 + SplitMethod = dto.SplitMethod,
35 + CreatedById = dto.CreatedById
36 + };
37 +}
38 +
39 +public static class SplitPresetMemberBllDtoFactory
40 +{
41 + public static SplitPresetMemberBllDto Create(SplitPresetMember entity) => new()
42 + {
43 + Id = entity.Id,
44 + CreatedAt = entity.CreatedAt,
45 + UpdatedAt = entity.UpdatedAt,
46 + SplitPresetId = entity.SplitPresetId,
47 + UserId = entity.UserId,
48 + UserFullName = entity.User != null ? $"{entity.User.FirstName} {entity.User.LastName}".Trim() : null,
49 + ShareWeight = entity.ShareWeight,
50 + Percentage = entity.Percentage
51 + };
52 +
53 + public static SplitPresetMember ToEntity(SplitPresetMemberBllDto dto) => new()
54 + {
55 + Id = dto.Id,
56 + SplitPresetId = dto.SplitPresetId,
57 + UserId = dto.UserId,
58 + ShareWeight = dto.ShareWeight,
59 + Percentage = dto.Percentage
60 + };
61 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/TripBllDtoFactory.cs +86 −0
@@ -0,0 +1,86 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class TripBllDtoFactory
7 +{
8 + public static TripBllDto Create(Trip entity, bool includeParticipants = false, bool includeExpenses = false)
9 + {
10 + var dto = new TripBllDto
11 + {
12 + Id = entity.Id,
13 + CreatedAt = entity.CreatedAt,
14 + UpdatedAt = entity.UpdatedAt,
15 + Name = entity.Name,
16 + Description = entity.Description,
17 + Destination = entity.Destination,
18 + StartDate = entity.StartDate,
19 + EndDate = entity.EndDate,
20 + Status = entity.Status,
21 + DefaultCurrencyId = entity.DefaultCurrencyId,
22 + DefaultCurrency = entity.DefaultCurrency != null ? CurrencyBllDtoFactory.Create(entity.DefaultCurrency) : null,
23 + CreatedById = entity.CreatedById,
24 + CreatedBy = AppUserBllDtoFactory.Create(entity.CreatedBy),
25 + };
26 +
27 + if (includeParticipants && entity.Participants != null)
28 + {
29 + dto.Participants = entity.Participants
30 + .Select(TripParticipantBllDtoFactory.Create)
31 + .ToList();
32 + }
33 +
34 + return dto;
35 + }
36 +
37 + public static List<TripBllDto> CreateList(IEnumerable<Trip> entities, bool includeParticipants = false)
38 + => entities.Select(t => Create(t, includeParticipants)).ToList();
39 +
40 + public static Trip ToEntity(TripBllDto dto) => new()
41 + {
42 + Id = dto.Id,
43 + Name = dto.Name,
44 + Description = dto.Description,
45 + Destination = dto.Destination,
46 + StartDate = dto.StartDate,
47 + EndDate = dto.EndDate,
48 + Status = dto.Status,
49 + DefaultCurrencyId = dto.DefaultCurrencyId,
50 + CreatedById = dto.CreatedById
51 + };
52 +}
53 +
54 +public static class TripParticipantBllDtoFactory
55 +{
56 + public static TripParticipantBllDto Create(TripParticipant entity) => new()
57 + {
58 + Id = entity.Id,
59 + CreatedAt = entity.CreatedAt,
60 + UpdatedAt = entity.UpdatedAt,
61 + TripId = entity.TripId,
62 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
63 + UserId = entity.UserId,
64 + User = AppUserBllDtoFactory.Create(entity.User),
65 + Role = entity.Role,
66 + Nickname = entity.Nickname,
67 + JoinedAt = entity.JoinedAt,
68 + LeftAt = entity.LeftAt,
69 + IsActive = entity.IsActive
70 + };
71 +
72 + public static List<TripParticipantBllDto> CreateList(IEnumerable<TripParticipant> entities)
73 + => entities.Select(Create).ToList();
74 +
75 + public static TripParticipant ToEntity(TripParticipantBllDto dto) => new()
76 + {
77 + Id = dto.Id,
78 + TripId = dto.TripId,
79 + UserId = dto.UserId,
80 + Role = dto.Role,
81 + Nickname = dto.Nickname,
82 + JoinedAt = dto.JoinedAt,
83 + LeftAt = dto.LeftAt,
84 + IsActive = dto.IsActive
85 + };
86 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/WishlistBllDtoFactory.cs +54 −0
@@ -0,0 +1,54 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +
8 +namespace SplitApp.WebApp.Application.Mappers;
9 +
10 +public static class WishlistBllDtoFactory
11 +{
12 + public static TripWishlistItemBllDto Create(TripWishlistItem entity) => new()
13 + {
14 + Id = entity.Id,
15 + CreatedAt = entity.CreatedAt,
16 + UpdatedAt = entity.UpdatedAt,
17 + TripId = entity.TripId,
18 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
19 + AddedByUserId = entity.AddedByUserId,
20 + AddedByUser = AppUserBllDtoFactory.Create(entity.AddedByUser),
21 + Title = entity.Title,
22 + Description = entity.Description,
23 + Category = entity.Category,
24 + Priority = entity.Priority,
25 + EstimatedCost = entity.EstimatedCost,
26 + Url = entity.Url,
27 + Location = entity.Location,
28 + IsCompleted = entity.IsCompleted,
29 + CompletedAt = entity.CompletedAt,
30 + DisplayOrder = entity.DisplayOrder,
31 + VoteCount = entity.Votes?.Count(v => v.IsInterested) ?? 0,
32 + VoterUserIds = entity.Votes?.Where(v => v.IsInterested).Select(v => v.UserId).ToList() ?? new List<Guid>()
33 + };
34 +
35 + public static List<TripWishlistItemBllDto> CreateList(IEnumerable<TripWishlistItem> entities)
36 + => entities.Select(Create).ToList();
37 +
38 + public static TripWishlistItem ToEntity(TripWishlistItemBllDto dto) => new()
39 + {
40 + Id = dto.Id,
41 + TripId = dto.TripId,
42 + AddedByUserId = dto.AddedByUserId,
43 + Title = dto.Title,
44 + Description = dto.Description,
45 + Category = dto.Category,
46 + Priority = dto.Priority,
47 + EstimatedCost = dto.EstimatedCost,
48 + Url = dto.Url,
49 + Location = dto.Location,
50 + IsCompleted = dto.IsCompleted,
51 + CompletedAt = dto.CompletedAt,
52 + DisplayOrder = dto.DisplayOrder
53 + };
54 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs +653 −0
@@ -0,0 +1,653 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +using SplitApp.Modules.Users.Infrastructure.Persistence;
8 +using SplitApp.Shared.Kernel.Domain;
9 +using SplitApp.Shared.Kernel.Persistence;
10 +using SplitApp.WebApp.Application.Contracts;
11 +
12 +namespace SplitApp.WebApp.Application.Persistence;
13 +
14 +/// <summary>
15 +/// Composition-root unit-of-work that aggregates the three module DbContexts. The
16 +/// repositories below each route to the appropriate module DbContext. Cross-module
17 +/// joins happen in C# (LINQ) rather than SQL — modules still own their schemas.
18 +/// Modules themselves never see this; only the WebApp host does.
19 +/// </summary>
20 +public class AppUnitOfWork : IAppUnitOfWork
21 +{
22 + private readonly TripsDbContext _tripsDb;
23 + private readonly ExpensesDbContext _expensesDb;
24 + private readonly UsersDbContext _usersDb;
25 +
26 + public AppUnitOfWork(TripsDbContext tripsDb, ExpensesDbContext expensesDb, UsersDbContext usersDb)
27 + {
28 + _tripsDb = tripsDb;
29 + _expensesDb = expensesDb;
30 + _usersDb = usersDb;
31 +
32 + // Repos that load entities with [NotMapped] cross-module navs (e.g. AppUser, Currency)
33 + // receive the foreign DbContexts so they can hydrate those navs in C# after the
34 + // primary query — EF can't follow them because the FK crosses Postgres schemas.
35 + Trips = new TripRepo(tripsDb, usersDb, expensesDb);
36 + Expenses = new ExpenseRepo(expensesDb, usersDb, tripsDb);
37 + TripParticipants = new TripParticipantRepo(tripsDb, usersDb);
38 + TripInvitations = new TripInvitationRepo(tripsDb, usersDb);
39 + SettlementPlans = new SettlementPlanRepo(expensesDb, usersDb, tripsDb);
40 + SettlementPayments = new SettlementPaymentRepo(expensesDb, usersDb);
41 + TripPolls = new TripPollRepo(tripsDb, usersDb);
42 + TripWishlistItems = new TripWishlistItemRepo(tripsDb, usersDb);
43 + SplitPresets = new SplitPresetRepo(expensesDb, usersDb, tripsDb);
44 + BudgetCategories = new BudgetCategoryRepo(tripsDb);
45 + RefreshTokens = new RefreshTokenRepo(usersDb);
46 + Users = new UserRepo(usersDb);
47 + }
48 +
49 + public ITripRepository Trips { get; }
50 + public IExpenseRepository Expenses { get; }
51 + public ITripParticipantRepository TripParticipants { get; }
52 + public ITripInvitationRepository TripInvitations { get; }
53 + public ISettlementPlanRepository SettlementPlans { get; }
54 + public ISettlementPaymentRepository SettlementPayments { get; }
55 + public ITripPollRepository TripPolls { get; }
56 + public ITripWishlistItemRepository TripWishlistItems { get; }
57 + public ISplitPresetRepository SplitPresets { get; }
58 + public IBudgetCategoryRepository BudgetCategories { get; }
59 + public IRefreshTokenRepository RefreshTokens { get; }
60 + public IUserRepository Users { get; }
61 +
62 + public async Task<int> SaveChangesAsync()
63 + {
64 + var trips = await _tripsDb.SaveChangesAsync();
65 + var expenses = await _expensesDb.SaveChangesAsync();
66 + var users = await _usersDb.SaveChangesAsync();
67 + return trips + expenses + users;
68 + }
69 +
70 + public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity
71 + {
72 + var t = typeof(TEntity);
73 + if (t == typeof(SettlementPayment)) return (IBaseRepository<TEntity>)SettlementPayments;
74 + if (t == typeof(SettlementPlan)) return (IBaseRepository<TEntity>)SettlementPlans;
75 + if (t == typeof(Trip)) return (IBaseRepository<TEntity>)Trips;
76 + if (t == typeof(TripParticipant)) return (IBaseRepository<TEntity>)TripParticipants;
77 + if (t == typeof(Expense)) return (IBaseRepository<TEntity>)Expenses;
78 + if (t == typeof(TripInvitation)) return (IBaseRepository<TEntity>)TripInvitations;
79 + if (t == typeof(BudgetCategory)) return (IBaseRepository<TEntity>)BudgetCategories;
80 + if (t == typeof(TripPoll)) return (IBaseRepository<TEntity>)TripPolls;
81 + if (t == typeof(TripWishlistItem)) return (IBaseRepository<TEntity>)TripWishlistItems;
82 + if (t == typeof(SplitPreset)) return (IBaseRepository<TEntity>)SplitPresets;
83 + if (t == typeof(AppRefreshToken)) return (IBaseRepository<TEntity>)RefreshTokens;
84 + if (t == typeof(AppUser)) return (IBaseRepository<TEntity>)Users;
85 + // Generic per-DbContext repos for entities BLL services touch via GetRepository<T>
86 + // but that aren't called out in the typed properties above.
87 + if (t == typeof(Currency)) return new GenericExpensesRepo<Currency>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
88 + if (t == typeof(ExpenseSplit)) return new GenericExpensesRepo<ExpenseSplit>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
89 + if (t == typeof(SplitPresetMember)) return new GenericExpensesRepo<SplitPresetMember>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
90 + if (t == typeof(TripPollOption)) return new GenericTripsRepo<TripPollOption>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
91 + if (t == typeof(TripPollVote)) return new GenericTripsRepo<TripPollVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
92 + if (t == typeof(TripWishlistVote)) return new GenericTripsRepo<TripWishlistVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
93 + throw new InvalidOperationException($"No repository registered for {t.Name}");
94 + }
95 +}
96 +
97 +internal class GenericTripsRepo<TEntity> : GenericRepo<TEntity, TripsDbContext>
98 + where TEntity : class, IBaseEntity
99 +{
100 + public GenericTripsRepo(TripsDbContext db) : base(db) { }
101 +}
102 +
103 +internal class GenericExpensesRepo<TEntity> : GenericRepo<TEntity, ExpensesDbContext>
104 + where TEntity : class, IBaseEntity
105 +{
106 + public GenericExpensesRepo(ExpensesDbContext db) : base(db) { }
107 +}
108 +
109 +internal class GenericRepo<TEntity, TDbContext> : IBaseRepository<TEntity>
110 + where TEntity : class, IBaseEntity
111 + where TDbContext : DbContext
112 +{
113 + protected readonly TDbContext Db;
114 + protected readonly DbSet<TEntity> Set;
115 +
116 + protected GenericRepo(TDbContext db)
117 + {
118 + Db = db;
119 + Set = db.Set<TEntity>();
120 + }
121 +
122 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await Set.ToListAsync();
123 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await Set.FirstOrDefaultAsync(e => e.Id == id);
124 + public virtual TEntity Add(TEntity entity) => Set.Add(entity).Entity;
125 + public virtual TEntity Update(TEntity entity) => Set.Update(entity).Entity;
126 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
127 + {
128 + var e = await GetByIdAsync(id);
129 + if (e == null) return null;
130 + return Set.Remove(e).Entity;
131 + }
132 + public virtual async Task<bool> ExistsAsync(Guid id) => await Set.AnyAsync(e => e.Id == id);
133 +}
134 +
135 +internal static class CrossModuleHydration
136 +{
137 + /// <summary>Batches a single AspNetUsers lookup, populates each item's User nav.</summary>
138 + public static async Task HydrateUsersAsync<T>(
139 + UsersDbContext usersDb,
140 + IEnumerable<T> items,
141 + Func<T, Guid> getUserId,
142 + Action<T, AppUser?> setUser) where T : class
143 + {
144 + var list = items as IList<T> ?? items.ToList();
145 + var ids = list.Select(getUserId).Where(id => id != Guid.Empty).Distinct().ToList();
146 + if (ids.Count == 0) return;
147 + var users = await usersDb.Users
148 + .Where(u => ids.Contains(u.Id))
149 + .ToDictionaryAsync(u => u.Id);
150 + foreach (var item in list)
151 + {
152 + users.TryGetValue(getUserId(item), out var u);
153 + setUser(item, u);
154 + }
155 + }
156 +
157 + public static async Task HydrateUsersAsync<T>(
158 + UsersDbContext usersDb,
159 + IEnumerable<T> items,
160 + Func<T, Guid?> getUserId,
161 + Action<T, AppUser?> setUser) where T : class
162 + {
163 + var list = items as IList<T> ?? items.ToList();
164 + var ids = list.Select(getUserId).Where(id => id is { } x && x != Guid.Empty).Select(id => id!.Value).Distinct().ToList();
165 + if (ids.Count == 0) return;
166 + var users = await usersDb.Users
167 + .Where(u => ids.Contains(u.Id))
168 + .ToDictionaryAsync(u => u.Id);
169 + foreach (var item in list)
170 + {
171 + var id = getUserId(item);
172 + if (id.HasValue && users.TryGetValue(id.Value, out var u)) setUser(item, u);
173 + else setUser(item, null);
174 + }
175 + }
176 +
177 + /// <summary>Batches a single Trips lookup, sets each item's Trip nav (boxed as object since
178 + /// Expenses-module entities can't reference the Trips-module Trip type directly).</summary>
179 + public static async Task HydrateTripsAsync<T>(
180 + TripsDbContext tripsDb,
181 + IEnumerable<T> items,
182 + Func<T, Guid> getTripId,
183 + Action<T, Trip?> setTrip) where T : class
184 + {
185 + var list = items as IList<T> ?? items.ToList();
186 + var ids = list.Select(getTripId).Where(id => id != Guid.Empty).Distinct().ToList();
187 + if (ids.Count == 0) return;
188 + var trips = await tripsDb.Trips
189 + .Where(t => ids.Contains(t.Id))
190 + .ToDictionaryAsync(t => t.Id);
191 + foreach (var item in list)
192 + {
193 + trips.TryGetValue(getTripId(item), out var t);
194 + setTrip(item, t);
195 + }
196 + }
197 +}
198 +
199 +internal class TripRepo : GenericRepo<Trip, TripsDbContext>, ITripRepository
200 +{
201 + private readonly UsersDbContext _users;
202 + private readonly ExpensesDbContext _expenses;
203 +
204 + public TripRepo(TripsDbContext db, UsersDbContext users, ExpensesDbContext expenses) : base(db)
205 + {
206 + _users = users;
207 + _expenses = expenses;
208 + }
209 +
210 + private async Task HydrateTripAsync(Trip trip)
211 + {
212 + // Trip-level navs
213 + var creator = await _users.Users.FirstOrDefaultAsync(u => u.Id == trip.CreatedById);
214 + trip.CreatedBy = creator;
215 + trip.DefaultCurrency = await _expenses.Currencies.FirstOrDefaultAsync(c => c.Id == trip.DefaultCurrencyId);
216 +
217 + if (trip.Participants is { Count: > 0 } parts)
218 + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
219 + if (trip.Invitations is { Count: > 0 } invs)
220 + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
221 + if (trip.Polls is { Count: > 0 } polls)
222 + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
223 + if (trip.WishlistItems is { Count: > 0 } wls)
224 + await CrossModuleHydration.HydrateUsersAsync(_users, wls, w => w.AddedByUserId, (w, u) => w.AddedByUser = u);
225 + }
226 +
227 + public override async Task<Trip?> GetByIdAsync(Guid id)
228 + {
229 + var trip = await base.GetByIdAsync(id);
230 + if (trip != null) await HydrateTripAsync(trip);
231 + return trip;
232 + }
233 +
234 + public override async Task<IEnumerable<Trip>> GetAllAsync()
235 + {
236 + var trips = (await Db.Trips.ToListAsync());
237 + await CrossModuleHydration.HydrateUsersAsync(_users, trips, t => t.CreatedById, (t, u) => t.CreatedBy = u);
238 + var currencyIds = trips.Select(t => t.DefaultCurrencyId).Where(id => id != Guid.Empty).Distinct().ToList();
239 + if (currencyIds.Count > 0)
240 + {
241 + var currencies = await _expenses.Currencies
242 + .Where(c => currencyIds.Contains(c.Id))
243 + .ToDictionaryAsync(c => c.Id);
244 + foreach (var t in trips)
245 + {
246 + if (currencies.TryGetValue(t.DefaultCurrencyId, out var c)) t.DefaultCurrency = c;
247 + }
248 + }
249 + return trips;
250 + }
251 +
252 + public async Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId)
253 + {
254 + var trips = await Db.Trips
255 + .Include(t => t.Participants)
256 + .Where(t => t.CreatedById == userId
257 + || (t.Participants != null
258 + && t.Participants.Any(p => p.UserId == userId && p.IsActive)))
259 + .ToListAsync();
260 + foreach (var t in trips) await HydrateTripAsync(t);
261 + return trips;
262 + }
263 +
264 + public async Task<Trip?> GetByIdWithDetailsAsync(Guid id)
265 + {
266 + var trip = await Db.Trips
267 + .Include(t => t.Participants)
268 + .Include(t => t.BudgetCategories)
269 + .Include(t => t.WishlistItems)
270 + .Include(t => t.Polls)!.ThenInclude(p => p.Options)
271 + .Include(t => t.Invitations)
272 + .FirstOrDefaultAsync(t => t.Id == id);
273 + if (trip != null) await HydrateTripAsync(trip);
274 + return trip;
275 + }
276 +}
277 +
278 +internal class ExpenseRepo : GenericRepo<Expense, ExpensesDbContext>, IExpenseRepository
279 +{
280 + private readonly UsersDbContext _users;
281 + private readonly TripsDbContext _trips;
282 + public ExpenseRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db)
283 + {
284 + _users = users;
285 + _trips = trips;
286 + }
287 +
288 + public override async Task<IEnumerable<Expense>> GetAllAsync()
289 + {
290 + var expenses = await Db.Expenses.Include(e => e.Currency).ToListAsync();
291 + await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
292 + await CrossModuleHydration.HydrateTripsAsync(_trips, expenses, e => e.TripId, (e, t) => e.Trip = t);
293 + return expenses;
294 + }
295 +
296 + public override async Task<Expense?> GetByIdAsync(Guid id)
297 + {
298 + var e = await Db.Expenses.Include(x => x.Currency).FirstOrDefaultAsync(x => x.Id == id);
299 + if (e != null)
300 + {
301 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { e }, x => x.PaidByUserId, (x, u) => x.PaidByUser = u);
302 + await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { e }, x => x.TripId, (x, t) => x.Trip = t);
303 + }
304 + return e;
305 + }
306 +
307 + public async Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId)
308 + {
309 + var expenses = await Db.Expenses
310 + .Include(e => e.Currency)
311 + .Where(e => e.TripId == tripId)
312 + .ToListAsync();
313 + await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
314 + return expenses;
315 + }
316 +
317 + public async Task<Expense?> GetByIdWithDetailsAsync(Guid id)
318 + {
319 + var expense = await Db.Expenses
320 + .Include(e => e.Currency)
321 + .Include(e => e.Splits)
322 + .FirstOrDefaultAsync(e => e.Id == id);
323 + if (expense == null) return null;
324 +
325 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { expense }, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
326 + if (expense.Splits is { Count: > 0 } splits)
327 + await CrossModuleHydration.HydrateUsersAsync(_users, splits, s => s.UserId, (s, u) => s.User = u);
328 + return expense;
329 + }
330 +}
331 +
332 +internal class TripParticipantRepo : GenericRepo<TripParticipant, TripsDbContext>, ITripParticipantRepository
333 +{
334 + private readonly UsersDbContext _users;
335 + public TripParticipantRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
336 +
337 + public async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
338 + => await Db.TripParticipants.AnyAsync(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
339 +
340 + public async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
341 + {
342 + var trip = await Db.Trips.FirstOrDefaultAsync(t => t.Id == tripId);
343 + if (trip == null) return false;
344 + if (trip.CreatedById == userId) return true;
345 + return await Db.TripParticipants.AnyAsync(p =>
346 + p.TripId == tripId && p.UserId == userId && p.IsActive
347 + && p.Role == SplitApp.Modules.Trips.Domain.Enums.EParticipantRole.Organizer);
348 + }
349 +
350 + public async Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId)
351 + {
352 + var parts = await Db.TripParticipants.Where(p => p.TripId == tripId).ToListAsync();
353 + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
354 + return parts;
355 + }
356 +
357 + public override async Task<TripParticipant?> GetByIdAsync(Guid id)
358 + {
359 + var p = await Db.TripParticipants.Include(x => x.Trip).FirstOrDefaultAsync(x => x.Id == id);
360 + if (p != null)
361 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { p }, x => x.UserId, (x, u) => x.User = u);
362 + return p;
363 + }
364 +
365 + public override async Task<IEnumerable<TripParticipant>> GetAllAsync()
366 + {
367 + var parts = await Db.TripParticipants.Include(p => p.Trip).ToListAsync();
368 + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
369 + return parts;
370 + }
371 +}
372 +
373 +internal class TripInvitationRepo : GenericRepo<TripInvitation, TripsDbContext>, ITripInvitationRepository
374 +{
375 + private readonly UsersDbContext _users;
376 + public TripInvitationRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
377 +
378 + public override async Task<IEnumerable<TripInvitation>> GetAllAsync()
379 + {
380 + var invs = await Db.TripInvitations.Include(i => i.Trip).ToListAsync();
381 + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
382 + return invs;
383 + }
384 +
385 + public async Task<TripInvitation?> GetByTokenAsync(string token)
386 + {
387 + var inv = await Db.TripInvitations.Include(i => i.Trip).FirstOrDefaultAsync(i => i.Token == token);
388 + if (inv != null)
389 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { inv }, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
390 + return inv;
391 + }
392 +
393 + public async Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId)
394 + {
395 + var invs = await Db.TripInvitations
396 + .Where(i => i.TripId == tripId
397 + && i.Status == SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus.Pending)
398 + .ToListAsync();
399 + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
400 + return invs;
401 + }
402 +}
403 +
404 +internal class SettlementPlanRepo : GenericRepo<SettlementPlan, ExpensesDbContext>, ISettlementPlanRepository
405 +{
406 + private readonly UsersDbContext _users;
407 + private readonly TripsDbContext _trips;
408 + public SettlementPlanRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db)
409 + {
410 + _users = users;
411 + _trips = trips;
412 + }
413 +
414 + public override async Task<IEnumerable<SettlementPlan>> GetAllAsync()
415 + {
416 + var plans = await Db.SettlementPlans.ToListAsync();
417 + await CrossModuleHydration.HydrateUsersAsync(_users, plans, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
418 + await CrossModuleHydration.HydrateTripsAsync(_trips, plans, p => p.TripId, (p, t) => p.Trip = t);
419 + return plans;
420 + }
421 +
422 + private async Task HydratePlanAsync(SettlementPlan plan)
423 + {
424 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { plan }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
425 + await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { plan }, p => p.TripId, (p, t) => p.Trip = t);
426 + if (plan.Payments is { Count: > 0 } payments)
427 + {
428 + // Batch From + To together — same AspNetUsers query.
429 + var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId })
430 + .Where(id => id != Guid.Empty).Distinct().ToList();
431 + if (ids.Count > 0)
432 + {
433 + var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
434 + foreach (var p in payments)
435 + {
436 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
437 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
438 + }
439 + }
440 + }
441 + }
442 +
443 + public override async Task<SettlementPlan?> GetByIdAsync(Guid id)
444 + {
445 + var plan = await Db.SettlementPlans.Include(p => p.Payments).FirstOrDefaultAsync(p => p.Id == id);
446 + if (plan != null) await HydratePlanAsync(plan);
447 + return plan;
448 + }
449 +
450 + public async Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId)
451 + {
452 + var plan = await Db.SettlementPlans
453 + .Include(p => p.Payments)
454 + .Where(p => p.TripId == tripId)
455 + .OrderByDescending(p => p.CreatedAt)
456 + .FirstOrDefaultAsync();
457 + if (plan != null) await HydratePlanAsync(plan);
458 + return plan;
459 + }
460 +
461 + public async Task DeletePlanWithPaymentsAsync(Guid planId)
462 + {
463 + var payments = await Db.SettlementPayments.Where(p => p.SettlementPlanId == planId).ToListAsync();
464 + Db.SettlementPayments.RemoveRange(payments);
465 + var plan = await Db.SettlementPlans.FirstOrDefaultAsync(p => p.Id == planId);
466 + if (plan != null) Db.SettlementPlans.Remove(plan);
467 + await Db.SaveChangesAsync();
468 + }
469 +}
470 +
471 +internal class SettlementPaymentRepo : GenericRepo<SettlementPayment, ExpensesDbContext>, ISettlementPaymentRepository
472 +{
473 + private readonly UsersDbContext _users;
474 + public SettlementPaymentRepo(ExpensesDbContext db, UsersDbContext users) : base(db) { _users = users; }
475 +
476 + public override async Task<IEnumerable<SettlementPayment>> GetAllAsync()
477 + {
478 + var payments = await Db.SettlementPayments.ToListAsync();
479 + var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId })
480 + .Where(id => id != Guid.Empty).Distinct().ToList();
481 + if (ids.Count > 0)
482 + {
483 + var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
484 + foreach (var p in payments)
485 + {
486 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
487 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
488 + }
489 + }
490 + return payments;
491 + }
492 +
493 + public override async Task<SettlementPayment?> GetByIdAsync(Guid id)
494 + {
495 + var p = await base.GetByIdAsync(id);
496 + if (p != null)
497 + {
498 + var ids = new[] { p.FromUserId, p.ToUserId }.Where(x => x != Guid.Empty).Distinct().ToList();
499 + var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
500 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
501 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
502 + }
503 + return p;
504 + }
505 +}
506 +
507 +internal class TripPollRepo : GenericRepo<TripPoll, TripsDbContext>, ITripPollRepository
508 +{
509 + private readonly UsersDbContext _users;
510 + public TripPollRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
511 +
512 + public override async Task<IEnumerable<TripPoll>> GetAllAsync()
513 + {
514 + var polls = await Db.TripPolls.Include(p => p.Trip).ToListAsync();
515 + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
516 + return polls;
517 + }
518 +
519 + public async Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId)
520 + {
521 + var polls = await Db.TripPolls
522 + .Include(p => p.Options)!
523 + .ThenInclude(o => o.Votes)
524 + .Where(p => p.TripId == tripId)
525 + .ToListAsync();
526 + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
527 + return polls;
528 + }
529 +
530 + public async Task<TripPoll?> GetByIdWithDetailsAsync(Guid id)
531 + {
532 + var poll = await Db.TripPolls
533 + .Include(p => p.Options)!
534 + .ThenInclude(o => o.Votes)
535 + .FirstOrDefaultAsync(p => p.Id == id);
536 + if (poll != null)
537 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { poll }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
538 + return poll;
539 + }
540 +}
541 +
542 +internal class TripWishlistItemRepo : GenericRepo<TripWishlistItem, TripsDbContext>, ITripWishlistItemRepository
543 +{
544 + private readonly UsersDbContext _users;
545 + public TripWishlistItemRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
546 +
547 + public override async Task<IEnumerable<TripWishlistItem>> GetAllAsync()
548 + {
549 + var items = await Db.TripWishlistItems.Include(i => i.Trip).ToListAsync();
550 + await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u);
551 + return items;
552 + }
553 +
554 + public async Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId)
555 + {
556 + var items = await Db.TripWishlistItems
557 + .Include(i => i.Votes)
558 + .Where(i => i.TripId == tripId)
559 + .ToListAsync();
560 + await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u);
561 + return items;
562 + }
563 +}
564 +
565 +internal class SplitPresetRepo : GenericRepo<SplitPreset, ExpensesDbContext>, ISplitPresetRepository
566 +{
567 + private readonly UsersDbContext _users;
568 + private readonly TripsDbContext _trips;
569 + public SplitPresetRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db)
570 + {
571 + _users = users;
572 + _trips = trips;
573 + }
574 +
575 + public override async Task<IEnumerable<SplitPreset>> GetAllAsync()
576 + {
577 + var presets = await Db.SplitPresets.ToListAsync();
578 + await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u);
579 + await CrossModuleHydration.HydrateTripsAsync(_trips, presets, p => p.TripId, (p, t) => p.Trip = t);
580 + return presets;
581 + }
582 +
583 + public async Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId)
584 + {
585 + var presets = await Db.SplitPresets
586 + .Include(p => p.Members)
587 + .Where(p => p.TripId == tripId)
588 + .ToListAsync();
589 + await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u);
590 + var members = presets.SelectMany(p => p.Members ?? Enumerable.Empty<SplitPresetMember>()).ToList();
591 + if (members.Count > 0)
592 + await CrossModuleHydration.HydrateUsersAsync(_users, members, m => m.UserId, (m, u) => m.User = u);
593 + return presets;
594 + }
595 +}
596 +
597 +internal class BudgetCategoryRepo : GenericRepo<BudgetCategory, TripsDbContext>, IBudgetCategoryRepository
598 +{
599 + public BudgetCategoryRepo(TripsDbContext db) : base(db) { }
600 +
601 + public async Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId)
602 + => await Db.BudgetCategories.Where(c => c.TripId == tripId).ToListAsync();
603 +}
604 +
605 +internal class RefreshTokenRepo : GenericRepo<AppRefreshToken, UsersDbContext>, IRefreshTokenRepository
606 +{
607 + public RefreshTokenRepo(UsersDbContext db) : base(db) { }
608 +
609 + public async Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue)
610 + {
611 + var now = DateTime.UtcNow;
612 + return await Db.RefreshTokens
613 + .Where(t => t.AppUserId == userId
614 + && (t.RefreshToken == refreshTokenValue && t.ExpirationDT > now
615 + || t.PreviousRefreshToken == refreshTokenValue && t.PreviousExpirationDT > now))
616 + .ToListAsync();
617 + }
618 +
619 + public async Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue)
620 + {
621 + return await Db.RefreshTokens
622 + .Where(t => t.AppUserId == userId
623 + && (t.RefreshToken == refreshTokenValue || t.PreviousRefreshToken == refreshTokenValue))
624 + .ToListAsync();
625 + }
626 +
627 + public async Task<int> RemoveExpiredForUserAsync(Guid userId)
628 + {
629 + var now = DateTime.UtcNow;
630 + var expired = await Db.RefreshTokens
631 + .Where(t => t.AppUserId == userId
632 + && t.ExpirationDT < now
633 + && t.PreviousExpirationDT < now)
634 + .ToListAsync();
635 + Db.RefreshTokens.RemoveRange(expired);
636 + return expired.Count;
637 + }
638 +
639 + public void Remove(AppRefreshToken token) => Db.RefreshTokens.Remove(token);
640 +}
641 +
642 +internal class UserRepo : GenericRepo<AppUser, UsersDbContext>, IUserRepository
643 +{
644 + public UserRepo(UsersDbContext db) : base(db) { }
645 +
646 + public async Task<int> CountAsync() => await Db.Users.CountAsync();
647 +
648 + public async Task<IEnumerable<AppUser>> GetRecentAsync(int take)
649 + => await Db.Users.OrderByDescending(u => u.Id).Take(take).ToListAsync();
650 +
651 + public async Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId)
652 + => await Db.Users.Include(u => u.RefreshTokens).FirstOrDefaultAsync(u => u.Id == userId);
653 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs +179 −0
@@ -0,0 +1,179 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +using SplitApp.Modules.Users.Infrastructure.Persistence;
8 +
9 +namespace SplitApp.WebApp.Application.Persistence;
10 +
11 +/// <summary>
12 +/// Composition-root helper that hydrates the [NotMapped] cross-module nav properties
13 +/// after entities have been loaded by their owning module's DbContext. EF can't follow
14 +/// these because the FK crosses schemas — the WebApp does it manually.
15 +/// </summary>
16 +public class CrossModuleNavigationLoader
17 +{
18 + private readonly UsersDbContext _users;
19 + private readonly TripsDbContext _trips;
20 + private readonly ExpensesDbContext _expenses;
21 +
22 + public CrossModuleNavigationLoader(UsersDbContext users, TripsDbContext trips, ExpensesDbContext expenses)
23 + {
24 + _users = users;
25 + _trips = trips;
26 + _expenses = expenses;
27 + }
28 +
29 + private async Task<Dictionary<Guid, AppUser>> UsersByIdAsync(IEnumerable<Guid> ids)
30 + {
31 + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList();
32 + if (idList.Count == 0) return new Dictionary<Guid, AppUser>();
33 + return await _users.Users.Where(u => idList.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
34 + }
35 +
36 + private async Task<Dictionary<Guid, Currency>> CurrenciesByIdAsync(IEnumerable<Guid> ids)
37 + {
38 + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList();
39 + if (idList.Count == 0) return new Dictionary<Guid, Currency>();
40 + return await _expenses.Currencies.Where(c => idList.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
41 + }
42 +
43 + private async Task<Dictionary<Guid, Trip>> TripsByIdAsync(IEnumerable<Guid> ids)
44 + {
45 + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList();
46 + if (idList.Count == 0) return new Dictionary<Guid, Trip>();
47 + return await _trips.Trips.Where(t => idList.Contains(t.Id)).ToDictionaryAsync(t => t.Id);
48 + }
49 +
50 + public async Task PopulateAsync(Trip? trip)
51 + {
52 + if (trip == null) return;
53 + var users = await UsersByIdAsync(new[] { trip.CreatedById });
54 + var currencies = await CurrenciesByIdAsync(new[] { trip.DefaultCurrencyId });
55 + if (users.TryGetValue(trip.CreatedById, out var creator)) trip.CreatedBy = creator;
56 + if (currencies.TryGetValue(trip.DefaultCurrencyId, out var currency)) trip.DefaultCurrency = currency;
57 + if (trip.Participants != null)
58 + {
59 + var pUsers = await UsersByIdAsync(trip.Participants.Select(p => p.UserId));
60 + foreach (var p in trip.Participants)
61 + if (pUsers.TryGetValue(p.UserId, out var u)) p.User = u;
62 + }
63 + if (trip.Invitations != null)
64 + {
65 + var iUsers = await UsersByIdAsync(trip.Invitations.Select(i => i.InvitedByUserId));
66 + foreach (var i in trip.Invitations)
67 + if (iUsers.TryGetValue(i.InvitedByUserId, out var u)) i.InvitedByUser = u;
68 + }
69 + }
70 +
71 + public async Task PopulateAsync(IEnumerable<Trip> trips)
72 + {
73 + foreach (var t in trips) await PopulateAsync(t);
74 + }
75 +
76 + public async Task PopulateAsync(TripParticipant? p)
77 + {
78 + if (p == null) return;
79 + var users = await UsersByIdAsync(new[] { p.UserId });
80 + if (users.TryGetValue(p.UserId, out var u)) p.User = u;
81 + }
82 +
83 + public async Task PopulateAsync(IEnumerable<TripParticipant> ps)
84 + {
85 + var ids = ps.Select(p => p.UserId).ToList();
86 + var users = await UsersByIdAsync(ids);
87 + foreach (var p in ps) if (users.TryGetValue(p.UserId, out var u)) p.User = u;
88 + }
89 +
90 + public async Task PopulateAsync(Expense? e)
91 + {
92 + if (e == null) return;
93 + var users = await UsersByIdAsync(new[] { e.PaidByUserId });
94 + if (users.TryGetValue(e.PaidByUserId, out var u)) e.PaidByUser = u;
95 + if (e.Splits != null)
96 + {
97 + var splitUsers = await UsersByIdAsync(e.Splits.Select(s => s.UserId));
98 + foreach (var s in e.Splits)
99 + if (splitUsers.TryGetValue(s.UserId, out var su)) s.User = su;
100 + }
101 + }
102 +
103 + public async Task PopulateAsync(IEnumerable<Expense> es)
104 + {
105 + foreach (var e in es) await PopulateAsync(e);
106 + }
107 +
108 + public async Task PopulateAsync(SettlementPlan? plan)
109 + {
110 + if (plan == null) return;
111 + var users = await UsersByIdAsync(
112 + new[] { plan.CreatedByUserId }
113 + .Concat(plan.Payments?.Select(p => p.FromUserId) ?? Enumerable.Empty<Guid>())
114 + .Concat(plan.Payments?.Select(p => p.ToUserId) ?? Enumerable.Empty<Guid>()));
115 + if (users.TryGetValue(plan.CreatedByUserId, out var creator)) plan.CreatedByUser = creator;
116 + if (plan.Payments != null)
117 + foreach (var p in plan.Payments)
118 + {
119 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
120 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
121 + }
122 + }
123 +
124 + public async Task PopulateAsync(SettlementPayment? p)
125 + {
126 + if (p == null) return;
127 + var users = await UsersByIdAsync(new[] { p.FromUserId, p.ToUserId });
128 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
129 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
130 + }
131 +
132 + public async Task PopulateAsync(SplitPreset? sp)
133 + {
134 + if (sp == null) return;
135 + var users = await UsersByIdAsync(
136 + new[] { sp.CreatedById }
137 + .Concat(sp.Members?.Select(m => m.UserId) ?? Enumerable.Empty<Guid>()));
138 + if (users.TryGetValue(sp.CreatedById, out var creator)) sp.CreatedBy = creator;
139 + if (sp.Members != null)
140 + foreach (var m in sp.Members)
141 + if (users.TryGetValue(m.UserId, out var u)) m.User = u;
142 + }
143 +
144 + public async Task PopulateAsync(IEnumerable<SplitPreset> sps)
145 + {
146 + foreach (var sp in sps) await PopulateAsync(sp);
147 + }
148 +
149 + public async Task PopulateAsync(TripPoll? poll)
150 + {
151 + if (poll == null) return;
152 + var users = await UsersByIdAsync(new[] { poll.CreatedByUserId });
153 + if (users.TryGetValue(poll.CreatedByUserId, out var u)) poll.CreatedByUser = u;
154 + }
155 +
156 + public async Task PopulateAsync(IEnumerable<TripPoll> polls)
157 + {
158 + foreach (var p in polls) await PopulateAsync(p);
159 + }
160 +
161 + public async Task PopulateAsync(TripWishlistItem? item)
162 + {
163 + if (item == null) return;
164 + var users = await UsersByIdAsync(new[] { item.AddedByUserId });
165 + if (users.TryGetValue(item.AddedByUserId, out var u)) item.AddedByUser = u;
166 + }
167 +
168 + public async Task PopulateAsync(IEnumerable<TripWishlistItem> items)
169 + {
170 + foreach (var i in items) await PopulateAsync(i);
171 + }
172 +
173 + public async Task PopulateAsync(TripInvitation? inv)
174 + {
175 + if (inv == null) return;
176 + var users = await UsersByIdAsync(new[] { inv.InvitedByUserId });
177 + if (users.TryGetValue(inv.InvitedByUserId, out var u)) inv.InvitedByUser = u;
178 + }
179 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminDashboardData.cs +66 −0
@@ -0,0 +1,66 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public class AdminDashboardData
6 +{
7 + public int TripCount { get; set; }
8 + public int UserCount { get; set; }
9 + public int ExpenseCount { get; set; }
10 + public int CategoryCount { get; set; }
11 + public int SettlementCount { get; set; }
12 + public int WishlistCount { get; set; }
13 + public int PollCount { get; set; }
14 + public int InvitationCount { get; set; }
15 + public int CurrencyCount { get; set; }
16 + public int ParticipantCount { get; set; }
17 +
18 + public int ActiveTrips { get; set; }
19 + public int SettledTrips { get; set; }
20 + public int ArchivedTrips { get; set; }
21 + public decimal TotalExpenseAmount { get; set; }
22 + public int PendingSettlements { get; set; }
23 + public int InProgressSettlements { get; set; }
24 + public int CompletedSettlements { get; set; }
25 + public int PendingInvitations { get; set; }
26 + public int PendingPayments { get; set; }
27 + public int MarkedPaidPayments { get; set; }
28 +
29 + public List<TripBllDto> RecentTrips { get; set; } = new();
30 + public List<ExpenseBllDto> RecentExpenses { get; set; } = new();
31 + public List<AppUserBllDto> RecentUsers { get; set; } = new();
32 +
33 + public List<AdminTopActiveTripItem> TopActiveTrips { get; set; } = new();
34 + public List<ExpenseBllDto> BiggestExpenses { get; set; } = new();
35 + public int NewUsersLast7Days { get; set; }
36 + public int NewUsersLast30Days { get; set; }
37 + public List<AdminTopActiveUserItem> TopActiveUsers { get; set; } = new();
38 + public List<AdminActivityFeedItem> ActivityFeed { get; set; } = new();
39 +}
40 +
41 +public class AdminTopActiveTripItem
42 +{
43 + public TripBllDto Trip { get; set; } = default!;
44 + public int ParticipantCount { get; set; }
45 + public decimal ExpenseSum { get; set; }
46 + public int ExpenseCount { get; set; }
47 +}
48 +
49 +public class AdminTopActiveUserItem
50 +{
51 + public Guid UserId { get; set; }
52 + public string Email { get; set; } = "";
53 + public string FullName { get; set; } = "";
54 + public int ExpenseCount { get; set; }
55 + public decimal TotalAmount { get; set; }
56 +}
57 +
58 +public class AdminActivityFeedItem
59 +{
60 + public string Type { get; set; } = "";
61 + public string MessageKey { get; set; } = "";
62 + public object[] MessageArgs { get; set; } = Array.Empty<object>();
63 + public DateTime Date { get; set; }
64 + public string IconCssClass { get; set; } = "bi-circle";
65 + public string BadgeCssClass { get; set; } = "bg-secondary";
66 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminStatsService.cs +203 −0
@@ -0,0 +1,203 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services.Admin;
11 +
12 +public class AdminStatsService : IAdminStatsService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public AdminStatsService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<AdminDashboardData> GetDashboardStatsAsync()
22 + {
23 + var trips = (await _uow.Trips.GetAllAsync()).ToList();
24 + var expenses = (await _uow.Expenses.GetAllAsync()).ToList();
25 + var budgetCategories = (await _uow.BudgetCategories.GetAllAsync()).ToList();
26 + var settlementPlans = (await _uow.SettlementPlans.GetAllAsync()).ToList();
27 + var wishlistItems = (await _uow.TripWishlistItems.GetAllAsync()).ToList();
28 + var polls = (await _uow.TripPolls.GetAllAsync()).ToList();
29 + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList();
30 + var currencies = (await _uow.GetRepository<Currency>().GetAllAsync()).ToList();
31 + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList();
32 + var settlementPayments = (await _uow.SettlementPayments.GetAllAsync()).ToList();
33 + var allUsers = (await _uow.Users.GetAllAsync()).ToList();
34 +
35 + // === Counts ===
36 + var tripCount = trips.Count;
37 + var userCount = allUsers.Count;
38 + var expenseCount = expenses.Count;
39 +
40 + // === Trip status breakdown ===
41 + var activeTrips = trips.Count(t => t.Status == ETripStatus.Active);
42 + var settledTrips = trips.Count(t => t.Status == ETripStatus.Settled);
43 + var archivedTrips = trips.Count(t => t.Status == ETripStatus.Archived);
44 +
45 + var totalExpenseAmount = expenses.Sum(e => e.Amount);
46 +
47 + var pendingSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.Pending);
48 + var inProgressSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.InProgress);
49 + var completedSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.Completed);
50 +
51 + var recentTrips = TripBllDtoFactory.CreateList(trips.OrderByDescending(t => t.CreatedAt).Take(5));
52 + var recentExpenses = ExpenseBllDtoFactory.CreateList(expenses.OrderByDescending(e => e.ExpenseDate).Take(8));
53 + var recentUsers = (await _uow.Users.GetRecentAsync(5)).Select(u => new AppUserBllDto
54 + {
55 + Id = u.Id,
56 + FirstName = u.FirstName,
57 + LastName = u.LastName,
58 + Email = u.Email
59 + }).ToList();
60 +
61 + var pendingInvitations = invitations.Count(i => i.Status == EInvitationStatus.Pending);
62 + var pendingPayments = settlementPayments.Count(p => p.Status == EPaymentStatus.Pending);
63 + var markedPaidPayments = settlementPayments.Count(p => p.Status == EPaymentStatus.MarkedPaid);
64 +
65 + // === Top Active Trips ===
66 + var topActiveTrips = trips
67 + .Where(t => t.Status == ETripStatus.Active)
68 + .Select(t =>
69 + {
70 + var tripExpenses = expenses.Where(e => e.TripId == t.Id).ToList();
71 + return new AdminTopActiveTripItem
72 + {
73 + Trip = TripBllDtoFactory.Create(t),
74 + ParticipantCount = participants.Count(p => p.TripId == t.Id),
75 + ExpenseSum = tripExpenses.Sum(e => e.Amount),
76 + ExpenseCount = tripExpenses.Count
77 + };
78 + })
79 + .OrderByDescending(x => x.ExpenseSum)
80 + .ThenByDescending(x => x.ParticipantCount)
81 + .Take(5)
82 + .ToList();
83 +
84 + // === Top 10 biggest expenses ===
85 + var biggestExpensesEntities = expenses
86 + .OrderByDescending(e => e.Amount)
87 + .Take(10)
88 + .ToList();
89 + foreach (var e in biggestExpensesEntities)
90 + {
91 + e.Trip ??= trips.FirstOrDefault(t => t.Id == e.TripId);
92 + e.PaidByUser ??= allUsers.FirstOrDefault(u => u.Id == e.PaidByUserId);
93 + }
94 + var biggestExpenses = ExpenseBllDtoFactory.CreateList(biggestExpensesEntities);
95 +
96 + // === Active users in last N days ===
97 + var now = DateTime.UtcNow;
98 + var cutoff7 = now.AddDays(-7);
99 + var cutoff30 = now.AddDays(-30);
100 +
101 + var activeUserIds7 = expenses.Where(e => e.CreatedAt >= cutoff7)
102 + .Select(e => e.PaidByUserId).Distinct().Count();
103 + var activeUserIds30 = expenses.Where(e => e.CreatedAt >= cutoff30)
104 + .Select(e => e.PaidByUserId).Distinct().Count();
105 +
106 + // === Top 5 most active users by expense count ===
107 + var topActiveUsers = expenses
108 + .GroupBy(e => e.PaidByUserId)
109 + .Select(g =>
110 + {
111 + var user = allUsers.FirstOrDefault(u => u.Id == g.Key);
112 + return new AdminTopActiveUserItem
113 + {
114 + UserId = g.Key,
115 + Email = user?.Email ?? "—",
116 + FullName = user != null ? $"{user.FirstName} {user.LastName}".Trim() : "—",
117 + ExpenseCount = g.Count(),
118 + TotalAmount = g.Sum(e => e.Amount)
119 + };
120 + })
121 + .OrderByDescending(x => x.ExpenseCount)
122 + .Take(5)
123 + .ToList();
124 +
125 + // === Activity feed (chronological) ===
126 + var feed = new List<AdminActivityFeedItem>();
127 + foreach (var t in trips)
128 + {
129 + feed.Add(new AdminActivityFeedItem
130 + {
131 + Type = "trip",
132 + Date = t.CreatedAt,
133 + MessageKey = "Trip \"{0}\" created",
134 + MessageArgs = new object[] { t.Name },
135 + IconCssClass = "bi-suitcase-lg",
136 + BadgeCssClass = "bg-primary"
137 + });
138 + }
139 + foreach (var e in expenses.OrderByDescending(x => x.CreatedAt).Take(30))
140 + {
141 + var tripName = trips.FirstOrDefault(t => t.Id == e.TripId)?.Name ?? "?";
142 + feed.Add(new AdminActivityFeedItem
143 + {
144 + Type = "expense",
145 + Date = e.CreatedAt,
146 + MessageKey = "Expense {0:0.00} added to \"{1}\"",
147 + MessageArgs = new object[] { e.Amount, tripName },
148 + IconCssClass = "bi-cash-coin",
149 + BadgeCssClass = "bg-success"
150 + });
151 + }
152 + foreach (var s in settlementPlans)
153 + {
154 + var tripName = trips.FirstOrDefault(t => t.Id == s.TripId)?.Name ?? "?";
155 + feed.Add(new AdminActivityFeedItem
156 + {
157 + Type = "settlement",
158 + Date = s.CreatedAt,
159 + MessageKey = "Settlement plan for \"{0}\" ({1})",
160 + MessageArgs = new object[] { tripName, s.Status },
161 + IconCssClass = "bi-diagram-3",
162 + BadgeCssClass = "bg-info"
163 + });
164 + }
165 + var activityFeed = feed.OrderByDescending(f => f.Date).Take(15).ToList();
166 +
167 + return new AdminDashboardData
168 + {
169 + TripCount = tripCount,
170 + UserCount = userCount,
171 + ExpenseCount = expenseCount,
172 + CategoryCount = budgetCategories.Count,
173 + SettlementCount = settlementPlans.Count,
174 + WishlistCount = wishlistItems.Count,
175 + PollCount = polls.Count,
176 + InvitationCount = invitations.Count,
177 + CurrencyCount = currencies.Count,
178 + ParticipantCount = participants.Count,
179 +
180 + ActiveTrips = activeTrips,
181 + SettledTrips = settledTrips,
182 + ArchivedTrips = archivedTrips,
183 + TotalExpenseAmount = totalExpenseAmount,
184 + PendingSettlements = pendingSettlements,
185 + InProgressSettlements = inProgressSettlements,
186 + CompletedSettlements = completedSettlements,
187 + PendingInvitations = pendingInvitations,
188 + PendingPayments = pendingPayments,
189 + MarkedPaidPayments = markedPaidPayments,
190 +
191 + RecentTrips = recentTrips,
192 + RecentExpenses = recentExpenses,
193 + RecentUsers = recentUsers,
194 +
195 + TopActiveTrips = topActiveTrips,
196 + BiggestExpenses = biggestExpenses,
197 + NewUsersLast7Days = activeUserIds7,
198 + NewUsersLast30Days = activeUserIds30,
199 + TopActiveUsers = topActiveUsers,
200 + ActivityFeed = activityFeed
201 + };
202 + }
203 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/BudgetCategoryAdminService.cs +81 −0
@@ -0,0 +1,81 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +using SplitApp.Shared.Kernel.Domain;
10 +using SplitApp.Shared.Kernel.Localization;
11 +
12 +namespace SplitApp.WebApp.Application.Services.Admin;
13 +
14 +public class BudgetCategoryAdminService : IBudgetCategoryAdminService
15 +{
16 + private readonly IAppUnitOfWork _uow;
17 +
18 + public BudgetCategoryAdminService(IAppUnitOfWork uow)
19 + {
20 + _uow = uow;
21 + }
22 +
23 + public async Task<List<BudgetCategoryBllDto>> GetAllAsync(Guid? tripId, string? search)
24 + {
25 + var items = (await _uow.BudgetCategories.GetAllAsync()).ToList();
26 + if (tripId.HasValue)
27 + items = items.Where(b => b.TripId == tripId.Value).ToList();
28 + if (!string.IsNullOrEmpty(search))
29 + items = items.Where(b => b.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
30 + return BudgetCategoryBllDtoFactory.CreateList(items.OrderBy(b => b.DisplayOrder));
31 + }
32 +
33 + public async Task<List<TripBllDto>> GetAllTripsAsync()
34 + {
35 + var trips = await _uow.Trips.GetAllAsync();
36 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
37 + }
38 +
39 + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id)
40 + {
41 + var category = await _uow.BudgetCategories.GetByIdAsync(id);
42 + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category);
43 + }
44 +
45 + public async Task CreateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt)
46 + {
47 + var domainEntity = BudgetCategoryBllDtoFactory.ToEntity(entity);
48 + ApplyLangStr(domainEntity, nameEn, nameEt);
49 + domainEntity.Id = Guid.NewGuid();
50 + _uow.BudgetCategories.Add(domainEntity);
51 + await _uow.SaveChangesAsync();
52 + }
53 +
54 + public async Task UpdateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt)
55 + {
56 + var existing = await _uow.BudgetCategories.GetByIdAsync(entity.Id);
57 + if (existing == null) return;
58 + existing.TripId = entity.TripId;
59 + existing.IconName = entity.IconName;
60 + existing.PlannedAmount = entity.PlannedAmount;
61 + existing.DisplayOrder = entity.DisplayOrder;
62 + ApplyLangStr(existing, nameEn, nameEt);
63 + _uow.BudgetCategories.Update(existing);
64 + await _uow.SaveChangesAsync();
65 + }
66 +
67 + public async Task DeleteAsync(Guid id)
68 + {
69 + await _uow.BudgetCategories.RemoveAsync(id);
70 + await _uow.SaveChangesAsync();
71 + }
72 +
73 + public Task<bool> ExistsAsync(Guid id) => _uow.BudgetCategories.ExistsAsync(id);
74 +
75 + private static void ApplyLangStr(BudgetCategory entity, string? nameEn, string? nameEt)
76 + {
77 + var name = new LangStr(nameEn ?? "", "en");
78 + name.SetTranslation(nameEt ?? nameEn ?? "", "et");
79 + entity.Name = name;
80 + }
81 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/CurrencyAdminService.cs +73 −0
@@ -0,0 +1,73 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +using SplitApp.Shared.Kernel.Domain;
10 +using SplitApp.Shared.Kernel.Localization;
11 +
12 +namespace SplitApp.WebApp.Application.Services.Admin;
13 +
14 +public class CurrencyAdminService : ICurrencyAdminService
15 +{
16 + private readonly IAppUnitOfWork _uow;
17 +
18 + public CurrencyAdminService(IAppUnitOfWork uow)
19 + {
20 + _uow = uow;
21 + }
22 +
23 + public async Task<List<CurrencyBllDto>> GetAllAsync(string? search)
24 + {
25 + var items = (await _uow.GetRepository<Currency>().GetAllAsync()).OrderBy(c => c.Code).ToList();
26 + if (!string.IsNullOrEmpty(search))
27 + items = items.Where(c =>
28 + c.Code.Contains(search, StringComparison.OrdinalIgnoreCase) ||
29 + c.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
30 + return CurrencyBllDtoFactory.CreateList(items);
31 + }
32 +
33 + public async Task<CurrencyBllDto?> GetByIdAsync(Guid id)
34 + {
35 + var entity = await _uow.GetRepository<Currency>().GetByIdAsync(id);
36 + return entity == null ? null : CurrencyBllDtoFactory.Create(entity);
37 + }
38 +
39 + public async Task CreateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt)
40 + {
41 + var domainEntity = CurrencyBllDtoFactory.ToEntity(entity);
42 + ApplyLangStr(domainEntity, nameEn, nameEt);
43 + domainEntity.Id = Guid.NewGuid();
44 + _uow.GetRepository<Currency>().Add(domainEntity);
45 + await _uow.SaveChangesAsync();
46 + }
47 +
48 + public async Task UpdateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt)
49 + {
50 + var existing = await _uow.GetRepository<Currency>().GetByIdAsync(entity.Id);
51 + if (existing == null) return;
52 + existing.Code = entity.Code;
53 + existing.Symbol = entity.Symbol;
54 + ApplyLangStr(existing, nameEn, nameEt);
55 + _uow.GetRepository<Currency>().Update(existing);
56 + await _uow.SaveChangesAsync();
57 + }
58 +
59 + public async Task DeleteAsync(Guid id)
60 + {
61 + await _uow.GetRepository<Currency>().RemoveAsync(id);
62 + await _uow.SaveChangesAsync();
63 + }
64 +
65 + public Task<bool> ExistsAsync(Guid id) => _uow.GetRepository<Currency>().ExistsAsync(id);
66 +
67 + private static void ApplyLangStr(Currency entity, string? nameEn, string? nameEt)
68 + {
69 + var name = new LangStr(nameEn ?? "", "en");
70 + name.SetTranslation(nameEt ?? nameEn ?? "", "et");
71 + entity.Name = name;
72 + }
73 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ExpenseAdminService.cs +99 −0
@@ -0,0 +1,99 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +using SplitApp.Modules.Users.Domain.Entities;
10 +
11 +namespace SplitApp.WebApp.Application.Services.Admin;
12 +
13 +public class ExpenseAdminService : IExpenseAdminService
14 +{
15 + private readonly IAppUnitOfWork _uow;
16 +
17 + public ExpenseAdminService(IAppUnitOfWork uow)
18 + {
19 + _uow = uow;
20 + }
21 +
22 + public async Task<List<ExpenseBllDto>> GetAllAsync(Guid? tripId, string? search)
23 + {
24 + var items = (await _uow.Expenses.GetAllAsync()).ToList();
25 + if (tripId.HasValue)
26 + items = items.Where(e => e.TripId == tripId.Value).ToList();
27 + if (!string.IsNullOrEmpty(search))
28 + items = items.Where(e => e.Description != null && e.Description.Contains(search)).ToList();
29 + return ExpenseBllDtoFactory.CreateList(items.OrderByDescending(e => e.ExpenseDate));
30 + }
31 +
32 + public async Task<ExpenseBllDto?> GetByIdAsync(Guid id)
33 + {
34 + var entity = await _uow.Expenses.GetByIdAsync(id);
35 + return entity == null ? null : ExpenseBllDtoFactory.Create(entity);
36 + }
37 +
38 + public async Task CreateAsync(ExpenseBllDto entity)
39 + {
40 + var domainEntity = ExpenseBllDtoFactory.ToEntity(entity);
41 + domainEntity.Id = Guid.NewGuid();
42 + _uow.Expenses.Add(domainEntity);
43 + await _uow.SaveChangesAsync();
44 + }
45 +
46 + public async Task UpdateAsync(ExpenseBllDto entity)
47 + {
48 + var existing = await _uow.Expenses.GetByIdAsync(entity.Id);
49 + if (existing == null) return;
50 + existing.TripId = entity.TripId;
51 + existing.PaidByUserId = entity.PaidByUserId;
52 + existing.BudgetCategoryId = entity.BudgetCategoryId;
53 + existing.CurrencyId = entity.CurrencyId;
54 + existing.Amount = entity.Amount;
55 + existing.Description = entity.Description;
56 + existing.ExpenseDate = entity.ExpenseDate;
57 + existing.SplitMethod = entity.SplitMethod;
58 + _uow.Expenses.Update(existing);
59 + await _uow.SaveChangesAsync();
60 + }
61 +
62 + public async Task DeleteAsync(Guid id)
63 + {
64 + await _uow.Expenses.RemoveAsync(id);
65 + await _uow.SaveChangesAsync();
66 + }
67 +
68 + public Task<bool> ExistsAsync(Guid id) => _uow.Expenses.ExistsAsync(id);
69 +
70 + public async Task<List<TripBllDto>> GetTripsAsync()
71 + {
72 + var trips = await _uow.Trips.GetAllAsync();
73 + return TripBllDtoFactory.CreateList(trips);
74 + }
75 +
76 + public async Task<List<AppUserBllDto>> GetUsersAsync()
77 + {
78 + var users = await _uow.Users.GetAllAsync();
79 + return users.Select(u => new AppUserBllDto
80 + {
81 + Id = u.Id,
82 + FirstName = u.FirstName,
83 + LastName = u.LastName,
84 + Email = u.Email
85 + }).ToList();
86 + }
87 +
88 + public async Task<List<CurrencyBllDto>> GetCurrenciesAsync()
89 + {
90 + var currencies = await _uow.GetRepository<Currency>().GetAllAsync();
91 + return CurrencyBllDtoFactory.CreateList(currencies);
92 + }
93 +
94 + public async Task<List<BudgetCategoryBllDto>> GetBudgetCategoriesAsync()
95 + {
96 + var categories = await _uow.BudgetCategories.GetAllAsync();
97 + return BudgetCategoryBllDtoFactory.CreateList(categories);
98 + }
99 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IAdminStatsService.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.WebApp.Application.Services.Admin;
2 +
3 +public interface IAdminStatsService
4 +{
5 + Task<AdminDashboardData> GetDashboardStatsAsync();
6 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IBudgetCategoryAdminService
6 +{
7 + Task<List<BudgetCategoryBllDto>> GetAllAsync(Guid? tripId, string? search);
8 + Task<List<TripBllDto>> GetAllTripsAsync();
9 + Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id);
10 + Task CreateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt);
11 + Task UpdateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt);
12 + Task DeleteAsync(Guid id);
13 + Task<bool> ExistsAsync(Guid id);
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ICurrencyAdminService.cs +13 −0
@@ -0,0 +1,13 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ICurrencyAdminService
6 +{
7 + Task<List<CurrencyBllDto>> GetAllAsync(string? search);
8 + Task<CurrencyBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt);
10 + Task UpdateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IExpenseAdminService.cs +18 −0
@@ -0,0 +1,18 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IExpenseAdminService
6 +{
7 + Task<List<ExpenseBllDto>> GetAllAsync(Guid? tripId, string? search);
8 + Task<ExpenseBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(ExpenseBllDto entity);
10 + Task UpdateAsync(ExpenseBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 + Task<List<CurrencyBllDto>> GetCurrenciesAsync();
17 + Task<List<BudgetCategoryBllDto>> GetBudgetCategoriesAsync();
18 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IInvitationAdminService.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IInvitationAdminService
6 +{
7 + Task<List<TripInvitationBllDto>> GetAllAsync(string? search);
8 + Task<TripInvitationBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripInvitationBllDto entity);
10 + Task UpdateAsync(TripInvitationBllDto entity);
11 + Task DeleteAsync(Guid id);
12 +
13 + Task<List<TripBllDto>> GetTripsAsync();
14 + Task<List<AppUserBllDto>> GetUsersAsync();
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IPollAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IPollAdminService
6 +{
7 + Task<List<TripPollBllDto>> GetAllAsync(string? search);
8 + Task<TripPollBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripPollBllDto entity);
10 + Task UpdateAsync(TripPollBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ISettlementPaymentAdminService
6 +{
7 + Task<List<SettlementPaymentBllDto>> GetAllAsync(string? search);
8 + Task<SettlementPaymentBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(SettlementPaymentBllDto entity);
10 + Task UpdateAsync(SettlementPaymentBllDto entity);
11 + Task DeleteAsync(Guid id);
12 +
13 + Task<List<SettlementPlanBllDto>> GetSettlementPlansAsync();
14 + Task<List<AppUserBllDto>> GetUsersAsync();
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPlanAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ISettlementPlanAdminService
6 +{
7 + Task<List<SettlementPlanBllDto>> GetAllAsync(Guid? tripId);
8 + Task<SettlementPlanBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(SettlementPlanBllDto entity);
10 + Task UpdateAsync(SettlementPlanBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISplitPresetAdminService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ISplitPresetAdminService
6 +{
7 + Task<List<SplitPresetBllDto>> GetAllAsync(string? search);
8 + Task<SplitPresetBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(SplitPresetBllDto entity);
10 + Task DeleteAsync(Guid id);
11 +
12 + Task<List<TripBllDto>> GetTripsAsync();
13 + Task<List<AppUserBllDto>> GetUsersAsync();
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripAdminService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ITripAdminService
6 +{
7 + Task<List<TripBllDto>> GetAllAsync(string? search);
8 + Task<List<CurrencyBllDto>> GetAllCurrenciesAsync();
9 + Task<TripBllDto?> GetByIdAsync(Guid id);
10 + Task CreateAsync(TripBllDto entity);
11 + Task UpdateAsync(TripBllDto entity);
12 + Task DeleteAsync(Guid id);
13 + Task<bool> ExistsAsync(Guid id);
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripParticipantAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ITripParticipantAdminService
6 +{
7 + Task<List<TripParticipantBllDto>> GetAllAsync(Guid? tripId, string? search);
8 + Task<TripParticipantBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripParticipantBllDto entity);
10 + Task UpdateAsync(TripParticipantBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IWishlistAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IWishlistAdminService
6 +{
7 + Task<List<TripWishlistItemBllDto>> GetAllAsync(string? search);
8 + Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripWishlistItemBllDto entity);
10 + Task UpdateAsync(TripWishlistItemBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/InvitationAdminService.cs +77 −0
@@ -0,0 +1,77 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class InvitationAdminService : IInvitationAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public InvitationAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<TripInvitationBllDto>> GetAllAsync(string? search)
17 + {
18 + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList();
19 + if (!string.IsNullOrEmpty(search))
20 + invitations = invitations.Where(i => i.Token.Contains(search)).ToList();
21 + return InvitationBllDtoFactory.CreateList(invitations.OrderByDescending(i => i.Id));
22 + }
23 +
24 + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id)
25 + {
26 + var entity = await _uow.TripInvitations.GetByIdAsync(id);
27 + return entity == null ? null : InvitationBllDtoFactory.Create(entity);
28 + }
29 +
30 + public async Task CreateAsync(TripInvitationBllDto entity)
31 + {
32 + var domainEntity = InvitationBllDtoFactory.ToEntity(entity);
33 + domainEntity.Id = Guid.NewGuid();
34 + if (string.IsNullOrEmpty(domainEntity.Token))
35 + domainEntity.Token = Guid.NewGuid().ToString("N");
36 + _uow.TripInvitations.Add(domainEntity);
37 + await _uow.SaveChangesAsync();
38 + }
39 +
40 + public async Task UpdateAsync(TripInvitationBllDto entity)
41 + {
42 + var existing = await _uow.TripInvitations.GetByIdAsync(entity.Id);
43 + if (existing == null) return;
44 + existing.TripId = entity.TripId;
45 + existing.InvitedByUserId = entity.InvitedByUserId;
46 + existing.Token = entity.Token;
47 + existing.Status = entity.Status;
48 + existing.ExpiresAt = entity.ExpiresAt;
49 + existing.RespondedAt = entity.RespondedAt;
50 + _uow.TripInvitations.Update(existing);
51 + await _uow.SaveChangesAsync();
52 + }
53 +
54 + public async Task DeleteAsync(Guid id)
55 + {
56 + await _uow.TripInvitations.RemoveAsync(id);
57 + await _uow.SaveChangesAsync();
58 + }
59 +
60 + public async Task<List<TripBllDto>> GetTripsAsync()
61 + {
62 + var trips = await _uow.Trips.GetAllAsync();
63 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
64 + }
65 +
66 + public async Task<List<AppUserBllDto>> GetUsersAsync()
67 + {
68 + var users = await _uow.Users.GetAllAsync();
69 + return users.Select(u => new AppUserBllDto
70 + {
71 + Id = u.Id,
72 + FirstName = u.FirstName,
73 + LastName = u.LastName,
74 + Email = u.Email
75 + }).ToList();
76 + }
77 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/PollAdminService.cs +77 −0
@@ -0,0 +1,77 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class PollAdminService : IPollAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public PollAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<TripPollBllDto>> GetAllAsync(string? search)
17 + {
18 + var items = (await _uow.TripPolls.GetAllAsync()).ToList();
19 + if (!string.IsNullOrEmpty(search))
20 + items = items.Where(p => p.Question.Contains(search)).ToList();
21 + return PollBllDtoFactory.CreateList(items.OrderByDescending(p => p.Id), includeOptions: true);
22 + }
23 +
24 + public async Task<TripPollBllDto?> GetByIdAsync(Guid id)
25 + {
26 + var entity = await _uow.TripPolls.GetByIdAsync(id);
27 + return entity == null ? null : PollBllDtoFactory.Create(entity);
28 + }
29 +
30 + public async Task CreateAsync(TripPollBllDto entity)
31 + {
32 + var domainEntity = PollBllDtoFactory.ToEntity(entity);
33 + domainEntity.Id = Guid.NewGuid();
34 + _uow.TripPolls.Add(domainEntity);
35 + await _uow.SaveChangesAsync();
36 + }
37 +
38 + public async Task UpdateAsync(TripPollBllDto entity)
39 + {
40 + var existing = await _uow.TripPolls.GetByIdAsync(entity.Id);
41 + if (existing == null) return;
42 + existing.TripId = entity.TripId;
43 + existing.CreatedByUserId = entity.CreatedByUserId;
44 + existing.Question = entity.Question;
45 + existing.AllowMultipleVotes = entity.AllowMultipleVotes;
46 + existing.IsAnonymous = entity.IsAnonymous;
47 + existing.ClosedAt = entity.ClosedAt;
48 + _uow.TripPolls.Update(existing);
49 + await _uow.SaveChangesAsync();
50 + }
51 +
52 + public async Task DeleteAsync(Guid id)
53 + {
54 + await _uow.TripPolls.RemoveAsync(id);
55 + await _uow.SaveChangesAsync();
56 + }
57 +
58 + public Task<bool> ExistsAsync(Guid id) => _uow.TripPolls.ExistsAsync(id);
59 +
60 + public async Task<List<TripBllDto>> GetTripsAsync()
61 + {
62 + var trips = await _uow.Trips.GetAllAsync();
63 + return TripBllDtoFactory.CreateList(trips);
64 + }
65 +
66 + public async Task<List<AppUserBllDto>> GetUsersAsync()
67 + {
68 + var users = await _uow.Users.GetAllAsync();
69 + return users.Select(u => new AppUserBllDto
70 + {
71 + Id = u.Id,
72 + FirstName = u.FirstName,
73 + LastName = u.LastName,
74 + Email = u.Email
75 + }).ToList();
76 + }
77 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPaymentAdminService.cs +78 −0
@@ -0,0 +1,78 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class SettlementPaymentAdminService : ISettlementPaymentAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public SettlementPaymentAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<SettlementPaymentBllDto>> GetAllAsync(string? search)
17 + {
18 + var payments = (await _uow.SettlementPayments.GetAllAsync()).ToList();
19 + if (!string.IsNullOrEmpty(search))
20 + payments = payments.Where(s =>
21 + (s.FromUser?.Email != null && s.FromUser.Email.Contains(search)) ||
22 + (s.ToUser?.Email != null && s.ToUser.Email.Contains(search))).ToList();
23 + return SettlementPaymentBllDtoFactory.CreateList(payments.OrderByDescending(s => s.Id));
24 + }
25 +
26 + public async Task<SettlementPaymentBllDto?> GetByIdAsync(Guid id)
27 + {
28 + var entity = await _uow.SettlementPayments.GetByIdAsync(id);
29 + return entity == null ? null : SettlementPaymentBllDtoFactory.Create(entity);
30 + }
31 +
32 + public async Task CreateAsync(SettlementPaymentBllDto entity)
33 + {
34 + var domainEntity = SettlementPaymentBllDtoFactory.ToEntity(entity);
35 + domainEntity.Id = Guid.NewGuid();
36 + _uow.SettlementPayments.Add(domainEntity);
37 + await _uow.SaveChangesAsync();
38 + }
39 +
40 + public async Task UpdateAsync(SettlementPaymentBllDto entity)
41 + {
42 + var existing = await _uow.SettlementPayments.GetByIdAsync(entity.Id);
43 + if (existing == null) return;
44 + existing.SettlementPlanId = entity.SettlementPlanId;
45 + existing.FromUserId = entity.FromUserId;
46 + existing.ToUserId = entity.ToUserId;
47 + existing.Amount = entity.Amount;
48 + existing.Status = entity.Status;
49 + existing.MarkedPaidAt = entity.MarkedPaidAt;
50 + existing.ConfirmedAt = entity.ConfirmedAt;
51 + _uow.SettlementPayments.Update(existing);
52 + await _uow.SaveChangesAsync();
53 + }
54 +
55 + public async Task DeleteAsync(Guid id)
56 + {
57 + await _uow.SettlementPayments.RemoveAsync(id);
58 + await _uow.SaveChangesAsync();
59 + }
60 +
61 + public async Task<List<SettlementPlanBllDto>> GetSettlementPlansAsync()
62 + {
63 + var plans = await _uow.SettlementPlans.GetAllAsync();
64 + return SettlementBllDtoFactory.CreateList(plans);
65 + }
66 +
67 + public async Task<List<AppUserBllDto>> GetUsersAsync()
68 + {
69 + var users = await _uow.Users.GetAllAsync();
70 + return users.Select(u => new AppUserBllDto
71 + {
72 + Id = u.Id,
73 + FirstName = u.FirstName,
74 + LastName = u.LastName,
75 + Email = u.Email
76 + }).ToList();
77 + }
78 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPlanAdminService.cs +76 −0
@@ -0,0 +1,76 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class SettlementPlanAdminService : ISettlementPlanAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public SettlementPlanAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<SettlementPlanBllDto>> GetAllAsync(Guid? tripId)
17 + {
18 + var plans = (await _uow.SettlementPlans.GetAllAsync()).ToList();
19 + if (tripId.HasValue)
20 + plans = plans.Where(s => s.TripId == tripId.Value).ToList();
21 + return SettlementBllDtoFactory.CreateList(plans.OrderByDescending(s => s.Id));
22 + }
23 +
24 + public async Task<SettlementPlanBllDto?> GetByIdAsync(Guid id)
25 + {
26 + var entity = await _uow.SettlementPlans.GetByIdAsync(id);
27 + return entity == null ? null : SettlementBllDtoFactory.Create(entity);
28 + }
29 +
30 + public async Task CreateAsync(SettlementPlanBllDto entity)
31 + {
32 + var domainEntity = SettlementBllDtoFactory.ToEntity(entity);
33 + domainEntity.Id = Guid.NewGuid();
34 + _uow.SettlementPlans.Add(domainEntity);
35 + await _uow.SaveChangesAsync();
36 + }
37 +
38 + public async Task UpdateAsync(SettlementPlanBllDto entity)
39 + {
40 + var existing = await _uow.SettlementPlans.GetByIdAsync(entity.Id);
41 + if (existing == null) return;
42 + existing.TripId = entity.TripId;
43 + existing.CreatedByUserId = entity.CreatedByUserId;
44 + existing.TotalAmount = entity.TotalAmount;
45 + existing.Status = entity.Status;
46 + existing.CompletedAt = entity.CompletedAt;
47 + _uow.SettlementPlans.Update(existing);
48 + await _uow.SaveChangesAsync();
49 + }
50 +
51 + public async Task DeleteAsync(Guid id)
52 + {
53 + await _uow.SettlementPlans.RemoveAsync(id);
54 + await _uow.SaveChangesAsync();
55 + }
56 +
57 + public Task<bool> ExistsAsync(Guid id) => _uow.SettlementPlans.ExistsAsync(id);
58 +
59 + public async Task<List<TripBllDto>> GetTripsAsync()
60 + {
61 + var trips = await _uow.Trips.GetAllAsync();
62 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
63 + }
64 +
65 + public async Task<List<AppUserBllDto>> GetUsersAsync()
66 + {
67 + var users = await _uow.Users.GetAllAsync();
68 + return users.Select(u => new AppUserBllDto
69 + {
70 + Id = u.Id,
71 + FirstName = u.FirstName,
72 + LastName = u.LastName,
73 + Email = u.Email
74 + }).ToList();
75 + }
76 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SplitPresetAdminService.cs +61 −0
@@ -0,0 +1,61 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class SplitPresetAdminService : ISplitPresetAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public SplitPresetAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<SplitPresetBllDto>> GetAllAsync(string? search)
17 + {
18 + var presets = (await _uow.SplitPresets.GetAllAsync()).ToList();
19 + if (!string.IsNullOrEmpty(search))
20 + presets = presets.Where(s => s.Name.Contains(search)).ToList();
21 + return SplitPresetBllDtoFactory.CreateList(presets.OrderByDescending(s => s.Id), includeMembers: true);
22 + }
23 +
24 + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id)
25 + {
26 + var entity = await _uow.SplitPresets.GetByIdAsync(id);
27 + return entity == null ? null : SplitPresetBllDtoFactory.Create(entity, includeMembers: true);
28 + }
29 +
30 + public async Task CreateAsync(SplitPresetBllDto entity)
31 + {
32 + var domainEntity = SplitPresetBllDtoFactory.ToEntity(entity);
33 + domainEntity.Id = Guid.NewGuid();
34 + _uow.SplitPresets.Add(domainEntity);
35 + await _uow.SaveChangesAsync();
36 + }
37 +
38 + public async Task DeleteAsync(Guid id)
39 + {
40 + await _uow.SplitPresets.RemoveAsync(id);
41 + await _uow.SaveChangesAsync();
42 + }
43 +
44 + public async Task<List<TripBllDto>> GetTripsAsync()
45 + {
46 + var trips = await _uow.Trips.GetAllAsync();
47 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
48 + }
49 +
50 + public async Task<List<AppUserBllDto>> GetUsersAsync()
51 + {
52 + var users = await _uow.Users.GetAllAsync();
53 + return users.Select(u => new AppUserBllDto
54 + {
55 + Id = u.Id,
56 + FirstName = u.FirstName,
57 + LastName = u.LastName,
58 + Email = u.Email
59 + }).ToList();
60 + }
61 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripAdminService.cs +71 −0
@@ -0,0 +1,71 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services.Admin;
11 +
12 +public class TripAdminService : ITripAdminService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public TripAdminService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<List<TripBllDto>> GetAllAsync(string? search)
22 + {
23 + var items = (await _uow.Trips.GetAllAsync()).ToList();
24 + if (!string.IsNullOrEmpty(search))
25 + items = items.Where(t => t.Name.Contains(search)).ToList();
26 + return TripBllDtoFactory.CreateList(items.OrderByDescending(t => t.CreatedAt));
27 + }
28 +
29 + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync()
30 + {
31 + var currencies = await _uow.GetRepository<Currency>().GetAllAsync();
32 + return CurrencyBllDtoFactory.CreateList(currencies);
33 + }
34 +
35 + public async Task<TripBllDto?> GetByIdAsync(Guid id)
36 + {
37 + var trip = await _uow.Trips.GetByIdAsync(id);
38 + return trip == null ? null : TripBllDtoFactory.Create(trip);
39 + }
40 +
41 + public async Task CreateAsync(TripBllDto entity)
42 + {
43 + var domainEntity = TripBllDtoFactory.ToEntity(entity);
44 + domainEntity.Id = Guid.NewGuid();
45 + _uow.Trips.Add(domainEntity);
46 + await _uow.SaveChangesAsync();
47 + }
48 +
49 + public async Task UpdateAsync(TripBllDto entity)
50 + {
51 + var existing = await _uow.Trips.GetByIdAsync(entity.Id);
52 + if (existing == null) return;
53 + existing.Name = entity.Name;
54 + existing.Description = entity.Description;
55 + existing.Destination = entity.Destination;
56 + existing.StartDate = entity.StartDate;
57 + existing.EndDate = entity.EndDate;
58 + existing.Status = entity.Status;
59 + existing.DefaultCurrencyId = entity.DefaultCurrencyId;
60 + _uow.Trips.Update(existing);
61 + await _uow.SaveChangesAsync();
62 + }
63 +
64 + public async Task DeleteAsync(Guid id)
65 + {
66 + await _uow.Trips.RemoveAsync(id);
67 + await _uow.SaveChangesAsync();
68 + }
69 +
70 + public Task<bool> ExistsAsync(Guid id) => _uow.Trips.ExistsAsync(id);
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripParticipantAdminService.cs +86 −0
@@ -0,0 +1,86 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class TripParticipantAdminService : ITripParticipantAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public TripParticipantAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<TripParticipantBllDto>> GetAllAsync(Guid? tripId, string? search)
17 + {
18 + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList();
19 +
20 + if (tripId.HasValue)
21 + participants = participants.Where(tp => tp.TripId == tripId.Value).ToList();
22 +
23 + if (!string.IsNullOrEmpty(search))
24 + participants = participants.Where(tp => tp.User != null && (
25 + tp.User.FirstName.Contains(search) ||
26 + tp.User.LastName.Contains(search) ||
27 + (tp.User.Email != null && tp.User.Email.Contains(search)))).ToList();
28 +
29 + return TripParticipantBllDtoFactory.CreateList(participants.OrderByDescending(tp => tp.JoinedAt));
30 + }
31 +
32 + public async Task<TripParticipantBllDto?> GetByIdAsync(Guid id)
33 + {
34 + var entity = await _uow.TripParticipants.GetByIdAsync(id);
35 + return entity == null ? null : TripParticipantBllDtoFactory.Create(entity);
36 + }
37 +
38 + public async Task CreateAsync(TripParticipantBllDto entity)
39 + {
40 + var domainEntity = TripParticipantBllDtoFactory.ToEntity(entity);
41 + domainEntity.Id = Guid.NewGuid();
42 + _uow.TripParticipants.Add(domainEntity);
43 + await _uow.SaveChangesAsync();
44 + }
45 +
46 + public async Task UpdateAsync(TripParticipantBllDto entity)
47 + {
48 + var existing = await _uow.TripParticipants.GetByIdAsync(entity.Id);
49 + if (existing == null) return;
50 + existing.TripId = entity.TripId;
51 + existing.UserId = entity.UserId;
52 + existing.Role = entity.Role;
53 + existing.Nickname = entity.Nickname;
54 + existing.JoinedAt = entity.JoinedAt;
55 + existing.LeftAt = entity.LeftAt;
56 + existing.IsActive = entity.IsActive;
57 + _uow.TripParticipants.Update(existing);
58 + await _uow.SaveChangesAsync();
59 + }
60 +
61 + public async Task DeleteAsync(Guid id)
62 + {
63 + await _uow.TripParticipants.RemoveAsync(id);
64 + await _uow.SaveChangesAsync();
65 + }
66 +
67 + public Task<bool> ExistsAsync(Guid id) => _uow.TripParticipants.ExistsAsync(id);
68 +
69 + public async Task<List<TripBllDto>> GetTripsAsync()
70 + {
71 + var trips = await _uow.Trips.GetAllAsync();
72 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
73 + }
74 +
75 + public async Task<List<AppUserBllDto>> GetUsersAsync()
76 + {
77 + var users = await _uow.Users.GetAllAsync();
78 + return users.Select(u => new AppUserBllDto
79 + {
80 + Id = u.Id,
81 + FirstName = u.FirstName,
82 + LastName = u.LastName,
83 + Email = u.Email
84 + }).ToList();
85 + }
86 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/WishlistAdminService.cs +83 −0
@@ -0,0 +1,83 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.WebApp.Application.Contracts;
4 +
5 +namespace SplitApp.WebApp.Application.Services.Admin;
6 +
7 +public class WishlistAdminService : IWishlistAdminService
8 +{
9 + private readonly IAppUnitOfWork _uow;
10 +
11 + public WishlistAdminService(IAppUnitOfWork uow)
12 + {
13 + _uow = uow;
14 + }
15 +
16 + public async Task<List<TripWishlistItemBllDto>> GetAllAsync(string? search)
17 + {
18 + var items = (await _uow.TripWishlistItems.GetAllAsync()).ToList();
19 + if (!string.IsNullOrEmpty(search))
20 + items = items.Where(w => w.Title.Contains(search)).ToList();
21 + return WishlistBllDtoFactory.CreateList(items.OrderByDescending(w => w.Id));
22 + }
23 +
24 + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id)
25 + {
26 + var entity = await _uow.TripWishlistItems.GetByIdAsync(id);
27 + return entity == null ? null : WishlistBllDtoFactory.Create(entity);
28 + }
29 +
30 + public async Task CreateAsync(TripWishlistItemBllDto entity)
31 + {
32 + var domainEntity = WishlistBllDtoFactory.ToEntity(entity);
33 + domainEntity.Id = Guid.NewGuid();
34 + _uow.TripWishlistItems.Add(domainEntity);
35 + await _uow.SaveChangesAsync();
36 + }
37 +
38 + public async Task UpdateAsync(TripWishlistItemBllDto entity)
39 + {
40 + var existing = await _uow.TripWishlistItems.GetByIdAsync(entity.Id);
41 + if (existing == null) return;
42 + existing.TripId = entity.TripId;
43 + existing.AddedByUserId = entity.AddedByUserId;
44 + existing.Title = entity.Title;
45 + existing.Description = entity.Description;
46 + existing.Category = entity.Category;
47 + existing.Priority = entity.Priority;
48 + existing.EstimatedCost = entity.EstimatedCost;
49 + existing.Url = entity.Url;
50 + existing.Location = entity.Location;
51 + existing.IsCompleted = entity.IsCompleted;
52 + existing.CompletedAt = entity.CompletedAt;
53 + existing.DisplayOrder = entity.DisplayOrder;
54 + _uow.TripWishlistItems.Update(existing);
55 + await _uow.SaveChangesAsync();
56 + }
57 +
58 + public async Task DeleteAsync(Guid id)
59 + {
60 + await _uow.TripWishlistItems.RemoveAsync(id);
61 + await _uow.SaveChangesAsync();
62 + }
63 +
64 + public Task<bool> ExistsAsync(Guid id) => _uow.TripWishlistItems.ExistsAsync(id);
65 +
66 + public async Task<List<TripBllDto>> GetTripsAsync()
67 + {
68 + var trips = await _uow.Trips.GetAllAsync();
69 + return TripBllDtoFactory.CreateList(trips);
70 + }
71 +
72 + public async Task<List<AppUserBllDto>> GetUsersAsync()
73 + {
74 + var users = await _uow.Users.GetAllAsync();
75 + return users.Select(u => new AppUserBllDto
76 + {
77 + Id = u.Id,
78 + FirstName = u.FirstName,
79 + LastName = u.LastName,
80 + Email = u.Email
81 + }).ToList();
82 + }
83 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/BudgetCategoryService.cs +86 −0
@@ -0,0 +1,86 @@
1 +using MediatR;
2 +using SplitApp.Shared.Contracts.Expenses.Queries;
3 +using SplitApp.WebApp.Application.DTO;
4 +using SplitApp.WebApp.Application.Mappers;
5 +using SplitApp.WebApp.Application.Contracts;
6 +
7 +namespace SplitApp.WebApp.Application.Services;
8 +
9 +public class BudgetCategoryService : IBudgetCategoryService
10 +{
11 + private readonly IAppUnitOfWork _uow;
12 + private readonly IMediator _mediator;
13 +
14 + public BudgetCategoryService(IAppUnitOfWork uow, IMediator mediator)
15 + {
16 + _uow = uow;
17 + _mediator = mediator;
18 + }
19 +
20 + public async Task<List<BudgetCategoryBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
21 + {
22 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
23 + return new List<BudgetCategoryBllDto>();
24 + var categories = await _uow.BudgetCategories.GetByTripIdAsync(tripId);
25 + var spent = await _mediator.Send(new GetBudgetCategorySpentQuery(tripId));
26 + return BudgetCategoryBllDtoFactory.CreateList(categories, spent);
27 + }
28 +
29 + public async Task<List<BudgetCategoryBllDto>> GetByTripIdRawAsync(Guid tripId)
30 + {
31 + var categories = await _uow.BudgetCategories.GetByTripIdAsync(tripId);
32 + var spent = await _mediator.Send(new GetBudgetCategorySpentQuery(tripId));
33 + return BudgetCategoryBllDtoFactory.CreateList(categories, spent);
34 + }
35 +
36 + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id)
37 + {
38 + var category = await _uow.BudgetCategories.GetByIdAsync(id);
39 + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category);
40 + }
41 +
42 + public async Task<(BudgetCategoryBllDto? category, string? errorCode)> CreateAsync(BudgetCategoryBllDto category, Guid userId)
43 + {
44 + if (!await _uow.TripParticipants.IsOrganizerAsync(category.TripId, userId))
45 + return (null, "forbidden");
46 +
47 + var entity = BudgetCategoryBllDtoFactory.ToEntity(category);
48 + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid();
49 + _uow.BudgetCategories.Add(entity);
50 + await _uow.SaveChangesAsync();
51 +
52 + var reloaded = await _uow.BudgetCategories.GetByIdAsync(entity.Id);
53 + return (reloaded == null ? null : BudgetCategoryBllDtoFactory.Create(reloaded), null);
54 + }
55 +
56 + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, BudgetCategoryBllDto incoming, Guid userId)
57 + {
58 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
59 + if (existing == null) return (false, "notfound");
60 +
61 + if (!await _uow.TripParticipants.IsOrganizerAsync(existing.TripId, userId))
62 + return (false, "forbidden");
63 +
64 + existing.Name = incoming.Name;
65 + existing.IconName = incoming.IconName;
66 + existing.PlannedAmount = incoming.PlannedAmount;
67 + existing.DisplayOrder = incoming.DisplayOrder;
68 +
69 + _uow.BudgetCategories.Update(existing);
70 + await _uow.SaveChangesAsync();
71 + return (true, null);
72 + }
73 +
74 + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId)
75 + {
76 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
77 + if (existing == null) return (false, "notfound");
78 +
79 + if (!await _uow.TripParticipants.IsOrganizerAsync(existing.TripId, userId))
80 + return (false, "forbidden");
81 +
82 + await _uow.BudgetCategories.RemoveAsync(id);
83 + await _uow.SaveChangesAsync();
84 + return (true, null);
85 + }
86 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ExpenseService.cs +348 −0
@@ -0,0 +1,348 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class ExpenseService : IExpenseService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public ExpenseService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages)
22 + {
23 + var entity = ExpenseBllDtoFactory.ToEntity(expense);
24 + entity.Id = Guid.NewGuid();
25 + _uow.Expenses.Add(entity);
26 +
27 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
28 +
29 + switch (entity.SplitMethod)
30 + {
31 + case ESplitMethod.EqualAll:
32 + {
33 + var allParticipants = (await _uow.TripParticipants.GetByTripIdAsync(entity.TripId)).ToList();
34 + var count = allParticipants.Count;
35 + if (count > 0)
36 + {
37 + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100;
38 + var remainder = entity.Amount - baseAmount * count;
39 +
40 + for (var i = 0; i < allParticipants.Count; i++)
41 + {
42 + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0);
43 + splitRepo.Add(new ExpenseSplit
44 + {
45 + Id = Guid.NewGuid(),
46 + ExpenseId = entity.Id,
47 + UserId = allParticipants[i].UserId,
48 + Amount = amount
49 + });
50 + }
51 + }
52 + break;
53 + }
54 + case ESplitMethod.EqualSubset:
55 + {
56 + if (participants.Length > 0)
57 + {
58 + var count = participants.Length;
59 + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100;
60 + var remainder = entity.Amount - baseAmount * count;
61 +
62 + for (var i = 0; i < participants.Length; i++)
63 + {
64 + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0);
65 + splitRepo.Add(new ExpenseSplit
66 + {
67 + Id = Guid.NewGuid(),
68 + ExpenseId = entity.Id,
69 + UserId = participants[i],
70 + Amount = amount
71 + });
72 + }
73 + }
74 + break;
75 + }
76 + case ESplitMethod.ExactAmounts:
77 + {
78 + if (participants.Length > 0 && amounts.Length == participants.Length)
79 + {
80 + for (var i = 0; i < participants.Length; i++)
81 + {
82 + splitRepo.Add(new ExpenseSplit
83 + {
84 + Id = Guid.NewGuid(),
85 + ExpenseId = entity.Id,
86 + UserId = participants[i],
87 + Amount = amounts[i]
88 + });
89 + }
90 + }
91 + break;
92 + }
93 + case ESplitMethod.Percentages:
94 + {
95 + if (participants.Length > 0 && percentages.Length == participants.Length)
96 + {
97 + for (var i = 0; i < participants.Length; i++)
98 + {
99 + var amount = Math.Round(entity.Amount * percentages[i] / 100, 2);
100 + splitRepo.Add(new ExpenseSplit
101 + {
102 + Id = Guid.NewGuid(),
103 + ExpenseId = entity.Id,
104 + UserId = participants[i],
105 + Amount = amount,
106 + Percentage = percentages[i]
107 + });
108 + }
109 + }
110 + break;
111 + }
112 + }
113 +
114 + await _uow.SaveChangesAsync();
115 +
116 + return ExpenseBllDtoFactory.Create(entity);
117 + }
118 +
119 + public async Task DeleteExpenseWithSplitsAsync(Guid expenseId)
120 + {
121 + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId);
122 + if (expense == null) return;
123 +
124 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
125 +
126 + if (expense.Splits != null)
127 + {
128 + foreach (var split in expense.Splits.ToList())
129 + {
130 + await splitRepo.RemoveAsync(split.Id);
131 + }
132 + }
133 +
134 + await _uow.Expenses.RemoveAsync(expenseId);
135 + await _uow.SaveChangesAsync();
136 + }
137 +
138 + public async Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
139 + {
140 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
141 + return new List<ExpenseBllDto>();
142 + var expenses = await _uow.Expenses.GetByTripIdAsync(tripId);
143 + return ExpenseBllDtoFactory.CreateList(expenses, includeSplits: true);
144 + }
145 +
146 + public async Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId)
147 + {
148 + var expense = await _uow.Expenses.GetByIdAsync(expenseId);
149 + if (expense == null) return null;
150 + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null;
151 + return ExpenseBllDtoFactory.Create(expense);
152 + }
153 +
154 + public async Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId)
155 + {
156 + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId);
157 + if (expense == null) return null;
158 + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null;
159 + return ExpenseBllDtoFactory.Create(expense, includeSplits: true);
160 + }
161 +
162 + public async Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId)
163 + {
164 + var expense = await _uow.Expenses.GetByIdAsync(expenseId);
165 + return expense == null ? null : ExpenseBllDtoFactory.Create(expense);
166 + }
167 +
168 + public async Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId)
169 + {
170 + if (expense.PaidByUserId == userId) return true;
171 + return await _uow.TripParticipants.IsOrganizerAsync(expense.TripId, userId);
172 + }
173 +
174 + public async Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync(
175 + Guid id,
176 + decimal amount,
177 + string? description,
178 + DateTime expenseDate,
179 + ESplitMethod splitMethod,
180 + Guid? budgetCategoryId,
181 + Guid? currencyId,
182 + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits,
183 + Guid userId)
184 + {
185 + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(id);
186 + if (expense == null) return (false, "notfound");
187 +
188 + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(expense), userId))
189 + return (false, "forbidden");
190 +
191 + var trip = await _uow.Trips.GetByIdAsync(expense.TripId);
192 + if (trip != null && trip.Status != ETripStatus.Active)
193 + return (false, "badstatus");
194 +
195 + expense.BudgetCategoryId = budgetCategoryId;
196 + expense.CurrencyId = currencyId;
197 + expense.Amount = amount;
198 + expense.Description = description;
199 + expense.ExpenseDate = expenseDate;
200 + expense.SplitMethod = splitMethod;
201 +
202 + _uow.Expenses.Update(expense);
203 +
204 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
205 + if (expense.Splits != null)
206 + {
207 + foreach (var split in expense.Splits.ToList())
208 + {
209 + await splitRepo.RemoveAsync(split.Id);
210 + }
211 + }
212 +
213 + foreach (var s in splits)
214 + {
215 + splitRepo.Add(new ExpenseSplit
216 + {
217 + ExpenseId = expense.Id,
218 + UserId = s.UserId,
219 + Amount = s.Amount,
220 + Percentage = s.Percentage
221 + });
222 + }
223 +
224 + await _uow.SaveChangesAsync();
225 + return (true, null);
226 + }
227 +
228 + public async Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId)
229 + {
230 + var existing = await _uow.Expenses.GetByIdAsync(id);
231 + if (existing == null) return (false, "notfound");
232 +
233 + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(existing), userId))
234 + return (false, "forbidden");
235 +
236 + var trip = await _uow.Trips.GetByIdAsync(existing.TripId);
237 + if (trip != null && trip.Status != ETripStatus.Active)
238 + return (false, "badstatus");
239 +
240 + existing.Amount = incoming.Amount;
241 + existing.Description = incoming.Description;
242 + existing.ExpenseDate = incoming.ExpenseDate;
243 + existing.SplitMethod = incoming.SplitMethod;
244 + existing.BudgetCategoryId = incoming.BudgetCategoryId;
245 + existing.CurrencyId = incoming.CurrencyId;
246 + existing.PaidByUserId = incoming.PaidByUserId;
247 +
248 + _uow.Expenses.Update(existing);
249 +
250 + if (incoming.SplitMethod == ESplitMethod.EqualAll)
251 + {
252 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
253 + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(id);
254 + if (expenseWithSplits?.Splits != null)
255 + {
256 + foreach (var oldSplit in expenseWithSplits.Splits.ToList())
257 + {
258 + await splitRepo.RemoveAsync(oldSplit.Id);
259 + }
260 + }
261 +
262 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(existing.TripId)).ToList();
263 +
264 + var count = participants.Count;
265 + if (count > 0)
266 + {
267 + var baseAmount = Math.Floor(incoming.Amount / count * 100) / 100;
268 + var remainder = incoming.Amount - baseAmount * count;
269 +
270 + for (var i = 0; i < participants.Count; i++)
271 + {
272 + var amountPortion = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0);
273 + splitRepo.Add(new ExpenseSplit
274 + {
275 + Id = Guid.NewGuid(),
276 + ExpenseId = id,
277 + UserId = participants[i].UserId,
278 + Amount = amountPortion
279 + });
280 + }
281 + }
282 + }
283 +
284 + await _uow.SaveChangesAsync();
285 + return (true, null);
286 + }
287 +
288 + public async Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId)
289 + {
290 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
291 + return new List<SplitPresetBllDto>();
292 + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId);
293 + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true);
294 + }
295 +
296 + public async Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod,
297 + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId)
298 + {
299 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false;
300 +
301 + if (string.IsNullOrWhiteSpace(presetName) || selectedParticipants.Length == 0)
302 + return false;
303 +
304 + var preset = new SplitPreset
305 + {
306 + TripId = tripId,
307 + Name = presetName,
308 + SplitMethod = splitMethod,
309 + CreatedById = userId
310 + };
311 + _uow.SplitPresets.Add(preset);
312 +
313 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
314 + for (var i = 0; i < selectedParticipants.Length; i++)
315 + {
316 + memberRepo.Add(new SplitPresetMember
317 + {
318 + SplitPresetId = preset.Id,
319 + UserId = selectedParticipants[i],
320 + Percentage = splitPercentages.Length > i ? splitPercentages[i] : null
321 + });
322 + }
323 +
324 + await _uow.SaveChangesAsync();
325 + return true;
326 + }
327 +
328 + public async Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId)
329 + {
330 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false;
331 +
332 + var presets = (await _uow.SplitPresets.GetByTripIdAsync(tripId)).ToList();
333 + var preset = presets.FirstOrDefault(p => p.Id == presetId);
334 + if (preset == null) return false;
335 +
336 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
337 + if (preset.Members != null)
338 + {
339 + foreach (var member in preset.Members.ToList())
340 + {
341 + await memberRepo.RemoveAsync(member.Id);
342 + }
343 + }
344 + await _uow.SplitPresets.RemoveAsync(presetId);
345 + await _uow.SaveChangesAsync();
346 + return true;
347 + }
348 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IBudgetCategoryService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface IBudgetCategoryService
6 +{
7 + Task<List<BudgetCategoryBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
8 + Task<List<BudgetCategoryBllDto>> GetByTripIdRawAsync(Guid tripId); // no IDOR, used for dropdowns post-check
9 + Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id); // raw (no IDOR yet)
10 +
11 + Task<(BudgetCategoryBllDto? category, string? errorCode)> CreateAsync(BudgetCategoryBllDto category, Guid userId); // organizer only
12 + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, BudgetCategoryBllDto incoming, Guid userId); // organizer only
13 + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // organizer only
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IExpenseService.cs +44 −0
@@ -0,0 +1,44 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +
8 +namespace SplitApp.WebApp.Application.Services;
9 +
10 +public interface IExpenseService
11 +{
12 + Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages);
13 + Task DeleteExpenseWithSplitsAsync(Guid expenseId);
14 +
15 + // Queries (IDOR-aware)
16 + Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
17 + Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId); // participant-only
18 + Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId); // participant-only
19 + Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId); // no IDOR, raw entity
20 +
21 + // Edit authorization
22 + Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId);
23 +
24 + // API-level full update (re-populates splits from explicit DTO data)
25 + Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync(
26 + Guid id,
27 + decimal amount,
28 + string? description,
29 + DateTime expenseDate,
30 + ESplitMethod splitMethod,
31 + Guid? budgetCategoryId,
32 + Guid? currencyId,
33 + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits,
34 + Guid userId);
35 +
36 + // MVC-level partial update (re-splits for EqualAll only)
37 + Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId);
38 +
39 + // Split presets (used by MVC Expenses controller dropdowns)
40 + Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId);
41 + Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod,
42 + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId);
43 + Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId);
44 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IInvitationService.cs +30 −0
@@ -0,0 +1,30 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +
8 +namespace SplitApp.WebApp.Application.Services;
9 +
10 +public interface IInvitationService
11 +{
12 + Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId);
13 + Task<bool> AcceptInvitationAsync(string token, Guid userId);
14 +
15 + // Guarded variant: also checks organizer
16 + Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId);
17 +
18 + // Lookups
19 + Task<TripInvitationBllDto?> GetByIdAsync(Guid id);
20 + Task<TripInvitationBllDto?> GetByTokenAsync(string token);
21 + Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId);
22 +
23 + // Accept: full guarded flow (API)
24 + Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId);
25 +
26 + // Revoke & Decline
27 + Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId);
28 + Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId);
29 + Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token);
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IPollService.cs +27 −0
@@ -0,0 +1,27 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface IPollService
6 +{
7 + Task<TripPollBllDto> CreatePollWithOptionsAsync(TripPollBllDto poll, List<string> optionTexts);
8 + Task ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId);
9 + Task DeletePollCascadeAsync(Guid pollId);
10 +
11 + // Queries
12 + Task<List<TripPollBllDto>> GetByTripIdAsync(Guid tripId, Guid userId);
13 + Task<TripPollBllDto?> GetByIdAsync(Guid pollId, Guid userId);
14 + Task<TripPollBllDto?> GetByIdWithDetailsAsync(Guid pollId, Guid userId);
15 +
16 + // Guarded create (participant required)
17 + Task<(TripPollBllDto? poll, string? errorCode)> CreatePollGuardedAsync(TripPollBllDto poll, List<string> optionTexts, Guid userId);
18 +
19 + // Vote
20 + Task<(bool success, string? errorCode)> CastVoteAsync(Guid pollId, Guid optionId, Guid userId);
21 +
22 + // Close
23 + Task<(bool success, string? errorCode)> ClosePollAsync(Guid pollId, Guid userId, bool organizerAllowed);
24 +
25 + // Delete
26 + Task<(bool success, string? errorCode)> DeletePollGuardedAsync(Guid pollId, Guid userId);
27 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISettlementService.cs +46 −0
@@ -0,0 +1,46 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +
8 +namespace SplitApp.WebApp.Application.Services;
9 +
10 +public interface ISettlementService
11 +{
12 + Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId);
13 + Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId);
14 + List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balances);
15 + Task MarkPaidAsync(Guid paymentId, Guid userId);
16 + Task ConfirmPaymentAsync(Guid paymentId, Guid userId);
17 +
18 + // IDOR-protected queries
19 + Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId); // participant-only, empty if forbidden
20 + Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId); // participant-only
21 + Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId); // no IDOR (used internally after IDOR checked)
22 +
23 + // Payment lookups
24 + Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId);
25 + Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId);
26 +
27 + // Guarded mark/confirm that also validate trip participation
28 + Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId);
29 + Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId);
30 +}
31 +
32 +public class BalanceEntry
33 +{
34 + public Guid UserId { get; set; }
35 + public string UserName { get; set; } = default!;
36 + public decimal TotalPaid { get; set; }
37 + public decimal TotalOwed { get; set; }
38 + public decimal NetBalance => TotalPaid - TotalOwed;
39 +}
40 +
41 +public class PreviewPayment
42 +{
43 + public string FromUserName { get; set; } = default!;
44 + public string ToUserName { get; set; } = default!;
45 + public decimal Amount { get; set; }
46 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISplitPresetService.cs +20 −0
@@ -0,0 +1,20 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +
8 +namespace SplitApp.WebApp.Application.Services;
9 +
10 +public interface ISplitPresetService
11 +{
12 + Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
13 + Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId); // participant-only
14 +
15 + Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset,
16 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant
17 + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod,
18 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant
19 + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // participant
20 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ITripService.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface ITripService
6 +{
7 + Task<TripBllDto> CreateTripAsync(TripBllDto trip, Guid userId);
8 +
9 + // Queries
10 + Task<List<TripBllDto>> GetUserTripsAsync(Guid userId);
11 + Task<TripBllDto?> GetByIdAsync(Guid tripId, Guid userId); // IDOR: must be participant
12 + Task<TripBllDto?> GetByIdWithDetailsAsync(Guid tripId, Guid userId); // IDOR: must be participant
13 + Task<TripBllDto?> GetByIdForOrganizerAsync(Guid tripId, Guid userId); // IDOR: must be organizer
14 + Task<TripBllDto?> GetRawByIdAsync(Guid tripId); // no IDOR (used when invitation is valid)
15 +
16 + // Authorization helpers
17 + Task<bool> IsParticipantAsync(Guid tripId, Guid userId);
18 + Task<bool> IsOrganizerAsync(Guid tripId, Guid userId);
19 +
20 + // Mutations
21 + Task<TripBllDto?> UpdateAsync(TripBllDto trip, Guid userId); // null if not organizer
22 + Task<bool> DeleteAsync(Guid tripId, Guid userId); // false if not organizer
23 +
24 + // Participant queries/actions
25 + Task<List<TripParticipantBllDto>> GetParticipantsAsync(Guid tripId, Guid userId); // participant-only
26 + Task<List<TripParticipantBllDto>> GetParticipantsForIndexAsync(Guid tripId, Guid userId);
27 + Task<TripParticipantBllDto?> GetParticipantByIdAsync(Guid participantId);
28 +
29 + Task<(bool success, string? errorCode)> RemoveParticipantAsync(Guid tripId, Guid participantUserId, Guid currentUserId);
30 + Task<bool> RemoveParticipantByIdAsync(Guid tripId, Guid participantId, Guid currentUserId);
31 +
32 + // Trip lifecycle
33 + Task<(bool success, string? errorCode)> FinalizeTripAsync(Guid tripId, Guid userId);
34 + Task<(bool success, string? errorCode)> ReopenTripAsync(Guid tripId, Guid userId);
35 +
36 + // Dropdown data
37 + Task<List<CurrencyBllDto>> GetAllCurrenciesAsync();
38 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IWishlistService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface IWishlistService
6 +{
7 + Task<List<TripWishlistItemBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
8 + Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id, Guid userId); // participant-only
9 + Task<TripWishlistItemBllDto?> GetByIdRawAsync(Guid id); // no IDOR
10 +
11 + Task<(TripWishlistItemBllDto? item, string? errorCode)> CreateAsync(TripWishlistItemBllDto item, Guid userId); // participant
12 + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, TripWishlistItemBllDto incoming, Guid userId); // creator only
13 + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // creator only
14 + Task<(bool success, string? errorCode)> ToggleVoteAsync(Guid id, Guid userId); // participant
15 + Task<(bool success, string? errorCode)> ToggleCompleteAsync(Guid id, Guid userId); // participant
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IIdentityService.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.WebApp.Application.Services.Identity;
2 +
3 +public interface IIdentityService
4 +{
5 + Task<IdentityServiceResult> RegisterAsync(RegisterRequest request);
6 + Task<IdentityServiceResult> LoginAsync(LoginRequest request);
7 + Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request);
8 + Task<IdentityServiceResult> LogoutAsync(LogoutRequest request);
9 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityService.cs +266 −0
@@ -0,0 +1,266 @@
1 +using System.IdentityModel.Tokens.Jwt;
2 +using System.Security.Claims;
3 +using SplitApp.WebApp.Application.Contracts;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Auth;
6 +using Microsoft.AspNetCore.Identity;
7 +using Microsoft.Extensions.Configuration;
8 +
9 +namespace SplitApp.WebApp.Application.Services.Identity;
10 +
11 +public class IdentityService : IIdentityService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 + private readonly UserManager<AppUser> _userManager;
15 + private readonly IConfiguration _configuration;
16 +
17 + public IdentityService(
18 + IAppUnitOfWork uow,
19 + UserManager<AppUser> userManager,
20 + IConfiguration configuration)
21 + {
22 + _uow = uow;
23 + _userManager = userManager;
24 + _configuration = configuration;
25 + }
26 +
27 + public async Task<IdentityServiceResult> RegisterAsync(RegisterRequest request)
28 + {
29 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
30 +
31 + var existing = await _userManager.FindByEmailAsync(request.Email);
32 + if (existing != null)
33 + {
34 + return IdentityServiceResult.Fail(
35 + $"User with email {request.Email} is already registered",
36 + IdentityServiceErrorKind.BadRequest);
37 + }
38 +
39 + var refreshToken = new AppRefreshToken();
40 + var appUser = new AppUser
41 + {
42 + Email = request.Email,
43 + UserName = request.Email,
44 + FirstName = request.FirstName,
45 + LastName = request.LastName,
46 + RefreshTokens = new List<AppRefreshToken> { refreshToken }
47 + };
48 + refreshToken.AppUser = appUser;
49 +
50 + var createResult = await _userManager.CreateAsync(appUser, request.Password);
51 + if (!createResult.Succeeded)
52 + {
53 + return IdentityServiceResult.Fail(
54 + createResult.Errors.First().Description,
55 + IdentityServiceErrorKind.BadRequest);
56 + }
57 +
58 + await _userManager.AddToRoleAsync(appUser, "user");
59 +
60 + var claimsResult = await _userManager.AddClaimsAsync(appUser, new List<Claim>
61 + {
62 + new(ClaimTypes.GivenName, appUser.FirstName),
63 + new(ClaimTypes.Surname, appUser.LastName)
64 + });
65 + if (!claimsResult.Succeeded)
66 + {
67 + return IdentityServiceResult.Fail(
68 + claimsResult.Errors.First().Description,
69 + IdentityServiceErrorKind.BadRequest);
70 + }
71 +
72 + var reloaded = await _userManager.FindByEmailAsync(appUser.Email);
73 + if (reloaded == null)
74 + {
75 + return IdentityServiceResult.Fail(
76 + $"User with email {request.Email} is not found after registration",
77 + IdentityServiceErrorKind.BadRequest);
78 + }
79 +
80 + var jwt = await GenerateJwtAsync(reloaded, expiresInSeconds);
81 +
82 + return IdentityServiceResult.Ok(new IdentityJwtPayload
83 + {
84 + Jwt = jwt,
85 + RefreshToken = refreshToken.RefreshToken,
86 + FirstName = reloaded.FirstName,
87 + LastName = reloaded.LastName
88 + });
89 + }
90 +
91 + public async Task<IdentityServiceResult> LoginAsync(LoginRequest request)
92 + {
93 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
94 +
95 + var appUser = await _userManager.FindByEmailAsync(request.Email);
96 + if (appUser == null)
97 + {
98 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
99 + }
100 +
101 + var passwordOk = await _userManager.CheckPasswordAsync(appUser, request.Password);
102 + if (!passwordOk)
103 + {
104 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
105 + }
106 +
107 + await _uow.RefreshTokens.RemoveExpiredForUserAsync(appUser.Id);
108 +
109 + var refreshToken = new AppRefreshToken
110 + {
111 + AppUserId = appUser.Id
112 + };
113 + _uow.RefreshTokens.Add(refreshToken);
114 + await _uow.SaveChangesAsync();
115 +
116 + var jwt = await GenerateJwtAsync(appUser, expiresInSeconds);
117 +
118 + return IdentityServiceResult.Ok(new IdentityJwtPayload
119 + {
120 + Jwt = jwt,
121 + RefreshToken = refreshToken.RefreshToken,
122 + FirstName = appUser.FirstName,
123 + LastName = appUser.LastName
124 + });
125 + }
126 +
127 + public async Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request)
128 + {
129 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
130 +
131 + JwtSecurityToken? jwt;
132 + try
133 + {
134 + jwt = new JwtSecurityTokenHandler().ReadJwtToken(request.Jwt);
135 + }
136 + catch (Exception)
137 + {
138 + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest);
139 + }
140 +
141 + if (jwt == null)
142 + {
143 + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest);
144 + }
145 +
146 + if (!IdentityHelpers.ValidateJWT(
147 + request.Jwt,
148 + _configuration.GetValue<string>("JWT:Key")!,
149 + _configuration.GetValue<string>("JWT:Issuer")!,
150 + _configuration.GetValue<string>("JWT:Audience")!))
151 + {
152 + return IdentityServiceResult.Fail("JWT validation fail", IdentityServiceErrorKind.BadRequest);
153 + }
154 +
155 + var userEmail = jwt.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
156 + if (userEmail == null)
157 + {
158 + return IdentityServiceResult.Fail("No email in jwt", IdentityServiceErrorKind.BadRequest);
159 + }
160 +
161 + var appUser = await _userManager.FindByEmailAsync(userEmail);
162 + if (appUser == null)
163 + {
164 + return IdentityServiceResult.Fail($"User with email {userEmail} not found", IdentityServiceErrorKind.NotFound);
165 + }
166 +
167 + var matchingTokens = (await _uow.RefreshTokens
168 + .GetUserActiveTokensAsync(appUser.Id, request.RefreshToken)).ToList();
169 +
170 + if (matchingTokens.Count == 0)
171 + {
172 + return IdentityServiceResult.Fail(
173 + "RefreshTokens collection is null or empty - 0",
174 + IdentityServiceErrorKind.NotFound);
175 + }
176 +
177 + if (matchingTokens.Count != 1)
178 + {
179 + return IdentityServiceResult.Fail(
180 + "More than one valid refresh token found",
181 + IdentityServiceErrorKind.NotFound);
182 + }
183 +
184 + var refreshToken = matchingTokens.First();
185 + if (refreshToken.RefreshToken == request.RefreshToken)
186 + {
187 + refreshToken.PreviousRefreshToken = refreshToken.RefreshToken;
188 + refreshToken.PreviousExpirationDT = DateTime.UtcNow.AddMinutes(1);
189 + refreshToken.RefreshToken = Guid.NewGuid().ToString();
190 + refreshToken.ExpirationDT = DateTime.UtcNow.AddDays(7);
191 + _uow.RefreshTokens.Update(refreshToken);
192 + await _uow.SaveChangesAsync();
193 + }
194 +
195 + var newJwt = await GenerateJwtAsync(appUser, expiresInSeconds);
196 +
197 + return IdentityServiceResult.Ok(new IdentityJwtPayload
198 + {
199 + Jwt = newJwt,
200 + RefreshToken = refreshToken.RefreshToken,
201 + FirstName = appUser.FirstName,
202 + LastName = appUser.LastName
203 + });
204 + }
205 +
206 + public async Task<IdentityServiceResult> LogoutAsync(LogoutRequest request)
207 + {
208 + var appUser = await _uow.Users.GetByIdAsync(request.UserId);
209 + if (appUser == null)
210 + {
211 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
212 + }
213 +
214 + var tokens = (await _uow.RefreshTokens
215 + .GetUserTokensByValueAsync(request.UserId, request.RefreshToken)).ToList();
216 +
217 + foreach (var token in tokens)
218 + {
219 + _uow.RefreshTokens.Remove(token);
220 + }
221 +
222 + var deleteCount = await _uow.SaveChangesAsync();
223 + return IdentityServiceResult.Logout(deleteCount);
224 + }
225 +
226 + private int ResolveExpiresInSeconds(int requested)
227 + {
228 + if (requested <= 0) requested = int.MaxValue;
229 + var configured = _configuration.GetValue<int>("JWT:ExpiresInSeconds");
230 + return requested < configured ? requested : configured;
231 + }
232 +
233 + private async Task<string> GenerateJwtAsync(AppUser user, int expiresInSeconds)
234 + {
235 + var claims = new List<Claim>
236 + {
237 + new(ClaimTypes.NameIdentifier, user.Id.ToString()),
238 + new(ClaimTypes.Email, user.Email ?? ""),
239 + new(ClaimTypes.Name, user.UserName ?? user.Email ?? ""),
240 + new(ClaimTypes.GivenName, user.FirstName),
241 + new(ClaimTypes.Surname, user.LastName)
242 + };
243 +
244 + var userClaims = await _userManager.GetClaimsAsync(user);
245 + foreach (var c in userClaims)
246 + {
247 + if (!claims.Any(existing => existing.Type == c.Type && existing.Value == c.Value))
248 + {
249 + claims.Add(c);
250 + }
251 + }
252 +
253 + var roles = await _userManager.GetRolesAsync(user);
254 + foreach (var role in roles)
255 + {
256 + claims.Add(new Claim(ClaimTypes.Role, role));
257 + }
258 +
259 + return IdentityHelpers.GenerateJwt(
260 + claims,
261 + _configuration.GetValue<string>("JWT:Key")!,
262 + _configuration.GetValue<string>("JWT:Issuer")!,
263 + _configuration.GetValue<string>("JWT:Audience")!,
264 + expiresInSeconds);
265 + }
266 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityServiceResult.cs +63 −0
@@ -0,0 +1,63 @@
1 +namespace SplitApp.WebApp.Application.Services.Identity;
2 +
3 +public class IdentityServiceResult
4 +{
5 + public bool Success { get; init; }
6 + public string? Error { get; init; }
7 + public IdentityServiceErrorKind ErrorKind { get; init; } = IdentityServiceErrorKind.None;
8 + public IdentityJwtPayload? Payload { get; init; }
9 + public int? TokensDeleted { get; init; }
10 +
11 + public static IdentityServiceResult Ok(IdentityJwtPayload payload) =>
12 + new() { Success = true, Payload = payload };
13 +
14 + public static IdentityServiceResult Logout(int tokensDeleted) =>
15 + new() { Success = true, TokensDeleted = tokensDeleted };
16 +
17 + public static IdentityServiceResult Fail(string error, IdentityServiceErrorKind kind) =>
18 + new() { Success = false, Error = error, ErrorKind = kind };
19 +}
20 +
21 +public enum IdentityServiceErrorKind
22 +{
23 + None = 0,
24 + BadRequest = 400,
25 + NotFound = 404
26 +}
27 +
28 +public class IdentityJwtPayload
29 +{
30 + public string Jwt { get; init; } = default!;
31 + public string RefreshToken { get; init; } = default!;
32 + public string FirstName { get; init; } = default!;
33 + public string LastName { get; init; } = default!;
34 +}
35 +
36 +public class RegisterRequest
37 +{
38 + public string Email { get; init; } = default!;
39 + public string Password { get; init; } = default!;
40 + public string FirstName { get; init; } = default!;
41 + public string LastName { get; init; } = default!;
42 + public int ExpiresInSeconds { get; init; }
43 +}
44 +
45 +public class LoginRequest
46 +{
47 + public string Email { get; init; } = default!;
48 + public string Password { get; init; } = default!;
49 + public int ExpiresInSeconds { get; init; }
50 +}
51 +
52 +public class RefreshRequest
53 +{
54 + public string Jwt { get; init; } = default!;
55 + public string RefreshToken { get; init; } = default!;
56 + public int ExpiresInSeconds { get; init; }
57 +}
58 +
59 +public class LogoutRequest
60 +{
61 + public Guid UserId { get; init; }
62 + public string RefreshToken { get; init; } = default!;
63 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/InvitationService.cs +193 −0
@@ -0,0 +1,193 @@
1 +using System.Security.Cryptography;
2 +using SplitApp.WebApp.Application.DTO;
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 InvitationService : IInvitationService
14 +{
15 + private readonly IAppUnitOfWork _uow;
16 +
17 + public InvitationService(IAppUnitOfWork uow)
18 + {
19 + _uow = uow;
20 + }
21 +
22 + public async Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId)
23 + {
24 + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
25 + .Replace("+", "-").Replace("/", "_").TrimEnd('=');
26 +
27 + var invitation = new TripInvitation
28 + {
29 + Id = Guid.NewGuid(),
30 + TripId = tripId,
31 + InvitedByUserId = userId,
32 + Token = token,
33 + Status = EInvitationStatus.Pending,
34 + ExpiresAt = DateTime.UtcNow.AddDays(7)
35 + };
36 +
37 + _uow.TripInvitations.Add(invitation);
38 + await _uow.SaveChangesAsync();
39 +
40 + return InvitationBllDtoFactory.Create(invitation);
41 + }
42 +
43 + public async Task<bool> AcceptInvitationAsync(string token, Guid userId)
44 + {
45 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
46 + if (invitation == null) return false;
47 +
48 + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow)
49 + return false;
50 +
51 + // Check if already a participant
52 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(invitation.TripId)).ToList();
53 + var existingParticipant = participants.FirstOrDefault(tp => tp.UserId == userId);
54 +
55 + var allParticipants = await _uow.TripParticipants.GetAllAsync();
56 + var inactiveParticipant = allParticipants
57 + .FirstOrDefault(tp => tp.TripId == invitation.TripId && tp.UserId == userId && !tp.IsActive);
58 +
59 + if (existingParticipant != null)
60 + {
61 + // Already an active participant
62 + }
63 + else if (inactiveParticipant != null)
64 + {
65 + inactiveParticipant.IsActive = true;
66 + inactiveParticipant.LeftAt = null;
67 + _uow.TripParticipants.Update(inactiveParticipant);
68 + }
69 + else
70 + {
71 + var participant = new TripParticipant
72 + {
73 + Id = Guid.NewGuid(),
74 + TripId = invitation.TripId,
75 + UserId = userId,
76 + Role = EParticipantRole.Participant,
77 + JoinedAt = DateTime.UtcNow,
78 + IsActive = true
79 + };
80 + _uow.TripParticipants.Add(participant);
81 + }
82 +
83 + invitation.Status = EInvitationStatus.Accepted;
84 + invitation.RespondedAt = DateTime.UtcNow;
85 + _uow.TripInvitations.Update(invitation);
86 +
87 + await _uow.SaveChangesAsync();
88 +
89 + return true;
90 + }
91 +
92 + public async Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId)
93 + {
94 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
95 + return (null, "forbidden");
96 + var invitation = await CreateInvitationAsync(tripId, userId);
97 + return (invitation, null);
98 + }
99 +
100 + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id)
101 + {
102 + var entity = await _uow.TripInvitations.GetByIdAsync(id);
103 + return entity == null ? null : InvitationBllDtoFactory.Create(entity);
104 + }
105 +
106 + public async Task<TripInvitationBllDto?> GetByTokenAsync(string token)
107 + {
108 + var entity = await _uow.TripInvitations.GetByTokenAsync(token);
109 + return entity == null ? null : InvitationBllDtoFactory.Create(entity);
110 + }
111 +
112 + public async Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId)
113 + {
114 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
115 + return new List<TripInvitationBllDto>();
116 + var invitations = await _uow.TripInvitations.GetPendingByTripIdAsync(tripId);
117 + return InvitationBllDtoFactory.CreateList(invitations);
118 + }
119 +
120 + public async Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId)
121 + {
122 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
123 + if (invitation == null) return (false, "notfound");
124 +
125 + if (invitation.Status != EInvitationStatus.Pending)
126 + return (false, "not-pending");
127 +
128 + if (invitation.ExpiresAt < DateTime.UtcNow)
129 + {
130 + invitation.Status = EInvitationStatus.Expired;
131 + _uow.TripInvitations.Update(invitation);
132 + await _uow.SaveChangesAsync();
133 + return (false, "expired");
134 + }
135 +
136 + if (await _uow.TripParticipants.IsParticipantAsync(invitation.TripId, userId))
137 + return (false, "already-participant");
138 +
139 + var accepted = await AcceptInvitationAsync(token, userId);
140 + if (!accepted) return (false, "failed");
141 +
142 + return (true, null);
143 + }
144 +
145 + public async Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId)
146 + {
147 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
148 + return (false, "forbidden");
149 +
150 + var invitation = await _uow.TripInvitations.GetByIdAsync(invitationId);
151 + if (invitation == null || invitation.TripId != tripId) return (false, "notfound");
152 +
153 + if (invitation.Status == EInvitationStatus.Pending)
154 + {
155 + invitation.Status = EInvitationStatus.Revoked;
156 + invitation.RespondedAt = DateTime.UtcNow;
157 + _uow.TripInvitations.Update(invitation);
158 + await _uow.SaveChangesAsync();
159 + }
160 +
161 + return (true, null);
162 + }
163 +
164 + public async Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId)
165 + {
166 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
167 + if (invitation == null) return (false, "notfound");
168 +
169 + if (!await _uow.TripParticipants.IsOrganizerAsync(invitation.TripId, userId))
170 + return (false, "forbidden");
171 +
172 + invitation.Status = EInvitationStatus.Revoked;
173 + invitation.RespondedAt = DateTime.UtcNow;
174 + _uow.TripInvitations.Update(invitation);
175 + await _uow.SaveChangesAsync();
176 + return (true, null);
177 + }
178 +
179 + public async Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token)
180 + {
181 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
182 + if (invitation == null) return (false, "notfound");
183 +
184 + if (invitation.Status != EInvitationStatus.Pending)
185 + return (false, "not-pending");
186 +
187 + invitation.Status = EInvitationStatus.Declined;
188 + invitation.RespondedAt = DateTime.UtcNow;
189 + _uow.TripInvitations.Update(invitation);
190 + await _uow.SaveChangesAsync();
191 + return (true, null);
192 + }
193 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/PollService.cs +207 −0
@@ -0,0 +1,207 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class PollService : IPollService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public PollService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<TripPollBllDto> CreatePollWithOptionsAsync(TripPollBllDto poll, List<string> optionTexts)
22 + {
23 + var entity = PollBllDtoFactory.ToEntity(poll);
24 + entity.Id = Guid.NewGuid();
25 + _uow.TripPolls.Add(entity);
26 +
27 + var optionRepo = _uow.GetRepository<TripPollOption>();
28 + var order = 0;
29 +
30 + foreach (var text in optionTexts.Where(t => !string.IsNullOrWhiteSpace(t)))
31 + {
32 + optionRepo.Add(new TripPollOption
33 + {
34 + Id = Guid.NewGuid(),
35 + PollId = entity.Id,
36 + Text = text,
37 + DisplayOrder = order++
38 + });
39 + }
40 +
41 + await _uow.SaveChangesAsync();
42 +
43 + return PollBllDtoFactory.Create(entity);
44 + }
45 +
46 + public async Task ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId)
47 + {
48 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
49 + if (poll == null) return;
50 +
51 + if (poll.ClosedAt != null) return;
52 +
53 + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId);
54 + if (option == null) return;
55 +
56 + var voteRepo = _uow.GetRepository<TripPollVote>();
57 +
58 + if (!poll.AllowMultipleVotes)
59 + {
60 + if (poll.Options != null)
61 + {
62 + foreach (var opt in poll.Options)
63 + {
64 + if (opt.Votes != null)
65 + {
66 + foreach (var vote in opt.Votes.Where(v => v.UserId == userId).ToList())
67 + {
68 + await voteRepo.RemoveAsync(vote.Id);
69 + }
70 + }
71 + }
72 + }
73 + }
74 +
75 + var existingVote = option.Votes?.FirstOrDefault(v => v.UserId == userId);
76 +
77 + if (existingVote != null)
78 + {
79 + await voteRepo.RemoveAsync(existingVote.Id);
80 + }
81 + else
82 + {
83 + voteRepo.Add(new TripPollVote
84 + {
85 + Id = Guid.NewGuid(),
86 + PollOptionId = optionId,
87 + UserId = userId
88 + });
89 + }
90 +
91 + await _uow.SaveChangesAsync();
92 + }
93 +
94 + public async Task<List<TripPollBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
95 + {
96 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
97 + return new List<TripPollBllDto>();
98 + var polls = await _uow.TripPolls.GetByTripIdAsync(tripId);
99 + return PollBllDtoFactory.CreateList(polls, includeOptions: true);
100 + }
101 +
102 + public async Task<TripPollBllDto?> GetByIdAsync(Guid pollId, Guid userId)
103 + {
104 + var poll = await _uow.TripPolls.GetByIdAsync(pollId);
105 + if (poll == null) return null;
106 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null;
107 + return PollBllDtoFactory.Create(poll);
108 + }
109 +
110 + public async Task<TripPollBllDto?> GetByIdWithDetailsAsync(Guid pollId, Guid userId)
111 + {
112 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
113 + if (poll == null) return null;
114 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null;
115 + return PollBllDtoFactory.Create(poll, includeOptions: true);
116 + }
117 +
118 + public async Task<(TripPollBllDto? poll, string? errorCode)> CreatePollGuardedAsync(TripPollBllDto poll, List<string> optionTexts, Guid userId)
119 + {
120 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId))
121 + return (null, "forbidden");
122 + var created = await CreatePollWithOptionsAsync(poll, optionTexts);
123 + return (created, null);
124 + }
125 +
126 + public async Task<(bool success, string? errorCode)> CastVoteAsync(Guid pollId, Guid optionId, Guid userId)
127 + {
128 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
129 + if (poll == null) return (false, "notfound");
130 +
131 + if (poll.ClosedAt != null) return (false, "closed");
132 +
133 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId))
134 + return (false, "forbidden");
135 +
136 + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId);
137 + if (option == null) return (false, "invalid-option");
138 +
139 + await ToggleVoteAsync(pollId, optionId, userId);
140 + return (true, null);
141 + }
142 +
143 + public async Task<(bool success, string? errorCode)> ClosePollAsync(Guid pollId, Guid userId, bool organizerAllowed)
144 + {
145 + var poll = await _uow.TripPolls.GetByIdAsync(pollId);
146 + if (poll == null) return (false, "notfound");
147 +
148 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId))
149 + return (false, "forbidden");
150 +
151 + var isCreator = poll.CreatedByUserId == userId;
152 + var isOrganizer = organizerAllowed && await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId);
153 +
154 + if (!isCreator && !isOrganizer) return (false, "forbidden");
155 +
156 + poll.ClosedAt = DateTime.UtcNow;
157 + _uow.TripPolls.Update(poll);
158 + await _uow.SaveChangesAsync();
159 + return (true, null);
160 + }
161 +
162 + public async Task<(bool success, string? errorCode)> DeletePollGuardedAsync(Guid pollId, Guid userId)
163 + {
164 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
165 + if (poll == null) return (false, "notfound");
166 +
167 + var isCreator = poll.CreatedByUserId == userId;
168 + var isOrganizer = await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId);
169 +
170 + if (!isCreator && !isOrganizer) return (false, "forbidden");
171 +
172 + await DeletePollCascadeAsync(pollId);
173 + return (true, null);
174 + }
175 +
176 + public async Task DeletePollCascadeAsync(Guid pollId)
177 + {
178 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
179 + if (poll == null) return;
180 +
181 + var voteRepo = _uow.GetRepository<TripPollVote>();
182 + var optionRepo = _uow.GetRepository<TripPollOption>();
183 +
184 + if (poll.Options != null)
185 + {
186 + foreach (var option in poll.Options)
187 + {
188 + if (option.Votes != null)
189 + {
190 + foreach (var vote in option.Votes.ToList())
191 + {
192 + await voteRepo.RemoveAsync(vote.Id);
193 + }
194 + }
195 + }
196 +
197 + foreach (var option in poll.Options.ToList())
198 + {
199 + await optionRepo.RemoveAsync(option.Id);
200 + }
201 + }
202 +
203 + await _uow.TripPolls.RemoveAsync(pollId);
204 +
205 + await _uow.SaveChangesAsync();
206 + }
207 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SettlementService.cs +340 −0
@@ -0,0 +1,340 @@
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 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SplitPresetService.cs +124 −0
@@ -0,0 +1,124 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class SplitPresetService : ISplitPresetService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public SplitPresetService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
22 + {
23 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
24 + return new List<SplitPresetBllDto>();
25 + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId);
26 + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true);
27 + }
28 +
29 + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId)
30 + {
31 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
32 + if (preset == null) return null;
33 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) return null;
34 + return SplitPresetBllDtoFactory.Create(preset, includeMembers: true);
35 + }
36 +
37 + public async Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset,
38 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId)
39 + {
40 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId))
41 + return (null, "forbidden");
42 +
43 + var entity = SplitPresetBllDtoFactory.ToEntity(preset);
44 + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid();
45 + entity.CreatedById = userId;
46 + _uow.SplitPresets.Add(entity);
47 +
48 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
49 + foreach (var m in members)
50 + {
51 + memberRepo.Add(new SplitPresetMember
52 + {
53 + SplitPresetId = entity.Id,
54 + UserId = m.UserId,
55 + ShareWeight = m.ShareWeight,
56 + Percentage = m.Percentage
57 + });
58 + }
59 +
60 + await _uow.SaveChangesAsync();
61 +
62 + var reloaded = await _uow.SplitPresets.GetByIdAsync(entity.Id);
63 + return (reloaded == null ? null : SplitPresetBllDtoFactory.Create(reloaded, includeMembers: true), null);
64 + }
65 +
66 + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod,
67 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId)
68 + {
69 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
70 + if (preset == null) return (false, "notfound");
71 +
72 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId))
73 + return (false, "forbidden");
74 +
75 + preset.Name = name;
76 + preset.SplitMethod = splitMethod;
77 + _uow.SplitPresets.Update(preset);
78 +
79 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
80 + if (preset.Members != null)
81 + {
82 + foreach (var member in preset.Members.ToList())
83 + {
84 + await memberRepo.RemoveAsync(member.Id);
85 + }
86 + }
87 +
88 + foreach (var m in members)
89 + {
90 + memberRepo.Add(new SplitPresetMember
91 + {
92 + SplitPresetId = preset.Id,
93 + UserId = m.UserId,
94 + ShareWeight = m.ShareWeight,
95 + Percentage = m.Percentage
96 + });
97 + }
98 +
99 + await _uow.SaveChangesAsync();
100 + return (true, null);
101 + }
102 +
103 + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId)
104 + {
105 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
106 + if (preset == null) return (false, "notfound");
107 +
108 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId))
109 + return (false, "forbidden");
110 +
111 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
112 + if (preset.Members != null)
113 + {
114 + foreach (var member in preset.Members.ToList())
115 + {
116 + await memberRepo.RemoveAsync(member.Id);
117 + }
118 + }
119 +
120 + await _uow.SplitPresets.RemoveAsync(id);
121 + await _uow.SaveChangesAsync();
122 + return (true, null);
123 + }
124 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/TripService.cs +240 −0
@@ -0,0 +1,240 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class TripService : ITripService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 + private readonly ISettlementService _settlementService;
16 +
17 + public TripService(IAppUnitOfWork uow, ISettlementService settlementService)
18 + {
19 + _uow = uow;
20 + _settlementService = settlementService;
21 + }
22 +
23 + public async Task<TripBllDto> CreateTripAsync(TripBllDto trip, Guid userId)
24 + {
25 + var entity = TripBllDtoFactory.ToEntity(trip);
26 + entity.Id = Guid.NewGuid();
27 + entity.CreatedById = userId;
28 + entity.Status = ETripStatus.Active;
29 +
30 + _uow.Trips.Add(entity);
31 +
32 + var participant = new TripParticipant
33 + {
34 + Id = Guid.NewGuid(),
35 + TripId = entity.Id,
36 + UserId = userId,
37 + Role = EParticipantRole.Organizer,
38 + JoinedAt = DateTime.UtcNow,
39 + IsActive = true
40 + };
41 +
42 + _uow.TripParticipants.Add(participant);
43 +
44 + await _uow.SaveChangesAsync();
45 +
46 + return TripBllDtoFactory.Create(entity);
47 + }
48 +
49 + public async Task<List<TripBllDto>> GetUserTripsAsync(Guid userId)
50 + {
51 + var trips = await _uow.Trips.GetUserTripsAsync(userId);
52 + return TripBllDtoFactory.CreateList(trips, includeParticipants: true);
53 + }
54 +
55 + public async Task<TripBllDto?> GetByIdAsync(Guid tripId, Guid userId)
56 + {
57 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null;
58 + var trip = await _uow.Trips.GetByIdAsync(tripId);
59 + return trip == null ? null : TripBllDtoFactory.Create(trip);
60 + }
61 +
62 + public async Task<TripBllDto?> GetByIdWithDetailsAsync(Guid tripId, Guid userId)
63 + {
64 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null;
65 + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId);
66 + return trip == null ? null : TripBllDtoFactory.Create(trip, includeParticipants: true, includeExpenses: true);
67 + }
68 +
69 + public async Task<TripBllDto?> GetByIdForOrganizerAsync(Guid tripId, Guid userId)
70 + {
71 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return null;
72 + var trip = await _uow.Trips.GetByIdAsync(tripId);
73 + return trip == null ? null : TripBllDtoFactory.Create(trip);
74 + }
75 +
76 + public async Task<TripBllDto?> GetRawByIdAsync(Guid tripId)
77 + {
78 + var trip = await _uow.Trips.GetByIdAsync(tripId);
79 + return trip == null ? null : TripBllDtoFactory.Create(trip);
80 + }
81 +
82 + public Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
83 + => _uow.TripParticipants.IsParticipantAsync(tripId, userId);
84 +
85 + public Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
86 + => _uow.TripParticipants.IsOrganizerAsync(tripId, userId);
87 +
88 + public async Task<TripBllDto?> UpdateAsync(TripBllDto trip, Guid userId)
89 + {
90 + if (!await _uow.TripParticipants.IsOrganizerAsync(trip.Id, userId)) return null;
91 +
92 + var existing = await _uow.Trips.GetByIdAsync(trip.Id);
93 + if (existing == null) return null;
94 +
95 + existing.Name = trip.Name;
96 + existing.Description = trip.Description;
97 + existing.Destination = trip.Destination;
98 + existing.StartDate = trip.StartDate;
99 + existing.EndDate = trip.EndDate;
100 + existing.DefaultCurrencyId = trip.DefaultCurrencyId;
101 + existing.Status = trip.Status;
102 +
103 + _uow.Trips.Update(existing);
104 + await _uow.SaveChangesAsync();
105 + return TripBllDtoFactory.Create(existing);
106 + }
107 +
108 + public async Task<bool> DeleteAsync(Guid tripId, Guid userId)
109 + {
110 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return false;
111 +
112 + var trip = await _uow.Trips.GetByIdAsync(tripId);
113 + if (trip == null) return false;
114 +
115 + await _uow.Trips.RemoveAsync(tripId);
116 + await _uow.SaveChangesAsync();
117 + return true;
118 + }
119 +
120 + public async Task<List<TripParticipantBllDto>> GetParticipantsAsync(Guid tripId, Guid userId)
121 + {
122 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
123 + return new List<TripParticipantBllDto>();
124 + var participants = await _uow.TripParticipants.GetByTripIdAsync(tripId);
125 + return TripParticipantBllDtoFactory.CreateList(participants);
126 + }
127 +
128 + public Task<List<TripParticipantBllDto>> GetParticipantsForIndexAsync(Guid tripId, Guid userId)
129 + => GetParticipantsAsync(tripId, userId);
130 +
131 + public async Task<TripParticipantBllDto?> GetParticipantByIdAsync(Guid participantId)
132 + {
133 + var participant = await _uow.TripParticipants.GetByIdAsync(participantId);
134 + return participant == null ? null : TripParticipantBllDtoFactory.Create(participant);
135 + }
136 +
137 + public async Task<(bool success, string? errorCode)> RemoveParticipantAsync(Guid tripId, Guid participantUserId, Guid currentUserId)
138 + {
139 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId))
140 + return (false, "forbidden");
141 +
142 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList();
143 + var participant = participants.FirstOrDefault(tp => tp.UserId == participantUserId);
144 + if (participant == null) return (false, "notfound");
145 +
146 + if (participant.Role == EParticipantRole.Organizer)
147 + return (false, "organizer");
148 + if (participant.UserId == currentUserId)
149 + return (false, "self");
150 +
151 + participant.IsActive = false;
152 + participant.LeftAt = DateTime.UtcNow;
153 + _uow.TripParticipants.Update(participant);
154 + await _uow.SaveChangesAsync();
155 +
156 + return (true, null);
157 + }
158 +
159 + public async Task<bool> RemoveParticipantByIdAsync(Guid tripId, Guid participantId, Guid currentUserId)
160 + {
161 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId))
162 + return false;
163 +
164 + var participant = await _uow.TripParticipants.GetByIdAsync(participantId);
165 + if (participant == null || participant.TripId != tripId) return false;
166 +
167 + // Cannot remove yourself
168 + if (participant.UserId == currentUserId) return false;
169 +
170 + participant.IsActive = false;
171 + participant.LeftAt = DateTime.UtcNow;
172 + _uow.TripParticipants.Update(participant);
173 + await _uow.SaveChangesAsync();
174 +
175 + return true;
176 + }
177 +
178 + public async Task<(bool success, string? errorCode)> FinalizeTripAsync(Guid tripId, Guid userId)
179 + {
180 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
181 + return (false, "forbidden");
182 +
183 + var trip = await _uow.Trips.GetByIdAsync(tripId);
184 + if (trip == null) return (false, "notfound");
185 + if (trip.Status != ETripStatus.Active) return (false, "badstatus");
186 +
187 + trip.Status = ETripStatus.Finalizing;
188 + _uow.Trips.Update(trip);
189 + await _uow.SaveChangesAsync();
190 +
191 + // Auto-create settlement plan
192 + var createdPlan = await _settlementService.CalculateSettlementAsync(tripId, userId);
193 +
194 + // If nobody owes anyone, skip straight to Settled.
195 + if (createdPlan == null)
196 + {
197 + trip.Status = ETripStatus.Settled;
198 + await _uow.SaveChangesAsync();
199 + }
200 +
201 + return (true, null);
202 + }
203 +
204 + public async Task<(bool success, string? errorCode)> ReopenTripAsync(Guid tripId, Guid userId)
205 + {
206 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
207 + return (false, "forbidden");
208 +
209 + var trip = await _uow.Trips.GetByIdAsync(tripId);
210 + if (trip == null) return (false, "notfound");
211 + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled)
212 + return (false, "badstatus");
213 +
214 + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
215 + if (plan?.Payments != null && plan.Payments.Any(p => p.Status == EPaymentStatus.Confirmed))
216 + return (false, "payments-confirmed");
217 +
218 + if (plan != null)
219 + {
220 + await _uow.SettlementPlans.DeletePlanWithPaymentsAsync(plan.Id);
221 + }
222 +
223 + var tripToUpdate = await _uow.Trips.GetByIdAsync(tripId);
224 + if (tripToUpdate == null) return (false, "notfound");
225 + tripToUpdate.Status = ETripStatus.Active;
226 + _uow.Trips.Update(tripToUpdate);
227 + await _uow.SaveChangesAsync();
228 +
229 + return (true, null);
230 + }
231 +
232 + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync()
233 + {
234 + var currencies = await _uow.GetRepository<Currency>().GetAllAsync();
235 + return currencies
236 + .OrderBy(c => c.Code)
237 + .Select(CurrencyBllDtoFactory.Create)
238 + .ToList();
239 + }
240 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/WishlistService.cs +149 −0
@@ -0,0 +1,149 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class WishlistService : IWishlistService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public WishlistService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<List<TripWishlistItemBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
22 + {
23 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
24 + return new List<TripWishlistItemBllDto>();
25 + var items = await _uow.TripWishlistItems.GetByTripIdAsync(tripId);
26 + return WishlistBllDtoFactory.CreateList(items);
27 + }
28 +
29 + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id, Guid userId)
30 + {
31 + var item = await _uow.TripWishlistItems.GetByIdAsync(id);
32 + if (item == null) return null;
33 + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId)) return null;
34 + return WishlistBllDtoFactory.Create(item);
35 + }
36 +
37 + public async Task<TripWishlistItemBllDto?> GetByIdRawAsync(Guid id)
38 + {
39 + var item = await _uow.TripWishlistItems.GetByIdAsync(id);
40 + return item == null ? null : WishlistBllDtoFactory.Create(item);
41 + }
42 +
43 + public async Task<(TripWishlistItemBllDto? item, string? errorCode)> CreateAsync(TripWishlistItemBllDto item, Guid userId)
44 + {
45 + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId))
46 + return (null, "forbidden");
47 +
48 + var entity = WishlistBllDtoFactory.ToEntity(item);
49 + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid();
50 + entity.AddedByUserId = userId;
51 + _uow.TripWishlistItems.Add(entity);
52 + await _uow.SaveChangesAsync();
53 +
54 + var reloaded = await _uow.TripWishlistItems.GetByIdAsync(entity.Id);
55 + return (reloaded == null ? null : WishlistBllDtoFactory.Create(reloaded), null);
56 + }
57 +
58 + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, TripWishlistItemBllDto incoming, Guid userId)
59 + {
60 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
61 + if (existing == null) return (false, "notfound");
62 +
63 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
64 + return (false, "forbidden");
65 +
66 + if (existing.AddedByUserId != userId) return (false, "forbidden");
67 +
68 + existing.Title = incoming.Title;
69 + existing.Description = incoming.Description;
70 + existing.Category = incoming.Category;
71 + existing.Priority = incoming.Priority;
72 + existing.EstimatedCost = incoming.EstimatedCost;
73 + existing.Url = incoming.Url;
74 + existing.Location = incoming.Location;
75 +
76 + _uow.TripWishlistItems.Update(existing);
77 + await _uow.SaveChangesAsync();
78 + return (true, null);
79 + }
80 +
81 + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId)
82 + {
83 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
84 + if (existing == null) return (false, "notfound");
85 +
86 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
87 + return (false, "forbidden");
88 +
89 + if (existing.AddedByUserId != userId) return (false, "forbidden");
90 +
91 + var voteRepo = _uow.GetRepository<TripWishlistVote>();
92 + var allVotes = (await voteRepo.GetAllAsync()).Where(v => v.WishlistItemId == id).ToList();
93 + foreach (var vote in allVotes)
94 + {
95 + await voteRepo.RemoveAsync(vote.Id);
96 + }
97 +
98 + await _uow.TripWishlistItems.RemoveAsync(id);
99 + await _uow.SaveChangesAsync();
100 + return (true, null);
101 + }
102 +
103 + public async Task<(bool success, string? errorCode)> ToggleVoteAsync(Guid id, Guid userId)
104 + {
105 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
106 + if (existing == null) return (false, "notfound");
107 +
108 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
109 + return (false, "forbidden");
110 +
111 + var voteRepo = _uow.GetRepository<TripWishlistVote>();
112 + var allVotes = (await voteRepo.GetAllAsync()).ToList();
113 + var existingVote = allVotes.FirstOrDefault(v => v.WishlistItemId == id && v.UserId == userId);
114 +
115 + if (existingVote != null)
116 + {
117 + await voteRepo.RemoveAsync(existingVote.Id);
118 + }
119 + else
120 + {
121 + voteRepo.Add(new TripWishlistVote
122 + {
123 + Id = Guid.NewGuid(),
124 + WishlistItemId = id,
125 + UserId = userId,
126 + IsInterested = true
127 + });
128 + }
129 +
130 + await _uow.SaveChangesAsync();
131 + return (true, null);
132 + }
133 +
134 + public async Task<(bool success, string? errorCode)> ToggleCompleteAsync(Guid id, Guid userId)
135 + {
136 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
137 + if (existing == null) return (false, "notfound");
138 +
139 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
140 + return (false, "forbidden");
141 +
142 + existing.IsCompleted = !existing.IsCompleted;
143 + existing.CompletedAt = existing.IsCompleted ? DateTime.UtcNow : null;
144 +
145 + _uow.TripWishlistItems.Update(existing);
146 + await _uow.SaveChangesAsync();
147 + return (true, null);
148 + }
149 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +147 −0
@@ -0,0 +1,147 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.EntityFrameworkCore;
12 +using Microsoft.Extensions.Localization;
13 +using SplitApp.WebApp.Areas.Admin.Models;
14 +
15 +namespace SplitApp.WebApp.Areas.Admin.Controllers
16 +{
17 + [Area("Admin")]
18 + [Authorize(Roles = "admin")]
19 + public class BudgetCategoriesController : Controller
20 + {
21 + private readonly IBudgetCategoryAdminService _service;
22 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
23 +
24 + public BudgetCategoriesController(IBudgetCategoryAdminService service,
25 + IStringLocalizer<App.Resources.Views.Shared> l)
26 + {
27 + _service = service;
28 + _l = l;
29 + }
30 +
31 + public async Task<IActionResult> Index(Guid? tripId, string? search)
32 + {
33 + var vm = new AdminBudgetCategoryIndexViewModel
34 + {
35 + Title = _l["Budget Categories"].Value,
36 + Items = await _service.GetAllAsync(tripId, search),
37 + Trips = await _service.GetAllTripsAsync(),
38 + CurrentTripId = tripId,
39 + CurrentSearch = search
40 + };
41 + return View(vm);
42 + }
43 +
44 + public async Task<IActionResult> Details(Guid? id)
45 + {
46 + if (id == null) return NotFound();
47 + var entity = await _service.GetByIdAsync(id.Value);
48 + if (entity == null) return NotFound();
49 +
50 + return View(new AdminDetailsViewModel<BudgetCategoryBllDto>
51 + {
52 + Title = _l["Budget Category details"].Value,
53 + Item = entity
54 + });
55 + }
56 +
57 + public async Task<IActionResult> Create()
58 + {
59 + var vm = new AdminBudgetCategoryFormViewModel
60 + {
61 + Title = _l["New budget category"].Value,
62 + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name")
63 + };
64 + return View(vm);
65 + }
66 +
67 + [HttpPost]
68 + [ValidateAntiForgeryToken]
69 + public async Task<IActionResult> Create(AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt)
70 + {
71 + ModelState.Remove("BudgetCategory.Name");
72 +
73 + if (ModelState.IsValid)
74 + {
75 + await _service.CreateAsync(vm.BudgetCategory, nameEn, nameEt);
76 + return RedirectToAction(nameof(Index));
77 + }
78 +
79 + vm.Title = _l["New budget category"].Value;
80 + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId);
81 + return View(vm);
82 + }
83 +
84 + public async Task<IActionResult> Edit(Guid? id)
85 + {
86 + if (id == null) return NotFound();
87 + var entity = await _service.GetByIdAsync(id.Value);
88 + if (entity == null) return NotFound();
89 +
90 + var vm = new AdminBudgetCategoryFormViewModel
91 + {
92 + Title = _l["Edit budget category"].Value,
93 + BudgetCategory = entity,
94 + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", entity.TripId)
95 + };
96 + return View(vm);
97 + }
98 +
99 + [HttpPost]
100 + [ValidateAntiForgeryToken]
101 + public async Task<IActionResult> Edit(Guid id, AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt)
102 + {
103 + if (id != vm.BudgetCategory.Id) return NotFound();
104 +
105 + ModelState.Remove("BudgetCategory.Name");
106 +
107 + if (ModelState.IsValid)
108 + {
109 + try
110 + {
111 + await _service.UpdateAsync(vm.BudgetCategory, nameEn, nameEt);
112 + }
113 + catch (DbUpdateConcurrencyException)
114 + {
115 + if (!await _service.ExistsAsync(vm.BudgetCategory.Id)) return NotFound();
116 + throw;
117 + }
118 + return RedirectToAction(nameof(Index));
119 + }
120 +
121 + vm.Title = _l["Edit budget category"].Value;
122 + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId);
123 + return View(vm);
124 + }
125 +
126 + public async Task<IActionResult> Delete(Guid? id)
127 + {
128 + if (id == null) return NotFound();
129 + var entity = await _service.GetByIdAsync(id.Value);
130 + if (entity == null) return NotFound();
131 +
132 + return View(new AdminDeleteViewModel<BudgetCategoryBllDto>
133 + {
134 + Title = _l["Delete budget category"].Value,
135 + Item = entity
136 + });
137 + }
138 +
139 + [HttpPost, ActionName("Delete")]
140 + [ValidateAntiForgeryToken]
141 + public async Task<IActionResult> DeleteConfirmed(Guid id)
142 + {
143 + await _service.DeleteAsync(id);
144 + return RedirectToAction(nameof(Index));
145 + }
146 + }
147 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/CurrenciesController.cs +134 −0
@@ -0,0 +1,134 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.EntityFrameworkCore;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers
15 +{
16 + [Area("Admin")]
17 + [Authorize(Roles = "admin")]
18 + public class CurrenciesController : Controller
19 + {
20 + private readonly ICurrencyAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public CurrenciesController(ICurrencyAdminService service,
24 + IStringLocalizer<App.Resources.Views.Shared> l)
25 + {
26 + _service = service;
27 + _l = l;
28 + }
29 +
30 + public async Task<IActionResult> Index(string? search)
31 + {
32 + return View(new AdminCurrencyIndexViewModel
33 + {
34 + Title = _l["Currencies"].Value,
35 + Items = await _service.GetAllAsync(search),
36 + CurrentSearch = search
37 + });
38 + }
39 +
40 + public async Task<IActionResult> Details(Guid? id)
41 + {
42 + if (id == null) return NotFound();
43 + var currency = await _service.GetByIdAsync(id.Value);
44 + if (currency == null) return NotFound();
45 +
46 + return View(new AdminDetailsViewModel<CurrencyBllDto>
47 + {
48 + Title = _l["Currency details"].Value,
49 + Item = currency
50 + });
51 + }
52 +
53 + public IActionResult Create()
54 + {
55 + return View(new AdminCurrencyFormViewModel { Title = _l["New currency"].Value });
56 + }
57 +
58 + [HttpPost]
59 + [ValidateAntiForgeryToken]
60 + public async Task<IActionResult> Create(AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt)
61 + {
62 + ModelState.Remove("Currency.Name");
63 +
64 + if (ModelState.IsValid)
65 + {
66 + await _service.CreateAsync(vm.Currency, nameEn, nameEt);
67 + return RedirectToAction(nameof(Index));
68 + }
69 +
70 + vm.Title = _l["New currency"].Value;
71 + return View(vm);
72 + }
73 +
74 + public async Task<IActionResult> Edit(Guid? id)
75 + {
76 + if (id == null) return NotFound();
77 + var currency = await _service.GetByIdAsync(id.Value);
78 + if (currency == null) return NotFound();
79 +
80 + return View(new AdminCurrencyFormViewModel
81 + {
82 + Title = _l["Edit currency"].Value,
83 + Currency = currency
84 + });
85 + }
86 +
87 + [HttpPost]
88 + [ValidateAntiForgeryToken]
89 + public async Task<IActionResult> Edit(Guid id, AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt)
90 + {
91 + if (id != vm.Currency.Id) return NotFound();
92 +
93 + ModelState.Remove("Currency.Name");
94 +
95 + if (ModelState.IsValid)
96 + {
97 + try
98 + {
99 + await _service.UpdateAsync(vm.Currency, nameEn, nameEt);
100 + }
101 + catch (DbUpdateConcurrencyException)
102 + {
103 + if (!await _service.ExistsAsync(vm.Currency.Id)) return NotFound();
104 + throw;
105 + }
106 + return RedirectToAction(nameof(Index));
107 + }
108 +
109 + vm.Title = _l["Edit currency"].Value;
110 + return View(vm);
111 + }
112 +
113 + public async Task<IActionResult> Delete(Guid? id)
114 + {
115 + if (id == null) return NotFound();
116 + var currency = await _service.GetByIdAsync(id.Value);
117 + if (currency == null) return NotFound();
118 +
119 + return View(new AdminDeleteViewModel<CurrencyBllDto>
120 + {
121 + Title = _l["Delete currency"].Value,
122 + Item = currency
123 + });
124 + }
125 +
126 + [HttpPost, ActionName("Delete")]
127 + [ValidateAntiForgeryToken]
128 + public async Task<IActionResult> DeleteConfirmed(Guid id)
129 + {
130 + await _service.DeleteAsync(id);
131 + return RedirectToAction(nameof(Index));
132 + }
133 + }
134 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
@@ -0,0 +1,88 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using Microsoft.AspNetCore.Authorization;
4 +using Microsoft.AspNetCore.Mvc;
5 +using Microsoft.Extensions.Localization;
6 +using SplitApp.WebApp.Areas.Admin.Models;
7 +
8 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
9 +
10 +[Area("Admin")]
11 +[Authorize(Roles = "admin")]
12 +public class DashboardController : Controller
13 +{
14 + private readonly IAdminStatsService _statsService;
15 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
16 +
17 + public DashboardController(IAdminStatsService statsService,
18 + IStringLocalizer<App.Resources.Views.Shared> l)
19 + {
20 + _statsService = statsService;
21 + _l = l;
22 + }
23 +
24 + public async Task<IActionResult> Index()
25 + {
26 + var data = await _statsService.GetDashboardStatsAsync();
27 +
28 + var vm = new AdminDashboardViewModel
29 + {
30 + Title = _l["Dashboard"].Value,
31 +
32 + TripCount = data.TripCount,
33 + UserCount = data.UserCount,
34 + ExpenseCount = data.ExpenseCount,
35 + CategoryCount = data.CategoryCount,
36 + SettlementCount = data.SettlementCount,
37 + WishlistCount = data.WishlistCount,
38 + PollCount = data.PollCount,
39 + InvitationCount = data.InvitationCount,
40 + CurrencyCount = data.CurrencyCount,
41 + ParticipantCount = data.ParticipantCount,
42 +
43 + ActiveTrips = data.ActiveTrips,
44 + SettledTrips = data.SettledTrips,
45 + ArchivedTrips = data.ArchivedTrips,
46 + TotalExpenseAmount = data.TotalExpenseAmount,
47 + PendingSettlements = data.PendingSettlements,
48 + InProgressSettlements = data.InProgressSettlements,
49 + CompletedSettlements = data.CompletedSettlements,
50 + PendingInvitations = data.PendingInvitations,
51 + PendingPayments = data.PendingPayments,
52 + MarkedPaidPayments = data.MarkedPaidPayments,
53 +
54 + RecentTrips = data.RecentTrips,
55 + RecentExpenses = data.RecentExpenses,
56 + RecentUsers = data.RecentUsers,
57 +
58 + TopActiveTrips = data.TopActiveTrips.Select(t => new TopActiveTripItem
59 + {
60 + Trip = t.Trip,
61 + ParticipantCount = t.ParticipantCount,
62 + ExpenseSum = t.ExpenseSum,
63 + ExpenseCount = t.ExpenseCount
64 + }).ToList(),
65 + BiggestExpenses = data.BiggestExpenses,
66 + NewUsersLast7Days = data.NewUsersLast7Days,
67 + NewUsersLast30Days = data.NewUsersLast30Days,
68 + TopActiveUsers = data.TopActiveUsers.Select(u => new TopActiveUserItem
69 + {
70 + UserId = u.UserId,
71 + Email = u.Email,
72 + FullName = u.FullName,
73 + ExpenseCount = u.ExpenseCount,
74 + TotalAmount = u.TotalAmount
75 + }).ToList(),
76 + ActivityFeed = data.ActivityFeed.Select(a => new ActivityFeedItem
77 + {
78 + Type = a.Type,
79 + Message = _l[a.MessageKey, a.MessageArgs].Value,
80 + Date = a.Date,
81 + IconCssClass = a.IconCssClass,
82 + BadgeCssClass = a.BadgeCssClass
83 + }).ToList()
84 + };
85 +
86 + return View(vm);
87 + }
88 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/ExpensesController.cs +149 −0
@@ -0,0 +1,149 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.EntityFrameworkCore;
12 +using Microsoft.Extensions.Localization;
13 +using SplitApp.WebApp.Areas.Admin.Models;
14 +
15 +namespace SplitApp.WebApp.Areas.Admin.Controllers
16 +{
17 + [Area("Admin")]
18 + [Authorize(Roles = "admin")]
19 + public class ExpensesController : Controller
20 + {
21 + private readonly IExpenseAdminService _service;
22 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
23 +
24 + public ExpensesController(IExpenseAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
25 + {
26 + _service = service;
27 + _l = l;
28 + }
29 +
30 + private async Task PopulateSelectListsAsync(AdminExpenseFormViewModel vm)
31 + {
32 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Expense.TripId);
33 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Expense.PaidByUserId);
34 + vm.CurrencyList = new SelectList(await _service.GetCurrenciesAsync(), "Id", "Code", vm.Expense.CurrencyId);
35 + vm.BudgetCategoryList = new SelectList(await _service.GetBudgetCategoriesAsync(), "Id", "Name", vm.Expense.BudgetCategoryId);
36 + }
37 +
38 + public async Task<IActionResult> Index(Guid? tripId, string? search)
39 + {
40 + var expenses = await _service.GetAllAsync(tripId, search);
41 + var trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList();
42 +
43 + return View(new AdminExpenseIndexViewModel
44 + {
45 + Title = _l["Expenses"].Value,
46 + Items = expenses.OrderByDescending(e => e.ExpenseDate).ToList(),
47 + Trips = trips,
48 + CurrentTripId = tripId,
49 + CurrentSearch = search
50 + });
51 + }
52 +
53 + public async Task<IActionResult> Details(Guid? id)
54 + {
55 + if (id == null) return NotFound();
56 + var expense = await _service.GetByIdAsync(id.Value);
57 + if (expense == null) return NotFound();
58 +
59 + return View(new AdminDetailsViewModel<ExpenseBllDto>
60 + {
61 + Title = _l["Expense details"].Value,
62 + Item = expense
63 + });
64 + }
65 +
66 + public async Task<IActionResult> Create()
67 + {
68 + var vm = new AdminExpenseFormViewModel { Title = _l["New expense"].Value };
69 + await PopulateSelectListsAsync(vm);
70 + return View(vm);
71 + }
72 +
73 + [HttpPost]
74 + [ValidateAntiForgeryToken]
75 + public async Task<IActionResult> Create(AdminExpenseFormViewModel vm)
76 + {
77 + if (ModelState.IsValid)
78 + {
79 + await _service.CreateAsync(vm.Expense);
80 + return RedirectToAction(nameof(Index));
81 + }
82 +
83 + vm.Title = _l["New expense"].Value;
84 + await PopulateSelectListsAsync(vm);
85 + return View(vm);
86 + }
87 +
88 + public async Task<IActionResult> Edit(Guid? id)
89 + {
90 + if (id == null) return NotFound();
91 + var expense = await _service.GetByIdAsync(id.Value);
92 + if (expense == null) return NotFound();
93 +
94 + var vm = new AdminExpenseFormViewModel
95 + {
96 + Title = _l["Edit expense"].Value,
97 + Expense = expense
98 + };
99 + await PopulateSelectListsAsync(vm);
100 + return View(vm);
101 + }
102 +
103 + [HttpPost]
104 + [ValidateAntiForgeryToken]
105 + public async Task<IActionResult> Edit(Guid id, AdminExpenseFormViewModel vm)
106 + {
107 + if (id != vm.Expense.Id) return NotFound();
108 +
109 + if (ModelState.IsValid)
110 + {
111 + try
112 + {
113 + await _service.UpdateAsync(vm.Expense);
114 + }
115 + catch (DbUpdateConcurrencyException)
116 + {
117 + if (!await _service.ExistsAsync(vm.Expense.Id)) return NotFound();
118 + throw;
119 + }
120 + return RedirectToAction(nameof(Index));
121 + }
122 +
123 + vm.Title = _l["Edit expense"].Value;
124 + await PopulateSelectListsAsync(vm);
125 + return View(vm);
126 + }
127 +
128 + public async Task<IActionResult> Delete(Guid? id)
129 + {
130 + if (id == null) return NotFound();
131 + var expense = await _service.GetByIdAsync(id.Value);
132 + if (expense == null) return NotFound();
133 +
134 + return View(new AdminDeleteViewModel<ExpenseBllDto>
135 + {
136 + Title = _l["Delete expense"].Value,
137 + Item = expense
138 + });
139 + }
140 +
141 + [HttpPost, ActionName("Delete")]
142 + [ValidateAntiForgeryToken]
143 + public async Task<IActionResult> DeleteConfirmed(Guid id)
144 + {
145 + await _service.DeleteAsync(id);
146 + return RedirectToAction(nameof(Index));
147 + }
148 + }
149 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/InvitationsController.cs +158 −0
@@ -0,0 +1,158 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.Extensions.Localization;
11 +using SplitApp.WebApp.Areas.Admin.Models;
12 +
13 +namespace SplitApp.WebApp.Areas.Admin.Controllers
14 +{
15 + [Area("Admin")]
16 + [Authorize(Roles = "admin")]
17 + public class InvitationsController : Controller
18 + {
19 + private readonly IInvitationAdminService _service;
20 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
21 +
22 + public InvitationsController(IInvitationAdminService service,
23 + IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + // GET: Admin/Invitations
30 + public async Task<IActionResult> Index(string? search)
31 + {
32 + var invitations = await _service.GetAllAsync(search);
33 +
34 + var vm = new AdminInvitationIndexViewModel
35 + {
36 + Title = _l["Invitations"].Value,
37 + Items = invitations.OrderByDescending(i => i.Id).ToList(),
38 + CurrentSearch = search
39 + };
40 + return View(vm);
41 + }
42 +
43 + // GET: Admin/Invitations/Details/5
44 + public async Task<IActionResult> Details(Guid? id)
45 + {
46 + if (id == null)
47 + {
48 + return NotFound();
49 + }
50 +
51 + var tripInvitation = await _service.GetByIdAsync(id.Value);
52 + if (tripInvitation == null)
53 + {
54 + return NotFound();
55 + }
56 +
57 + return View(new AdminDetailsViewModel<TripInvitationBllDto>
58 + {
59 + Title = _l["Invitation details"].Value,
60 + Item = tripInvitation
61 + });
62 + }
63 +
64 + public async Task<IActionResult> Create()
65 + {
66 + return View(new AdminInvitationFormViewModel
67 + {
68 + Title = _l["New invitation"].Value,
69 + Invitation = new TripInvitationBllDto
70 + {
71 + ExpiresAt = DateTime.UtcNow.AddDays(7),
72 + Status = EInvitationStatus.Pending
73 + },
74 + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"),
75 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email")
76 + });
77 + }
78 +
79 + [HttpPost]
80 + [ValidateAntiForgeryToken]
81 + public async Task<IActionResult> Create(AdminInvitationFormViewModel vm)
82 + {
83 + if (ModelState.IsValid)
84 + {
85 + await _service.CreateAsync(vm.Invitation);
86 + return RedirectToAction(nameof(Index));
87 + }
88 +
89 + vm.Title = _l["New invitation"].Value;
90 + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId);
91 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId);
92 + return View(vm);
93 + }
94 +
95 + public async Task<IActionResult> Edit(Guid? id)
96 + {
97 + if (id == null) return NotFound();
98 + var invitation = await _service.GetByIdAsync(id.Value);
99 + if (invitation == null) return NotFound();
100 +
101 + return View(new AdminInvitationFormViewModel
102 + {
103 + Title = _l["Edit invitation"].Value,
104 + Invitation = invitation,
105 + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", invitation.TripId),
106 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", invitation.InvitedByUserId)
107 + });
108 + }
109 +
110 + [HttpPost]
111 + [ValidateAntiForgeryToken]
112 + public async Task<IActionResult> Edit(Guid id, AdminInvitationFormViewModel vm)
113 + {
114 + if (id != vm.Invitation.Id) return NotFound();
115 +
116 + if (ModelState.IsValid)
117 + {
118 + await _service.UpdateAsync(vm.Invitation);
119 + return RedirectToAction(nameof(Index));
120 + }
121 +
122 + vm.Title = _l["Edit invitation"].Value;
123 + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId);
124 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId);
125 + return View(vm);
126 + }
127 +
128 + // GET: Admin/Invitations/Delete/5
129 + public async Task<IActionResult> Delete(Guid? id)
130 + {
131 + if (id == null)
132 + {
133 + return NotFound();
134 + }
135 +
136 + var tripInvitation = await _service.GetByIdAsync(id.Value);
137 + if (tripInvitation == null)
138 + {
139 + return NotFound();
140 + }
141 +
142 + return View(new AdminDeleteViewModel<TripInvitationBllDto>
143 + {
144 + Title = _l["Delete invitation"].Value,
145 + Item = tripInvitation
146 + });
147 + }
148 +
149 + // POST: Admin/Invitations/Delete/5
150 + [HttpPost, ActionName("Delete")]
151 + [ValidateAntiForgeryToken]
152 + public async Task<IActionResult> DeleteConfirmed(Guid id)
153 + {
154 + await _service.DeleteAsync(id);
155 + return RedirectToAction(nameof(Index));
156 + }
157 + }
158 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/PollsController.cs +129 −0
@@ -0,0 +1,129 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
15 +
16 +[Area("Admin")]
17 +[Authorize(Roles = "admin")]
18 +public class PollsController : Controller
19 +{
20 + private readonly IPollAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public PollsController(IPollAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + var polls = await _service.GetAllAsync(search);
32 +
33 + var vm = new AdminPollIndexViewModel
34 + {
35 + Title = _l["Polls"].Value,
36 + Items = polls.OrderByDescending(p => p.Id).ToList(),
37 + CurrentSearch = search
38 + };
39 + return View(vm);
40 + }
41 +
42 + public async Task<IActionResult> Details(Guid id)
43 + {
44 + var poll = await _service.GetByIdAsync(id);
45 + if (poll == null) return NotFound();
46 +
47 + return View(new AdminDetailsViewModel<TripPollBllDto>
48 + {
49 + Title = _l["Poll details"].Value,
50 + Item = poll
51 + });
52 + }
53 +
54 + public async Task<IActionResult> Create()
55 + {
56 + var vm = new AdminPollFormViewModel
57 + {
58 + Title = _l["New poll"].Value,
59 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
60 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
61 + };
62 + return View(vm);
63 + }
64 +
65 + [HttpPost]
66 + [ValidateAntiForgeryToken]
67 + public async Task<IActionResult> Create(AdminPollFormViewModel vm)
68 + {
69 + if (ModelState.IsValid)
70 + {
71 + await _service.CreateAsync(vm.Poll);
72 + return RedirectToAction(nameof(Index));
73 + }
74 + vm.Title = _l["New poll"].Value;
75 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
76 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
77 + return View(vm);
78 + }
79 +
80 + public async Task<IActionResult> Edit(Guid id)
81 + {
82 + var poll = await _service.GetByIdAsync(id);
83 + if (poll == null) return NotFound();
84 + var vm = new AdminPollFormViewModel
85 + {
86 + Title = _l["Edit poll"].Value,
87 + Poll = poll,
88 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
89 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
90 + };
91 + return View(vm);
92 + }
93 +
94 + [HttpPost]
95 + [ValidateAntiForgeryToken]
96 + public async Task<IActionResult> Edit(Guid id, AdminPollFormViewModel vm)
97 + {
98 + if (id != vm.Poll.Id) return NotFound();
99 + if (ModelState.IsValid)
100 + {
101 + await _service.UpdateAsync(vm.Poll);
102 + return RedirectToAction(nameof(Index));
103 + }
104 + vm.Title = _l["Edit poll"].Value;
105 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
106 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
107 + return View(vm);
108 + }
109 +
110 + public async Task<IActionResult> Delete(Guid id)
111 + {
112 + var poll = await _service.GetByIdAsync(id);
113 + if (poll == null) return NotFound();
114 +
115 + return View(new AdminDeleteViewModel<TripPollBllDto>
116 + {
117 + Title = _l["Delete poll"].Value,
118 + Item = poll
119 + });
120 + }
121 +
122 + [HttpPost, ActionName("Delete")]
123 + [ValidateAntiForgeryToken]
124 + public async Task<IActionResult> DeleteConfirmed(Guid id)
125 + {
126 + await _service.DeleteAsync(id);
127 + return RedirectToAction(nameof(Index));
128 + }
129 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +153 −0
@@ -0,0 +1,153 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.Extensions.Localization;
11 +using SplitApp.WebApp.Areas.Admin.Models;
12 +
13 +namespace SplitApp.WebApp.Areas.Admin.Controllers
14 +{
15 + [Area("Admin")]
16 + [Authorize(Roles = "admin")]
17 + public class SettlementPaymentsController : Controller
18 + {
19 + private readonly ISettlementPaymentAdminService _service;
20 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
21 +
22 + public SettlementPaymentsController(ISettlementPaymentAdminService service,
23 + IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + // GET: Admin/SettlementPayments
30 + public async Task<IActionResult> Index(string? search)
31 + {
32 + var payments = await _service.GetAllAsync(search);
33 +
34 + var vm = new AdminSettlementPaymentIndexViewModel
35 + {
36 + Title = _l["Settlement payments"].Value,
37 + Items = payments.OrderByDescending(s => s.Id).ToList(),
38 + CurrentSearch = search
39 + };
40 + return View(vm);
41 + }
42 +
43 + // GET: Admin/SettlementPayments/Details/5
44 + public async Task<IActionResult> Details(Guid? id)
45 + {
46 + if (id == null)
47 + {
48 + return NotFound();
49 + }
50 +
51 + var settlementPayment = await _service.GetByIdAsync(id.Value);
52 + if (settlementPayment == null)
53 + {
54 + return NotFound();
55 + }
56 +
57 + return View(new AdminDetailsViewModel<SettlementPaymentBllDto>
58 + {
59 + Title = _l["Settlement payment details"].Value,
60 + Item = settlementPayment
61 + });
62 + }
63 +
64 + public async Task<IActionResult> Create()
65 + {
66 + return View(new AdminSettlementPaymentFormViewModel
67 + {
68 + Title = _l["New settlement payment"].Value,
69 + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id"),
70 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email")
71 + });
72 + }
73 +
74 + [HttpPost]
75 + [ValidateAntiForgeryToken]
76 + public async Task<IActionResult> Create(AdminSettlementPaymentFormViewModel vm)
77 + {
78 + if (ModelState.IsValid)
79 + {
80 + await _service.CreateAsync(vm.Payment);
81 + return RedirectToAction(nameof(Index));
82 + }
83 +
84 + vm.Title = _l["New settlement payment"].Value;
85 + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId);
86 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId);
87 + return View(vm);
88 + }
89 +
90 + public async Task<IActionResult> Edit(Guid? id)
91 + {
92 + if (id == null) return NotFound();
93 + var payment = await _service.GetByIdAsync(id.Value);
94 + if (payment == null) return NotFound();
95 +
96 + return View(new AdminSettlementPaymentFormViewModel
97 + {
98 + Title = _l["Edit settlement payment"].Value,
99 + Payment = payment,
100 + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", payment.SettlementPlanId),
101 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", payment.FromUserId)
102 + });
103 + }
104 +
105 + [HttpPost]
106 + [ValidateAntiForgeryToken]
107 + public async Task<IActionResult> Edit(Guid id, AdminSettlementPaymentFormViewModel vm)
108 + {
109 + if (id != vm.Payment.Id) return NotFound();
110 +
111 + if (ModelState.IsValid)
112 + {
113 + await _service.UpdateAsync(vm.Payment);
114 + return RedirectToAction(nameof(Index));
115 + }
116 +
117 + vm.Title = _l["Edit settlement payment"].Value;
118 + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId);
119 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId);
120 + return View(vm);
121 + }
122 +
123 + // GET: Admin/SettlementPayments/Delete/5
124 + public async Task<IActionResult> Delete(Guid? id)
125 + {
126 + if (id == null)
127 + {
128 + return NotFound();
129 + }
130 +
131 + var settlementPayment = await _service.GetByIdAsync(id.Value);
132 + if (settlementPayment == null)
133 + {
134 + return NotFound();
135 + }
136 +
137 + return View(new AdminDeleteViewModel<SettlementPaymentBllDto>
138 + {
139 + Title = _l["Delete settlement payment"].Value,
140 + Item = settlementPayment
141 + });
142 + }
143 +
144 + // POST: Admin/SettlementPayments/Delete/5
145 + [HttpPost, ActionName("Delete")]
146 + [ValidateAntiForgeryToken]
147 + public async Task<IActionResult> DeleteConfirmed(Guid id)
148 + {
149 + await _service.DeleteAsync(id);
150 + return RedirectToAction(nameof(Index));
151 + }
152 + }
153 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +187 −0
@@ -0,0 +1,187 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.EntityFrameworkCore;
12 +using Microsoft.Extensions.Localization;
13 +using SplitApp.WebApp.Areas.Admin.Models;
14 +
15 +namespace SplitApp.WebApp.Areas.Admin.Controllers
16 +{
17 + [Area("Admin")]
18 + [Authorize(Roles = "admin")]
19 + public class SettlementPlansController : Controller
20 + {
21 + private readonly ISettlementPlanAdminService _service;
22 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
23 +
24 + public SettlementPlansController(ISettlementPlanAdminService service,
25 + IStringLocalizer<App.Resources.Views.Shared> l)
26 + {
27 + _service = service;
28 + _l = l;
29 + }
30 +
31 + // GET: Admin/SettlementPlans
32 + public async Task<IActionResult> Index(Guid? tripId)
33 + {
34 + var plans = await _service.GetAllAsync(tripId);
35 +
36 + var vm = new AdminSettlementPlanIndexViewModel
37 + {
38 + Title = _l["Settlement plans"].Value,
39 + Items = plans.OrderByDescending(s => s.Id).ToList(),
40 + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(),
41 + CurrentTripId = tripId
42 + };
43 + return View(vm);
44 + }
45 +
46 + // GET: Admin/SettlementPlans/Details/5
47 + public async Task<IActionResult> Details(Guid? id)
48 + {
49 + if (id == null)
50 + {
51 + return NotFound();
52 + }
53 +
54 + var settlementPlan = await _service.GetByIdAsync(id.Value);
55 + if (settlementPlan == null)
56 + {
57 + return NotFound();
58 + }
59 +
60 + return View(new AdminDetailsViewModel<SettlementPlanBllDto>
61 + {
62 + Title = _l["Settlement plan details"].Value,
63 + Item = settlementPlan
64 + });
65 + }
66 +
67 + // GET: Admin/SettlementPlans/Create
68 + public async Task<IActionResult> Create()
69 + {
70 + var vm = new AdminSettlementPlanFormViewModel
71 + {
72 + Title = _l["New settlement plan"].Value,
73 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
74 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email")
75 + };
76 + return View(vm);
77 + }
78 +
79 + // POST: Admin/SettlementPlans/Create
80 + [HttpPost]
81 + [ValidateAntiForgeryToken]
82 + public async Task<IActionResult> Create(AdminSettlementPlanFormViewModel vm)
83 + {
84 + if (ModelState.IsValid)
85 + {
86 + await _service.CreateAsync(vm.SettlementPlan);
87 + return RedirectToAction(nameof(Index));
88 + }
89 +
90 + vm.Title = _l["New settlement plan"].Value;
91 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId);
92 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId);
93 + return View(vm);
94 + }
95 +
96 + // GET: Admin/SettlementPlans/Edit/5
97 + public async Task<IActionResult> Edit(Guid? id)
98 + {
99 + if (id == null)
100 + {
101 + return NotFound();
102 + }
103 +
104 + var settlementPlan = await _service.GetByIdAsync(id.Value);
105 + if (settlementPlan == null)
106 + {
107 + return NotFound();
108 + }
109 +
110 + var vm = new AdminSettlementPlanFormViewModel
111 + {
112 + Title = _l["Edit settlement plan"].Value,
113 + SettlementPlan = settlementPlan,
114 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", settlementPlan.TripId),
115 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", settlementPlan.CreatedByUserId)
116 + };
117 + return View(vm);
118 + }
119 +
120 + // POST: Admin/SettlementPlans/Edit/5
121 + [HttpPost]
122 + [ValidateAntiForgeryToken]
123 + public async Task<IActionResult> Edit(Guid id, AdminSettlementPlanFormViewModel vm)
124 + {
125 + if (id != vm.SettlementPlan.Id)
126 + {
127 + return NotFound();
128 + }
129 +
130 + if (ModelState.IsValid)
131 + {
132 + try
133 + {
134 + await _service.UpdateAsync(vm.SettlementPlan);
135 + }
136 + catch (DbUpdateConcurrencyException)
137 + {
138 + if (!await _service.ExistsAsync(vm.SettlementPlan.Id))
139 + {
140 + return NotFound();
141 + }
142 + else
143 + {
144 + throw;
145 + }
146 + }
147 +
148 + return RedirectToAction(nameof(Index));
149 + }
150 +
151 + vm.Title = _l["Edit settlement plan"].Value;
152 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId);
153 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId);
154 + return View(vm);
155 + }
156 +
157 + // GET: Admin/SettlementPlans/Delete/5
158 + public async Task<IActionResult> Delete(Guid? id)
159 + {
160 + if (id == null)
161 + {
162 + return NotFound();
163 + }
164 +
165 + var settlementPlan = await _service.GetByIdAsync(id.Value);
166 + if (settlementPlan == null)
167 + {
168 + return NotFound();
169 + }
170 +
171 + return View(new AdminDeleteViewModel<SettlementPlanBllDto>
172 + {
173 + Title = _l["Delete settlement plan"].Value,
174 + Item = settlementPlan
175 + });
176 + }
177 +
178 + // POST: Admin/SettlementPlans/Delete/5
179 + [HttpPost, ActionName("Delete")]
180 + [ValidateAntiForgeryToken]
181 + public async Task<IActionResult> DeleteConfirmed(Guid id)
182 + {
183 + await _service.DeleteAsync(id);
184 + return RedirectToAction(nameof(Index));
185 + }
186 + }
187 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +115 −0
@@ -0,0 +1,115 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.Extensions.Localization;
11 +using SplitApp.WebApp.Areas.Admin.Models;
12 +
13 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
14 +
15 +[Area("Admin")]
16 +[Authorize(Roles = "admin")]
17 +public class SplitPresetsController : Controller
18 +{
19 + private readonly ISplitPresetAdminService _service;
20 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
21 +
22 + public SplitPresetsController(ISplitPresetAdminService service,
23 + IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + var presets = await _service.GetAllAsync(search);
32 +
33 + var vm = new AdminSplitPresetIndexViewModel
34 + {
35 + Title = _l["Split presets"].Value,
36 + Items = presets.OrderByDescending(s => s.Id).ToList(),
37 + CurrentSearch = search
38 + };
39 + return View(vm);
40 + }
41 +
42 + public async Task<IActionResult> Details(Guid? id)
43 + {
44 + if (id == null)
45 + {
46 + return NotFound();
47 + }
48 +
49 + var splitPreset = await _service.GetByIdAsync(id.Value);
50 + if (splitPreset == null)
51 + {
52 + return NotFound();
53 + }
54 +
55 + return View(new AdminDetailsViewModel<SplitPresetBllDto>
56 + {
57 + Title = _l["Split preset details"].Value,
58 + Item = splitPreset
59 + });
60 + }
61 +
62 + public async Task<IActionResult> Create()
63 + {
64 + return View(new AdminSplitPresetFormViewModel
65 + {
66 + Title = _l["New split preset"].Value,
67 + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"),
68 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email")
69 + });
70 + }
71 +
72 + [HttpPost]
73 + [ValidateAntiForgeryToken]
74 + public async Task<IActionResult> Create(AdminSplitPresetFormViewModel vm)
75 + {
76 + if (ModelState.IsValid)
77 + {
78 + await _service.CreateAsync(vm.SplitPreset);
79 + return RedirectToAction(nameof(Index));
80 + }
81 +
82 + vm.Title = _l["New split preset"].Value;
83 + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SplitPreset.TripId);
84 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SplitPreset.CreatedById);
85 + return View(vm);
86 + }
87 +
88 + public async Task<IActionResult> Delete(Guid? id)
89 + {
90 + if (id == null)
91 + {
92 + return NotFound();
93 + }
94 +
95 + var splitPreset = await _service.GetByIdAsync(id.Value);
96 + if (splitPreset == null)
97 + {
98 + return NotFound();
99 + }
100 +
101 + return View(new AdminDeleteViewModel<SplitPresetBllDto>
102 + {
103 + Title = _l["Delete split preset"].Value,
104 + Item = splitPreset
105 + });
106 + }
107 +
108 + [HttpPost, ActionName("Delete")]
109 + [ValidateAntiForgeryToken]
110 + public async Task<IActionResult> DeleteConfirmed(Guid id)
111 + {
112 + await _service.DeleteAsync(id);
113 + return RedirectToAction(nameof(Index));
114 + }
115 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +188 −0
@@ -0,0 +1,188 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.EntityFrameworkCore;
12 +using Microsoft.Extensions.Localization;
13 +using SplitApp.WebApp.Areas.Admin.Models;
14 +
15 +namespace SplitApp.WebApp.Areas.Admin.Controllers
16 +{
17 + [Area("Admin")]
18 + [Authorize(Roles = "admin")]
19 + public class TripParticipantsController : Controller
20 + {
21 + private readonly ITripParticipantAdminService _service;
22 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
23 +
24 + public TripParticipantsController(ITripParticipantAdminService service,
25 + IStringLocalizer<App.Resources.Views.Shared> l)
26 + {
27 + _service = service;
28 + _l = l;
29 + }
30 +
31 + // GET: Admin/TripParticipants
32 + public async Task<IActionResult> Index(Guid? tripId, string? search)
33 + {
34 + var participants = await _service.GetAllAsync(tripId, search);
35 +
36 + var vm = new AdminTripParticipantIndexViewModel
37 + {
38 + Title = _l["Trip participants"].Value,
39 + Items = participants.OrderByDescending(tp => tp.JoinedAt).ToList(),
40 + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(),
41 + CurrentTripId = tripId,
42 + CurrentSearch = search
43 + };
44 + return View(vm);
45 + }
46 +
47 + // GET: Admin/TripParticipants/Details/5
48 + public async Task<IActionResult> Details(Guid? id)
49 + {
50 + if (id == null)
51 + {
52 + return NotFound();
53 + }
54 +
55 + var tripParticipant = await _service.GetByIdAsync(id.Value);
56 + if (tripParticipant == null)
57 + {
58 + return NotFound();
59 + }
60 +
61 + return View(new AdminDetailsViewModel<TripParticipantBllDto>
62 + {
63 + Title = _l["Trip participant details"].Value,
64 + Item = tripParticipant
65 + });
66 + }
67 +
68 + // GET: Admin/TripParticipants/Create
69 + public async Task<IActionResult> Create()
70 + {
71 + var vm = new AdminTripParticipantFormViewModel
72 + {
73 + Title = _l["New trip participant"].Value,
74 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
75 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email")
76 + };
77 + return View(vm);
78 + }
79 +
80 + // POST: Admin/TripParticipants/Create
81 + [HttpPost]
82 + [ValidateAntiForgeryToken]
83 + public async Task<IActionResult> Create(AdminTripParticipantFormViewModel vm)
84 + {
85 + if (ModelState.IsValid)
86 + {
87 + await _service.CreateAsync(vm.TripParticipant);
88 + return RedirectToAction(nameof(Index));
89 + }
90 +
91 + vm.Title = _l["New trip participant"].Value;
92 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId);
93 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId);
94 + return View(vm);
95 + }
96 +
97 + // GET: Admin/TripParticipants/Edit/5
98 + public async Task<IActionResult> Edit(Guid? id)
99 + {
100 + if (id == null)
101 + {
102 + return NotFound();
103 + }
104 +
105 + var tripParticipant = await _service.GetByIdAsync(id.Value);
106 + if (tripParticipant == null)
107 + {
108 + return NotFound();
109 + }
110 +
111 + var vm = new AdminTripParticipantFormViewModel
112 + {
113 + Title = _l["Edit trip participant"].Value,
114 + TripParticipant = tripParticipant,
115 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", tripParticipant.TripId),
116 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", tripParticipant.UserId)
117 + };
118 + return View(vm);
119 + }
120 +
121 + // POST: Admin/TripParticipants/Edit/5
122 + [HttpPost]
123 + [ValidateAntiForgeryToken]
124 + public async Task<IActionResult> Edit(Guid id, AdminTripParticipantFormViewModel vm)
125 + {
126 + if (id != vm.TripParticipant.Id)
127 + {
128 + return NotFound();
129 + }
130 +
131 + if (ModelState.IsValid)
132 + {
133 + try
134 + {
135 + await _service.UpdateAsync(vm.TripParticipant);
136 + }
137 + catch (DbUpdateConcurrencyException)
138 + {
139 + if (!await _service.ExistsAsync(vm.TripParticipant.Id))
140 + {
141 + return NotFound();
142 + }
143 + else
144 + {
145 + throw;
146 + }
147 + }
148 +
149 + return RedirectToAction(nameof(Index));
150 + }
151 +
152 + vm.Title = _l["Edit trip participant"].Value;
153 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId);
154 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId);
155 + return View(vm);
156 + }
157 +
158 + // GET: Admin/TripParticipants/Delete/5
159 + public async Task<IActionResult> Delete(Guid? id)
160 + {
161 + if (id == null)
162 + {
163 + return NotFound();
164 + }
165 +
166 + var tripParticipant = await _service.GetByIdAsync(id.Value);
167 + if (tripParticipant == null)
168 + {
169 + return NotFound();
170 + }
171 +
172 + return View(new AdminDeleteViewModel<TripParticipantBllDto>
173 + {
174 + Title = _l["Delete trip participant"].Value,
175 + Item = tripParticipant
176 + });
177 + }
178 +
179 + // POST: Admin/TripParticipants/Delete/5
180 + [HttpPost, ActionName("Delete")]
181 + [ValidateAntiForgeryToken]
182 + public async Task<IActionResult> DeleteConfirmed(Guid id)
183 + {
184 + await _service.DeleteAsync(id);
185 + return RedirectToAction(nameof(Index));
186 + }
187 + }
188 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripsController.cs +140 −0
@@ -0,0 +1,140 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.EntityFrameworkCore;
12 +using Microsoft.Extensions.Localization;
13 +using SplitApp.WebApp.Areas.Admin.Models;
14 +
15 +namespace SplitApp.WebApp.Areas.Admin.Controllers
16 +{
17 + [Area("Admin")]
18 + [Authorize(Roles = "admin")]
19 + public class TripsController : Controller
20 + {
21 + private readonly ITripAdminService _service;
22 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
23 +
24 + public TripsController(ITripAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
25 + {
26 + _service = service;
27 + _l = l;
28 + }
29 +
30 + public async Task<IActionResult> Index(string? search)
31 + {
32 + var trips = await _service.GetAllAsync(search);
33 +
34 + return View(new AdminTripIndexViewModel
35 + {
36 + Title = _l["Trips"].Value,
37 + Items = trips.OrderByDescending(t => t.CreatedAt).ToList(),
38 + CurrentSearch = search
39 + });
40 + }
41 +
42 + public async Task<IActionResult> Details(Guid? id)
43 + {
44 + if (id == null) return NotFound();
45 + var trip = await _service.GetByIdAsync(id.Value);
46 + if (trip == null) return NotFound();
47 +
48 + return View(new AdminDetailsViewModel<TripBllDto>
49 + {
50 + Title = _l["Trip details"].Value,
51 + Item = trip
52 + });
53 + }
54 +
55 + public async Task<IActionResult> Create()
56 + {
57 + return View(new AdminTripFormViewModel
58 + {
59 + Title = _l["New trip"].Value,
60 + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code")
61 + });
62 + }
63 +
64 + [HttpPost]
65 + [ValidateAntiForgeryToken]
66 + public async Task<IActionResult> Create(AdminTripFormViewModel vm)
67 + {
68 + if (ModelState.IsValid)
69 + {
70 + await _service.CreateAsync(vm.Trip);
71 + return RedirectToAction(nameof(Index));
72 + }
73 +
74 + vm.Title = _l["New trip"].Value;
75 + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId);
76 + return View(vm);
77 + }
78 +
79 + public async Task<IActionResult> Edit(Guid? id)
80 + {
81 + if (id == null) return NotFound();
82 + var trip = await _service.GetByIdAsync(id.Value);
83 + if (trip == null) return NotFound();
84 +
85 + return View(new AdminTripFormViewModel
86 + {
87 + Title = _l["Edit trip"].Value,
88 + Trip = trip,
89 + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", trip.DefaultCurrencyId)
90 + });
91 + }
92 +
93 + [HttpPost]
94 + [ValidateAntiForgeryToken]
95 + public async Task<IActionResult> Edit(Guid id, AdminTripFormViewModel vm)
96 + {
97 + if (id != vm.Trip.Id) return NotFound();
98 +
99 + if (ModelState.IsValid)
100 + {
101 + try
102 + {
103 + await _service.UpdateAsync(vm.Trip);
104 + }
105 + catch (DbUpdateConcurrencyException)
106 + {
107 + if (!await _service.ExistsAsync(vm.Trip.Id)) return NotFound();
108 + throw;
109 + }
110 +
111 + return RedirectToAction(nameof(Index));
112 + }
113 +
114 + vm.Title = _l["Edit trip"].Value;
115 + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId);
116 + return View(vm);
117 + }
118 +
119 + public async Task<IActionResult> Delete(Guid? id)
120 + {
121 + if (id == null) return NotFound();
122 + var trip = await _service.GetByIdAsync(id.Value);
123 + if (trip == null) return NotFound();
124 +
125 + return View(new AdminDeleteViewModel<TripBllDto>
126 + {
127 + Title = _l["Delete trip"].Value,
128 + Item = trip
129 + });
130 + }
131 +
132 + [HttpPost, ActionName("Delete")]
133 + [ValidateAntiForgeryToken]
134 + public async Task<IActionResult> DeleteConfirmed(Guid id)
135 + {
136 + await _service.DeleteAsync(id);
137 + return RedirectToAction(nameof(Index));
138 + }
139 + }
140 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
@@ -0,0 +1,197 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using Microsoft.AspNetCore.Authorization;
3 +using Microsoft.AspNetCore.Identity;
4 +using Microsoft.AspNetCore.Mvc;
5 +using Microsoft.EntityFrameworkCore;
6 +using Microsoft.Extensions.Localization;
7 +using SplitApp.WebApp.Areas.Admin.Models;
8 +
9 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
10 +
11 +[Area("Admin")]
12 +[Authorize(Roles = "admin")]
13 +public class UsersController : Controller
14 +{
15 + private readonly UserManager<AppUser> _userManager;
16 + private readonly RoleManager<AppRole> _roleManager;
17 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
18 +
19 + public UsersController(
20 + UserManager<AppUser> userManager,
21 + RoleManager<AppRole> roleManager,
22 + IStringLocalizer<App.Resources.Views.Shared> l)
23 + {
24 + _userManager = userManager;
25 + _roleManager = roleManager;
26 + _l = l;
27 + }
28 +
29 + public async Task<IActionResult> Index()
30 + {
31 + var users = await _userManager.Users.OrderBy(u => u.Email).ToListAsync();
32 + var userList = new List<AdminUserViewModel>();
33 +
34 + foreach (var user in users)
35 + {
36 + var roles = await _userManager.GetRolesAsync(user);
37 + userList.Add(new AdminUserViewModel
38 + {
39 + Id = user.Id,
40 + Email = user.Email!,
41 + FirstName = user.FirstName,
42 + LastName = user.LastName,
43 + Roles = roles.ToList()
44 + });
45 + }
46 +
47 + var vm = new AdminUserIndexViewModel
48 + {
49 + Title = _l["Users"].Value,
50 + Users = userList
51 + };
52 + return View(vm);
53 + }
54 +
55 + public async Task<IActionResult> EditRoles(Guid id)
56 + {
57 + var user = await _userManager.FindByIdAsync(id.ToString());
58 + if (user == null) return NotFound();
59 +
60 + var userRoles = await _userManager.GetRolesAsync(user);
61 + var allRoles = await _roleManager.Roles.OrderBy(r => r.Name).ToListAsync();
62 +
63 + var vm = new AdminEditRolesViewModel
64 + {
65 + Title = _l["Edit user roles"].Value,
66 + UserEmail = user.Email!,
67 + UserName = $"{user.FirstName} {user.LastName}",
68 + Roles = allRoles.Select(r => new RoleAssignmentViewModel
69 + {
70 + RoleName = r.Name!,
71 + IsAssigned = userRoles.Contains(r.Name!)
72 + }).ToList()
73 + };
74 + return View(vm);
75 + }
76 +
77 + [HttpPost]
78 + [ValidateAntiForgeryToken]
79 + public async Task<IActionResult> EditRoles(Guid id, AdminEditRolesViewModel vm)
80 + {
81 + var user = await _userManager.FindByIdAsync(id.ToString());
82 + if (user == null) return NotFound();
83 +
84 + var currentRoles = await _userManager.GetRolesAsync(user);
85 +
86 + foreach (var role in vm.Roles)
87 + {
88 + if (role.IsAssigned && !currentRoles.Contains(role.RoleName))
89 + {
90 + await _userManager.AddToRoleAsync(user, role.RoleName);
91 + }
92 + else if (!role.IsAssigned && currentRoles.Contains(role.RoleName))
93 + {
94 + await _userManager.RemoveFromRoleAsync(user, role.RoleName);
95 + }
96 + }
97 +
98 + return RedirectToAction(nameof(Index));
99 + }
100 +
101 + public async Task<IActionResult> Details(Guid id)
102 + {
103 + var user = await _userManager.FindByIdAsync(id.ToString());
104 + if (user == null) return NotFound();
105 +
106 + var roles = await _userManager.GetRolesAsync(user);
107 + var vm = new AdminUserDetailsViewModel
108 + {
109 + Title = _l["User details"].Value,
110 + Id = user.Id,
111 + Email = user.Email!,
112 + FirstName = user.FirstName,
113 + LastName = user.LastName,
114 + Roles = roles.ToList()
115 + };
116 + return View(vm);
117 + }
118 +
119 + public async Task<IActionResult> Edit(Guid id)
120 + {
121 + var user = await _userManager.FindByIdAsync(id.ToString());
122 + if (user == null) return NotFound();
123 +
124 + var vm = new AdminUserEditViewModel
125 + {
126 + Title = _l["Edit user"].Value,
127 + Id = user.Id,
128 + Email = user.Email!,
129 + FirstName = user.FirstName,
130 + LastName = user.LastName
131 + };
132 + return View(vm);
133 + }
134 +
135 + [HttpPost]
136 + [ValidateAntiForgeryToken]
137 + public async Task<IActionResult> Edit(Guid id, AdminUserEditViewModel vm)
138 + {
139 + if (id != vm.Id) return NotFound();
140 +
141 + if (!ModelState.IsValid)
142 + {
143 + vm.Title = _l["Edit user"].Value;
144 + return View(vm);
145 + }
146 +
147 + var user = await _userManager.FindByIdAsync(id.ToString());
148 + if (user == null) return NotFound();
149 +
150 + user.FirstName = vm.FirstName;
151 + user.LastName = vm.LastName;
152 +
153 + var result = await _userManager.UpdateAsync(user);
154 + if (!result.Succeeded)
155 + {
156 + foreach (var err in result.Errors)
157 + ModelState.AddModelError(string.Empty, err.Description);
158 + vm.Title = _l["Edit user"].Value;
159 + return View(vm);
160 + }
161 +
162 + return RedirectToAction(nameof(Index));
163 + }
164 +
165 + public async Task<IActionResult> Delete(Guid id)
166 + {
167 + var user = await _userManager.FindByIdAsync(id.ToString());
168 + if (user == null) return NotFound();
169 +
170 + var roles = await _userManager.GetRolesAsync(user);
171 + var vm = new AdminUserDetailsViewModel
172 + {
173 + Title = _l["Delete user"].Value,
174 + Id = user.Id,
175 + Email = user.Email!,
176 + FirstName = user.FirstName,
177 + LastName = user.LastName,
178 + Roles = roles.ToList()
179 + };
180 + return View(vm);
181 + }
182 +
183 + [HttpPost, ActionName("Delete")]
184 + [ValidateAntiForgeryToken]
185 + public async Task<IActionResult> DeleteConfirmed(Guid id)
186 + {
187 + var user = await _userManager.FindByIdAsync(id.ToString());
188 + if (user == null) return NotFound();
189 +
190 + var result = await _userManager.DeleteAsync(user);
191 + if (!result.Succeeded)
192 + {
193 + TempData["Error"] = string.Join("; ", result.Errors.Select(e => e.Description));
194 + }
195 + return RedirectToAction(nameof(Index));
196 + }
197 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/WishlistController.cs +129 −0
@@ -0,0 +1,129 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Mvc;
10 +using Microsoft.AspNetCore.Mvc.Rendering;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
15 +
16 +[Area("Admin")]
17 +[Authorize(Roles = "admin")]
18 +public class WishlistController : Controller
19 +{
20 + private readonly IWishlistAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public WishlistController(IWishlistAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + var items = await _service.GetAllAsync(search);
32 +
33 + var vm = new AdminWishlistIndexViewModel
34 + {
35 + Title = _l["Wishlist"].Value,
36 + Items = items.OrderByDescending(w => w.Id).ToList(),
37 + CurrentSearch = search
38 + };
39 + return View(vm);
40 + }
41 +
42 + public async Task<IActionResult> Details(Guid id)
43 + {
44 + var item = await _service.GetByIdAsync(id);
45 + if (item == null) return NotFound();
46 +
47 + return View(new AdminDetailsViewModel<TripWishlistItemBllDto>
48 + {
49 + Title = _l["Wishlist item details"].Value,
50 + Item = item
51 + });
52 + }
53 +
54 + public async Task<IActionResult> Create()
55 + {
56 + var vm = new AdminWishlistFormViewModel
57 + {
58 + Title = _l["New wishlist item"].Value,
59 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
60 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
61 + };
62 + return View(vm);
63 + }
64 +
65 + [HttpPost]
66 + [ValidateAntiForgeryToken]
67 + public async Task<IActionResult> Create(AdminWishlistFormViewModel vm)
68 + {
69 + if (ModelState.IsValid)
70 + {
71 + await _service.CreateAsync(vm.Item);
72 + return RedirectToAction(nameof(Index));
73 + }
74 + vm.Title = _l["New wishlist item"].Value;
75 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
76 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
77 + return View(vm);
78 + }
79 +
80 + public async Task<IActionResult> Edit(Guid id)
81 + {
82 + var item = await _service.GetByIdAsync(id);
83 + if (item == null) return NotFound();
84 + var vm = new AdminWishlistFormViewModel
85 + {
86 + Title = _l["Edit wishlist item"].Value,
87 + Item = item,
88 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
89 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
90 + };
91 + return View(vm);
92 + }
93 +
94 + [HttpPost]
95 + [ValidateAntiForgeryToken]
96 + public async Task<IActionResult> Edit(Guid id, AdminWishlistFormViewModel vm)
97 + {
98 + if (id != vm.Item.Id) return NotFound();
99 + if (ModelState.IsValid)
100 + {
101 + await _service.UpdateAsync(vm.Item);
102 + return RedirectToAction(nameof(Index));
103 + }
104 + vm.Title = _l["Edit wishlist item"].Value;
105 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
106 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
107 + return View(vm);
108 + }
109 +
110 + public async Task<IActionResult> Delete(Guid id)
111 + {
112 + var item = await _service.GetByIdAsync(id);
113 + if (item == null) return NotFound();
114 +
115 + return View(new AdminDeleteViewModel<TripWishlistItemBllDto>
116 + {
117 + Title = _l["Delete wishlist item"].Value,
118 + Item = item
119 + });
120 + }
121 +
122 + [HttpPost, ActionName("Delete")]
123 + [ValidateAntiForgeryToken]
124 + public async Task<IActionResult> DeleteConfirmed(Guid id)
125 + {
126 + await _service.DeleteAsync(id);
127 + return RedirectToAction(nameof(Index));
128 + }
129 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
@@ -0,0 +1,287 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using Microsoft.AspNetCore.Mvc.Rendering;
3 +
4 +namespace SplitApp.WebApp.Areas.Admin.Models;
5 +
6 +// === BASE ===
7 +
8 +public interface ITitledViewModel
9 +{
10 + string Title { get; set; }
11 +}
12 +
13 +public abstract class AdminPageViewModel : ITitledViewModel
14 +{
15 + public string Title { get; set; } = "";
16 +}
17 +
18 +// Generic wrappers used for Details/Delete views so domain entities
19 +// don't leak directly into views (keeps us strictly on ViewModels).
20 +public class AdminDetailsViewModel<T> : AdminPageViewModel where T : class
21 +{
22 + public T Item { get; set; } = default!;
23 +}
24 +
25 +public class AdminDeleteViewModel<T> : AdminPageViewModel where T : class
26 +{
27 + public T Item { get; set; } = default!;
28 +}
29 +
30 +// === INDEX VIEWMODELS ===
31 +
32 +public class AdminTripIndexViewModel : AdminPageViewModel
33 +{
34 + public List<TripBllDto> Items { get; set; } = new();
35 + public string? CurrentSearch { get; set; }
36 +}
37 +
38 +public class AdminExpenseIndexViewModel : AdminPageViewModel
39 +{
40 + public List<ExpenseBllDto> Items { get; set; } = new();
41 + public List<TripBllDto> Trips { get; set; } = new();
42 + public Guid? CurrentTripId { get; set; }
43 + public string? CurrentSearch { get; set; }
44 +}
45 +
46 +public class AdminBudgetCategoryIndexViewModel : AdminPageViewModel
47 +{
48 + public List<BudgetCategoryBllDto> Items { get; set; } = new();
49 + public List<TripBllDto> Trips { get; set; } = new();
50 + public Guid? CurrentTripId { get; set; }
51 + public string? CurrentSearch { get; set; }
52 +}
53 +
54 +public class AdminSettlementPlanIndexViewModel : AdminPageViewModel
55 +{
56 + public List<SettlementPlanBllDto> Items { get; set; } = new();
57 + public List<TripBllDto> Trips { get; set; } = new();
58 + public Guid? CurrentTripId { get; set; }
59 +}
60 +
61 +public class AdminTripParticipantIndexViewModel : AdminPageViewModel
62 +{
63 + public List<TripParticipantBllDto> Items { get; set; } = new();
64 + public List<TripBllDto> Trips { get; set; } = new();
65 + public Guid? CurrentTripId { get; set; }
66 + public string? CurrentSearch { get; set; }
67 +}
68 +
69 +public class AdminSettlementPaymentIndexViewModel : AdminPageViewModel
70 +{
71 + public List<SettlementPaymentBllDto> Items { get; set; } = new();
72 + public string? CurrentSearch { get; set; }
73 +}
74 +
75 +public class AdminPollIndexViewModel : AdminPageViewModel
76 +{
77 + public List<TripPollBllDto> Items { get; set; } = new();
78 + public string? CurrentSearch { get; set; }
79 +}
80 +
81 +public class AdminWishlistIndexViewModel : AdminPageViewModel
82 +{
83 + public List<TripWishlistItemBllDto> Items { get; set; } = new();
84 + public string? CurrentSearch { get; set; }
85 +}
86 +
87 +public class AdminInvitationIndexViewModel : AdminPageViewModel
88 +{
89 + public List<TripInvitationBllDto> Items { get; set; } = new();
90 + public string? CurrentSearch { get; set; }
91 +}
92 +
93 +public class AdminCurrencyIndexViewModel : AdminPageViewModel
94 +{
95 + public List<CurrencyBllDto> Items { get; set; } = new();
96 + public string? CurrentSearch { get; set; }
97 +}
98 +
99 +public class AdminSplitPresetIndexViewModel : AdminPageViewModel
100 +{
101 + public List<SplitPresetBllDto> Items { get; set; } = new();
102 + public string? CurrentSearch { get; set; }
103 +}
104 +
105 +public class AdminUserIndexViewModel : AdminPageViewModel
106 +{
107 + public List<AdminUserViewModel> Users { get; set; } = new();
108 +}
109 +
110 +// === FORM VIEWMODELS ===
111 +
112 +public class AdminTripFormViewModel : AdminPageViewModel
113 +{
114 + public TripBllDto Trip { get; set; } = new();
115 + public SelectList CurrencyList { get; set; } = default!;
116 +}
117 +
118 +public class AdminExpenseFormViewModel : AdminPageViewModel
119 +{
120 + public ExpenseBllDto Expense { get; set; } = new();
121 + public SelectList TripList { get; set; } = default!;
122 + public SelectList UserList { get; set; } = default!;
123 + public SelectList CurrencyList { get; set; } = default!;
124 + public SelectList BudgetCategoryList { get; set; } = default!;
125 +}
126 +
127 +public class AdminBudgetCategoryFormViewModel : AdminPageViewModel
128 +{
129 + public BudgetCategoryBllDto BudgetCategory { get; set; } = new();
130 + public SelectList TripList { get; set; } = default!;
131 +}
132 +
133 +public class AdminSettlementPlanFormViewModel : AdminPageViewModel
134 +{
135 + public SettlementPlanBllDto SettlementPlan { get; set; } = new();
136 + public SelectList TripList { get; set; } = default!;
137 + public SelectList UserList { get; set; } = default!;
138 +}
139 +
140 +public class AdminTripParticipantFormViewModel : AdminPageViewModel
141 +{
142 + public TripParticipantBllDto TripParticipant { get; set; } = new();
143 + public SelectList TripList { get; set; } = default!;
144 + public SelectList UserList { get; set; } = default!;
145 +}
146 +
147 +public class AdminPollFormViewModel : AdminPageViewModel
148 +{
149 + public TripPollBllDto Poll { get; set; } = new();
150 + public SelectList TripList { get; set; } = default!;
151 + public SelectList UserList { get; set; } = default!;
152 +}
153 +
154 +public class AdminWishlistFormViewModel : AdminPageViewModel
155 +{
156 + public TripWishlistItemBllDto Item { get; set; } = new();
157 + public SelectList TripList { get; set; } = default!;
158 + public SelectList UserList { get; set; } = default!;
159 +}
160 +
161 +public class AdminCurrencyFormViewModel : AdminPageViewModel
162 +{
163 + public CurrencyBllDto Currency { get; set; } = new();
164 +}
165 +
166 +public class AdminSplitPresetFormViewModel : AdminPageViewModel
167 +{
168 + public SplitPresetBllDto SplitPreset { get; set; } = new();
169 + public SelectList TripList { get; set; } = default!;
170 + public SelectList UserList { get; set; } = default!;
171 +}
172 +
173 +public class AdminInvitationFormViewModel : AdminPageViewModel
174 +{
175 + public TripInvitationBllDto Invitation { get; set; } = new();
176 + public SelectList TripList { get; set; } = default!;
177 + public SelectList UserList { get; set; } = default!;
178 +}
179 +
180 +public class AdminSettlementPaymentFormViewModel : AdminPageViewModel
181 +{
182 + public SettlementPaymentBllDto Payment { get; set; } = new();
183 + public SelectList SettlementPlanList { get; set; } = default!;
184 + public SelectList UserList { get; set; } = default!;
185 +}
186 +
187 +public class AdminEditRolesViewModel : AdminPageViewModel
188 +{
189 + public string UserEmail { get; set; } = default!;
190 + public string UserName { get; set; } = default!;
191 + public List<RoleAssignmentViewModel> Roles { get; set; } = new();
192 +}
193 +
194 +public class AdminUserDetailsViewModel : AdminPageViewModel
195 +{
196 + public Guid Id { get; set; }
197 + public string Email { get; set; } = default!;
198 + public string FirstName { get; set; } = default!;
199 + public string LastName { get; set; } = default!;
200 + public List<string> Roles { get; set; } = new();
201 +}
202 +
203 +public class AdminUserEditViewModel : AdminPageViewModel
204 +{
205 + public Guid Id { get; set; }
206 + public string Email { get; set; } = default!;
207 + public string FirstName { get; set; } = default!;
208 + public string LastName { get; set; } = default!;
209 +}
210 +
211 +// === DASHBOARD ===
212 +
213 +public class AdminDashboardViewModel : AdminPageViewModel
214 +{
215 + public int TripCount { get; set; }
216 + public int UserCount { get; set; }
217 + public int ExpenseCount { get; set; }
218 + public int CategoryCount { get; set; }
219 + public int SettlementCount { get; set; }
220 + public int WishlistCount { get; set; }
221 + public int PollCount { get; set; }
222 + public int InvitationCount { get; set; }
223 + public int CurrencyCount { get; set; }
224 + public int ParticipantCount { get; set; }
225 + public int ActiveTrips { get; set; }
226 + public int SettledTrips { get; set; }
227 + public int ArchivedTrips { get; set; }
228 + public decimal TotalExpenseAmount { get; set; }
229 + public int PendingSettlements { get; set; }
230 + public int InProgressSettlements { get; set; }
231 + public int CompletedSettlements { get; set; }
232 + public int PendingInvitations { get; set; }
233 + public int PendingPayments { get; set; }
234 + public int MarkedPaidPayments { get; set; }
235 + public List<TripBllDto> RecentTrips { get; set; } = new();
236 + public List<ExpenseBllDto> RecentExpenses { get; set; } = new();
237 + public List<AppUserBllDto> RecentUsers { get; set; } = new();
238 +
239 + // Extended stats
240 + public List<TopActiveTripItem> TopActiveTrips { get; set; } = new();
241 + public List<ExpenseBllDto> BiggestExpenses { get; set; } = new();
242 + public int NewUsersLast7Days { get; set; }
243 + public int NewUsersLast30Days { get; set; }
244 + public List<TopActiveUserItem> TopActiveUsers { get; set; } = new();
245 + public List<ActivityFeedItem> ActivityFeed { get; set; } = new();
246 +}
247 +
248 +public class TopActiveTripItem
249 +{
250 + public TripBllDto Trip { get; set; } = default!;
251 + public int ParticipantCount { get; set; }
252 + public decimal ExpenseSum { get; set; }
253 + public int ExpenseCount { get; set; }
254 +}
255 +
256 +public class TopActiveUserItem
257 +{
258 + public Guid UserId { get; set; }
259 + public string Email { get; set; } = "";
260 + public string FullName { get; set; } = "";
261 + public int ExpenseCount { get; set; }
262 + public decimal TotalAmount { get; set; }
263 +}
264 +
265 +public class ActivityFeedItem
266 +{
267 + public string Type { get; set; } = "";
268 + public string Message { get; set; } = "";
269 + public DateTime Date { get; set; }
270 + public string IconCssClass { get; set; } = "bi-circle";
271 + public string BadgeCssClass { get; set; } = "bg-secondary";
272 +}
273 +
274 +public class AdminUserViewModel
275 +{
276 + public Guid Id { get; set; }
277 + public string Email { get; set; } = default!;
278 + public string FirstName { get; set; } = default!;
279 + public string LastName { get; set; } = default!;
280 + public List<string> Roles { get; set; } = new();
281 +}
282 +
283 +public class RoleAssignmentViewModel
284 +{
285 + public string RoleName { get; set; } = default!;
286 + public bool IsAssigned { get; set; }
287 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
@@ -0,0 +1,55 @@
1 +@model AdminBudgetCategoryFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-tags-fill"></i> @Localizer["Create"] @Localizer["BudgetCategory"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["BudgetCategory"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="BudgetCategory.TripId" class="control-label"></label>
19 + <select asp-for="BudgetCategory.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="BudgetCategory.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label class="control-label">@Localizer["Name"] (EN)</label>
24 + <input name="nameEn" class="form-control" />
25 + </div>
26 + <div class="form-group mb-3">
27 + <label class="control-label">@Localizer["Name"] (ET)</label>
28 + <input name="nameEt" class="form-control" />
29 + </div>
30 + <div class="form-group mb-3">
31 + <label asp-for="BudgetCategory.IconName" class="control-label"></label>
32 + <input asp-for="BudgetCategory.IconName" class="form-control" />
33 + <span asp-validation-for="BudgetCategory.IconName" class="text-danger"></span>
34 + </div>
35 + <div class="form-group mb-3">
36 + <label asp-for="BudgetCategory.PlannedAmount" class="control-label"></label>
37 + <input asp-for="BudgetCategory.PlannedAmount" class="form-control" />
38 + <span asp-validation-for="BudgetCategory.PlannedAmount" class="text-danger"></span>
39 + </div>
40 + <div class="form-group mb-3">
41 + <label asp-for="BudgetCategory.DisplayOrder" class="control-label"></label>
42 + <input asp-for="BudgetCategory.DisplayOrder" class="form-control" />
43 + <span asp-validation-for="BudgetCategory.DisplayOrder" class="text-danger"></span>
44 + </div>
45 + <div class="form-group mb-3 d-flex gap-2">
46 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
47 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
48 + </div>
49 + </form>
50 + </div>
51 +</div>
52 +
53 +@section Scripts {
54 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
55 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +25 −0
@@ -0,0 +1,25 @@
1 +@model AdminDeleteViewModel<BudgetCategoryBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["BudgetCategory"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Name"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["PlannedAmount"]</dt>
17 + <dd class="col-sm-10">@Model.Item.PlannedAmount</dd>
18 + </dl>
19 +
20 + <form asp-action="Delete">
21 + <input type="hidden" asp-for="Item.Id" />
22 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
23 + <a asp-action="Index">@Localizer["Back"]</a>
24 + </form>
25 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDetailsViewModel<BudgetCategoryBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["BudgetCategory"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Name"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["IconName"]</dt>
16 + <dd class="col-sm-10">@Model.Item.IconName</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["PlannedAmount"]</dt>
19 + <dd class="col-sm-10">@Model.Item.PlannedAmount</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["DisplayOrder"]</dt>
22 + <dd class="col-sm-10">@Model.Item.DisplayOrder</dd>
23 + </dl>
24 +</div>
25 +<div>
26 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
27 + <a asp-action="Index">@Localizer["Back"]</a>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
@@ -0,0 +1,56 @@
1 +@model AdminBudgetCategoryFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-tags-fill"></i> @Localizer["Edit"] @Localizer["BudgetCategory"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["BudgetCategory"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="BudgetCategory.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="BudgetCategory.TripId" class="control-label"></label>
20 + <select asp-for="BudgetCategory.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="BudgetCategory.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label class="control-label">@Localizer["Name"] (EN)</label>
25 + <input name="nameEn" class="form-control" value="@(Model.BudgetCategory.Name.ContainsKey("en") ? Model.BudgetCategory.Name["en"] : "")" />
26 + </div>
27 + <div class="form-group mb-3">
28 + <label class="control-label">@Localizer["Name"] (ET)</label>
29 + <input name="nameEt" class="form-control" value="@(Model.BudgetCategory.Name.ContainsKey("et") ? Model.BudgetCategory.Name["et"] : "")" />
30 + </div>
31 + <div class="form-group mb-3">
32 + <label asp-for="BudgetCategory.IconName" class="control-label"></label>
33 + <input asp-for="BudgetCategory.IconName" class="form-control" />
34 + <span asp-validation-for="BudgetCategory.IconName" class="text-danger"></span>
35 + </div>
36 + <div class="form-group mb-3">
37 + <label asp-for="BudgetCategory.PlannedAmount" class="control-label"></label>
38 + <input asp-for="BudgetCategory.PlannedAmount" class="form-control" />
39 + <span asp-validation-for="BudgetCategory.PlannedAmount" class="text-danger"></span>
40 + </div>
41 + <div class="form-group mb-3">
42 + <label asp-for="BudgetCategory.DisplayOrder" class="control-label"></label>
43 + <input asp-for="BudgetCategory.DisplayOrder" class="form-control" />
44 + <span asp-validation-for="BudgetCategory.DisplayOrder" class="text-danger"></span>
45 + </div>
46 + <div class="form-group mb-3 d-flex gap-2">
47 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
48 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
49 + </div>
50 + </form>
51 + </div>
52 +</div>
53 +
54 +@section Scripts {
55 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
56 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +76 −0
@@ -0,0 +1,76 @@
1 +@model AdminBudgetCategoryIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-tags me-2"></i>@Localizer["BudgetCategories"]</h1>
5 + <p class="lead">@Localizer["Manage budget categories for trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
26 + </div>
27 + <div class="col-auto">
28 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
29 + </div>
30 + </form>
31 + </div>
32 +</div>
33 +
34 +<div class="admin-card">
35 + <div class="admin-card-body p-0">
36 + @if (Model.Items.Any())
37 + {
38 + <table class="table admin-table mb-0">
39 + <thead>
40 + <tr>
41 + <th>@Localizer["Name"]</th>
42 + <th>@Localizer["Trip"]</th>
43 + <th class="text-end">@Localizer["PlannedAmount"]</th>
44 + <th>@Localizer["DisplayOrder"]</th>
45 + <th class="text-end">@Localizer["Actions"]</th>
46 + </tr>
47 + </thead>
48 + <tbody>
49 + @foreach (var item in Model.Items)
50 + {
51 + <tr>
52 + <td>@item.Name</td>
53 + <td>@item.Trip?.Name</td>
54 + <td class="text-end fw-bold">@item.PlannedAmount</td>
55 + <td>@item.DisplayOrder</td>
56 + <td class="text-end">
57 + <div class="admin-action-group">
58 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
59 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
60 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
61 + </div>
62 + </td>
63 + </tr>
64 + }
65 + </tbody>
66 + </table>
67 + }
68 + else
69 + {
70 + <div class="admin-empty">
71 + <i class="bi bi-inbox"></i>
72 + <div>@Localizer["No items yet"]</div>
73 + </div>
74 + }
75 + </div>
76 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@model AdminCurrencyFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-currency-exchange"></i> @Localizer["Create"] @Localizer["Currency"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Currency"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="Currency.Code" class="control-label"></label>
19 + <input asp-for="Currency.Code" class="form-control" />
20 + <span asp-validation-for="Currency.Code" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label class="control-label">@Localizer["Name"] (EN)</label>
24 + <input name="NameEn" class="form-control" />
25 + </div>
26 + <div class="form-group mb-3">
27 + <label class="control-label">@Localizer["Name"] (ET)</label>
28 + <input name="NameEt" class="form-control" />
29 + </div>
30 + <div class="form-group mb-3">
31 + <label asp-for="Currency.Symbol" class="control-label"></label>
32 + <input asp-for="Currency.Symbol" class="form-control" />
33 + <span asp-validation-for="Currency.Symbol" class="text-danger"></span>
34 + </div>
35 + <div class="form-group mb-3 d-flex gap-2">
36 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
37 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
38 + </div>
39 + </form>
40 + </div>
41 +</div>
42 +
43 +@section Scripts {
44 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
45 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +25 −0
@@ -0,0 +1,25 @@
1 +@model AdminDeleteViewModel<CurrencyBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["Currency"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Code"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Code</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Name"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Name</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Symbol"]</dt>
17 + <dd class="col-sm-10">@Model.Item.Symbol</dd>
18 + </dl>
19 +
20 + <form asp-action="Delete">
21 + <input type="hidden" asp-for="Item.Id" />
22 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
23 + <a asp-action="Index">@Localizer["Back"]</a>
24 + </form>
25 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Details.cshtml +22 −0
@@ -0,0 +1,22 @@
1 +@model AdminDetailsViewModel<CurrencyBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["Currency"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Code"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Code</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Name"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Name</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Symbol"]</dt>
16 + <dd class="col-sm-10">@Model.Item.Symbol</dd>
17 + </dl>
18 +</div>
19 +<div>
20 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
21 + <a asp-action="Index">@Localizer["Back"]</a>
22 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
@@ -0,0 +1,46 @@
1 +@model AdminCurrencyFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-currency-exchange"></i> @Localizer["Edit"] @Localizer["Currency"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Currency"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="Currency.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="Currency.Code" class="control-label"></label>
20 + <input asp-for="Currency.Code" class="form-control" />
21 + <span asp-validation-for="Currency.Code" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label class="control-label">@Localizer["Name"] (EN)</label>
25 + <input name="NameEn" class="form-control" value="@(Model.Currency.Name.ContainsKey("en") ? Model.Currency.Name["en"] : "")" />
26 + </div>
27 + <div class="form-group mb-3">
28 + <label class="control-label">@Localizer["Name"] (ET)</label>
29 + <input name="NameEt" class="form-control" value="@(Model.Currency.Name.ContainsKey("et") ? Model.Currency.Name["et"] : "")" />
30 + </div>
31 + <div class="form-group mb-3">
32 + <label asp-for="Currency.Symbol" class="control-label"></label>
33 + <input asp-for="Currency.Symbol" class="form-control" />
34 + <span asp-validation-for="Currency.Symbol" class="text-danger"></span>
35 + </div>
36 + <div class="form-group mb-3 d-flex gap-2">
37 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
38 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
39 + </div>
40 + </form>
41 + </div>
42 +</div>
43 +
44 +@section Scripts {
45 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
46 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Index.cshtml +65 −0
@@ -0,0 +1,65 @@
1 +@model AdminCurrencyIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-currency-exchange me-2"></i>@Localizer["Currencies"]</h1>
5 + <p class="lead">@Localizer["Manage supported currencies"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Code"]</th>
33 + <th>@Localizer["Name"]</th>
34 + <th>@Localizer["Symbol"]</th>
35 + <th class="text-end">@Localizer["Actions"]</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + @foreach (var item in Model.Items)
40 + {
41 + <tr>
42 + <td><strong>@item.Code</strong></td>
43 + <td>@item.Name</td>
44 + <td>@item.Symbol</td>
45 + <td class="text-end">
46 + <div class="admin-action-group">
47 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
48 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
49 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
50 + </div>
51 + </td>
52 + </tr>
53 + }
54 + </tbody>
55 + </table>
56 + }
57 + else
58 + {
59 + <div class="admin-empty">
60 + <i class="bi bi-inbox"></i>
61 + <div>@Localizer["No items yet"]</div>
62 + </div>
63 + }
64 + </div>
65 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
@@ -0,0 +1,373 @@
1 +@model AdminDashboardViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-speedometer2 me-2"></i>@Localizer["Dashboard"]</h1>
6 + <p class="lead">@Localizer["System overview and real-time statistics"]</p>
7 + </div>
8 + <div class="text-muted small">
9 + <i class="bi bi-clock"></i> @DateTime.UtcNow.ToString("dd MMM yyyy, HH:mm") UTC
10 + </div>
11 +</div>
12 +
13 +@if (Model.PendingPayments > 0 || Model.MarkedPaidPayments > 0 || Model.PendingInvitations > 0)
14 +{
15 + <div class="row g-2 mb-3">
16 + @if (Model.PendingPayments > 0)
17 + {
18 + <div class="col-md-4"><div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-1"></i><strong>@Model.PendingPayments</strong> @Localizer["payments awaiting action"]</div></div>
19 + }
20 + @if (Model.MarkedPaidPayments > 0)
21 + {
22 + <div class="col-md-4"><div class="alert alert-info mb-0"><i class="bi bi-info-circle me-1"></i><strong>@Model.MarkedPaidPayments</strong> @Localizer["payments awaiting confirmation"]</div></div>
23 + }
24 + @if (Model.PendingInvitations > 0)
25 + {
26 + <div class="col-md-4"><div class="alert alert-info mb-0"><i class="bi bi-envelope me-1"></i><strong>@Model.PendingInvitations</strong> @Localizer["pending invitations"]</div></div>
27 + }
28 + </div>
29 +}
30 +
31 +<!-- Primary metric cards -->
32 +<div class="row g-3 mb-4">
33 + <div class="col-6 col-md-4 col-xl">
34 + <div class="admin-metric">
35 + <div class="admin-metric-icon bg-primary"><i class="bi bi-people-fill"></i></div>
36 + <div>
37 + <div class="admin-metric-value">@Model.UserCount</div>
38 + <div class="admin-metric-label">@Localizer["Total Users"]</div>
39 + </div>
40 + </div>
41 + </div>
42 + <div class="col-6 col-md-4 col-xl">
43 + <div class="admin-metric">
44 + <div class="admin-metric-icon bg-success"><i class="bi bi-suitcase-lg-fill"></i></div>
45 + <div>
46 + <div class="admin-metric-value">@Model.TripCount</div>
47 + <div class="admin-metric-label">@Localizer["Trips"]</div>
48 + </div>
49 + </div>
50 + </div>
51 + <div class="col-6 col-md-4 col-xl">
52 + <div class="admin-metric">
53 + <div class="admin-metric-icon bg-warning"><i class="bi bi-cash-coin"></i></div>
54 + <div>
55 + <div class="admin-metric-value">@Model.ExpenseCount</div>
56 + <div class="admin-metric-label">@Localizer["Expenses"]</div>
57 + </div>
58 + </div>
59 + </div>
60 + <div class="col-6 col-md-4 col-xl">
61 + <div class="admin-metric">
62 + <div class="admin-metric-icon bg-info"><i class="bi bi-currency-euro"></i></div>
63 + <div>
64 + <div class="admin-metric-value">@Model.TotalExpenseAmount.ToString("N2")</div>
65 + <div class="admin-metric-label">@Localizer["Total amount"]</div>
66 + </div>
67 + </div>
68 + </div>
69 + <div class="col-6 col-md-4 col-xl">
70 + <div class="admin-metric">
71 + <div class="admin-metric-icon bg-purple"><i class="bi bi-diagram-3-fill"></i></div>
72 + <div>
73 + <div class="admin-metric-value">@Model.SettlementCount</div>
74 + <div class="admin-metric-label">@Localizer["Settlement plans"]</div>
75 + </div>
76 + </div>
77 + </div>
78 + <div class="col-6 col-md-4 col-xl">
79 + <div class="admin-metric">
80 + <div class="admin-metric-icon bg-danger"><i class="bi bi-hourglass-split"></i></div>
81 + <div>
82 + <div class="admin-metric-value">@Model.PendingPayments</div>
83 + <div class="admin-metric-label">@Localizer["Pending payments"]</div>
84 + </div>
85 + </div>
86 + </div>
87 +</div>
88 +
89 +<!-- Status breakdowns row -->
90 +<div class="row g-3 mb-4">
91 + <div class="col-lg-6">
92 + <div class="admin-card h-100">
93 + <div class="admin-card-header">
94 + <span><i class="bi bi-pie-chart-fill"></i>@Localizer["Trip status breakdown"]</span>
95 + <span class="text-muted small">@Model.TripCount @Localizer["total"]</span>
96 + </div>
97 + <div class="admin-card-body">
98 + <div class="d-flex flex-wrap gap-2 mb-3">
99 + <span class="badge status-active fs-6 px-3 py-2"><i class="bi bi-circle-fill me-1"></i>@Model.ActiveTrips @Localizer["Active"]</span>
100 + <span class="badge status-settled fs-6 px-3 py-2"><i class="bi bi-check2-circle me-1"></i>@Model.SettledTrips @Localizer["Settled"]</span>
101 + <span class="badge status-archived fs-6 px-3 py-2"><i class="bi bi-archive me-1"></i>@Model.ArchivedTrips @Localizer["Archived"]</span>
102 + </div>
103 + @if (Model.TripCount > 0)
104 + {
105 + <div class="progress" style="height: 10px;">
106 + <div class="progress-bar bg-success" style="width: @(Model.ActiveTrips * 100 / Model.TripCount)%"></div>
107 + <div class="progress-bar bg-primary" style="width: @(Model.SettledTrips * 100 / Model.TripCount)%"></div>
108 + <div class="progress-bar bg-secondary" style="width: @(Model.ArchivedTrips * 100 / Model.TripCount)%"></div>
109 + </div>
110 + }
111 + </div>
112 + </div>
113 + </div>
114 + <div class="col-lg-6">
115 + <div class="admin-card h-100">
116 + <div class="admin-card-header">
117 + <span><i class="bi bi-diagram-3"></i>@Localizer["Settlement status"]</span>
118 + <span class="text-muted small">@Model.SettlementCount @Localizer["total"]</span>
119 + </div>
120 + <div class="admin-card-body">
121 + <div class="d-flex flex-wrap gap-2 mb-3">
122 + <span class="badge status-pending fs-6 px-3 py-2"><i class="bi bi-hourglass me-1"></i>@Model.PendingSettlements @Localizer["Pending"]</span>
123 + <span class="badge status-inprogress fs-6 px-3 py-2"><i class="bi bi-arrow-repeat me-1"></i>@Model.InProgressSettlements @Localizer["In progress"]</span>
124 + <span class="badge status-completed fs-6 px-3 py-2"><i class="bi bi-check-circle me-1"></i>@Model.CompletedSettlements @Localizer["Completed"]</span>
125 + </div>
126 + @if (Model.SettlementCount > 0)
127 + {
128 + <div class="progress" style="height: 10px;">
129 + <div class="progress-bar bg-warning" style="width: @(Model.PendingSettlements * 100 / Model.SettlementCount)%"></div>
130 + <div class="progress-bar bg-info" style="width: @(Model.InProgressSettlements * 100 / Model.SettlementCount)%"></div>
131 + <div class="progress-bar bg-success" style="width: @(Model.CompletedSettlements * 100 / Model.SettlementCount)%"></div>
132 + </div>
133 + }
134 + </div>
135 + </div>
136 + </div>
137 +</div>
138 +
139 +<!-- Top Active Trips + Biggest Expenses -->
140 +<div class="row g-3 mb-4">
141 + <div class="col-lg-6">
142 + <div class="admin-card h-100">
143 + <div class="admin-card-header">
144 + <span><i class="bi bi-trophy-fill"></i>@Localizer["Top active trips"]</span>
145 + <a asp-area="Admin" asp-controller="Trips" asp-action="Index" class="btn btn-sm btn-outline-primary">@Localizer["View all"]</a>
146 + </div>
147 + <div class="admin-card-body p-0">
148 + @if (Model.TopActiveTrips.Any())
149 + {
150 + <table class="table admin-table mb-0">
151 + <thead>
152 + <tr>
153 + <th>@Localizer["Trip"]</th>
154 + <th class="text-center">@Localizer["Participants"]</th>
155 + <th class="text-center">@Localizer["Expenses"]</th>
156 + <th class="text-end">@Localizer["Total"]</th>
157 + </tr>
158 + </thead>
159 + <tbody>
160 + @foreach (var row in Model.TopActiveTrips)
161 + {
162 + <tr>
163 + <td>
164 + <a asp-area="Admin" asp-controller="Trips" asp-action="Details" asp-route-id="@row.Trip.Id" class="text-decoration-none fw-semibold">@row.Trip.Name</a>
165 + @if (!string.IsNullOrEmpty(row.Trip.Destination))
166 + {
167 + <br /><small class="text-muted">@row.Trip.Destination</small>
168 + }
169 + </td>
170 + <td class="text-center"><span class="badge bg-light text-dark border">@row.ParticipantCount</span></td>
171 + <td class="text-center"><span class="badge bg-light text-dark border">@row.ExpenseCount</span></td>
172 + <td class="text-end fw-semibold">@row.ExpenseSum.ToString("N2")</td>
173 + </tr>
174 + }
175 + </tbody>
176 + </table>
177 + }
178 + else
179 + {
180 + <div class="admin-empty"><i class="bi bi-inbox"></i>@Localizer["No active trips yet"]</div>
181 + }
182 + </div>
183 + </div>
184 + </div>
185 + <div class="col-lg-6">
186 + <div class="admin-card h-100">
187 + <div class="admin-card-header">
188 + <span><i class="bi bi-graph-up-arrow"></i>@Localizer["Biggest expenses"]</span>
189 + <a asp-area="Admin" asp-controller="Expenses" asp-action="Index" class="btn btn-sm btn-outline-primary">@Localizer["View all"]</a>
190 + </div>
191 + <div class="admin-card-body p-0">
192 + @if (Model.BiggestExpenses.Any())
193 + {
194 + <table class="table admin-table mb-0">
195 + <thead>
196 + <tr>
197 + <th>@Localizer["Description"]</th>
198 + <th>@Localizer["Trip"]</th>
199 + <th class="text-end">@Localizer["Amount"]</th>
200 + </tr>
201 + </thead>
202 + <tbody>
203 + @foreach (var e in Model.BiggestExpenses)
204 + {
205 + <tr>
206 + <td>
207 + <a asp-area="Admin" asp-controller="Expenses" asp-action="Details" asp-route-id="@e.Id" class="text-decoration-none">
208 + @(string.IsNullOrWhiteSpace(e.Description) ? Localizer["No description"].Value : e.Description)
209 + </a>
210 + @if (e.PaidByUser != null)
211 + {
212 + <br /><small class="text-muted">@Localizer["by"] @e.PaidByUser.FirstName @e.PaidByUser.LastName</small>
213 + }
214 + </td>
215 + <td class="text-muted small">@(e.Trip?.Name ?? "—")</td>
216 + <td class="text-end fw-bold">@e.Amount.ToString("N2")</td>
217 + </tr>
218 + }
219 + </tbody>
220 + </table>
221 + }
222 + else
223 + {
224 + <div class="admin-empty"><i class="bi bi-inbox"></i>@Localizer["No expenses yet"]</div>
225 + }
226 + </div>
227 + </div>
228 + </div>
229 +</div>
230 +
231 +<!-- User stats + Activity Feed -->
232 +<div class="row g-3 mb-4">
233 + <div class="col-lg-5">
234 + <div class="admin-card h-100">
235 + <div class="admin-card-header">
236 + <span><i class="bi bi-person-lines-fill"></i>@Localizer["User activity"]</span>
237 + </div>
238 + <div class="admin-card-body">
239 + <div class="row g-2 mb-3">
240 + <div class="col-4 text-center">
241 + <div class="fs-4 fw-bold text-primary">@Model.UserCount</div>
242 + <div class="small text-muted">@Localizer["Total"]</div>
243 + </div>
244 + <div class="col-4 text-center">
245 + <div class="fs-4 fw-bold text-success">@Model.NewUsersLast7Days</div>
246 + <div class="small text-muted">@Localizer["Active 7d"]</div>
247 + </div>
248 + <div class="col-4 text-center">
249 + <div class="fs-4 fw-bold text-info">@Model.NewUsersLast30Days</div>
250 + <div class="small text-muted">@Localizer["Active 30d"]</div>
251 + </div>
252 + </div>
253 + <hr />
254 + <div class="small fw-semibold text-muted mb-2 text-uppercase">@Localizer["Most active users"]</div>
255 + @if (Model.TopActiveUsers.Any())
256 + {
257 + <ul class="list-unstyled mb-0">
258 + @foreach (var u in Model.TopActiveUsers)
259 + {
260 + <li class="d-flex justify-content-between align-items-center py-1 border-bottom border-light">
261 + <div>
262 + <div class="fw-semibold small">@u.FullName</div>
263 + <div class="text-muted" style="font-size: 0.78rem;">@u.Email</div>
264 + </div>
265 + <div class="text-end">
266 + <span class="badge bg-primary">@u.ExpenseCount @Localizer["expenses"]</span>
267 + </div>
268 + </li>
269 + }
270 + </ul>
271 + }
272 + else
273 + {
274 + <div class="admin-empty"><i class="bi bi-person-slash"></i>@Localizer["No activity yet"]</div>
275 + }
276 + </div>
277 + </div>
278 + </div>
279 + <div class="col-lg-7">
280 + <div class="admin-card h-100">
281 + <div class="admin-card-header">
282 + <span><i class="bi bi-activity"></i>@Localizer["Recent activity"]</span>
283 + <span class="text-muted small">@Model.ActivityFeed.Count @Localizer["events"]</span>
284 + </div>
285 + <div class="admin-card-body">
286 + @if (Model.ActivityFeed.Any())
287 + {
288 + <ul class="admin-feed">
289 + @foreach (var item in Model.ActivityFeed)
290 + {
291 + <li class="admin-feed-item">
292 + <div class="admin-feed-icon @item.BadgeCssClass">
293 + <i class="bi @item.IconCssClass"></i>
294 + </div>
295 + <div class="admin-feed-body">
296 + <div class="admin-feed-message">@item.Message</div>
297 + <div class="admin-feed-date">@item.Date.ToString("dd MMM yyyy, HH:mm") UTC</div>
298 + </div>
299 + </li>
300 + }
301 + </ul>
302 + }
303 + else
304 + {
305 + <div class="admin-empty"><i class="bi bi-clock-history"></i>@Localizer["No recent activity"]</div>
306 + }
307 + </div>
308 + </div>
309 + </div>
310 +</div>
311 +
312 +<!-- Secondary counts -->
313 +<div class="row g-3 mb-4">
314 + <div class="col-6 col-md-4 col-lg-2">
315 + <div class="admin-metric">
316 + <div class="admin-metric-icon bg-info"><i class="bi bi-people"></i></div>
317 + <div><div class="admin-metric-value">@Model.ParticipantCount</div><div class="admin-metric-label">@Localizer["Participants"]</div></div>
318 + </div>
319 + </div>
320 + <div class="col-6 col-md-4 col-lg-2">
321 + <div class="admin-metric">
322 + <div class="admin-metric-icon bg-warning"><i class="bi bi-tags"></i></div>
323 + <div><div class="admin-metric-value">@Model.CategoryCount</div><div class="admin-metric-label">@Localizer["Categories"]</div></div>
324 + </div>
325 + </div>
326 + <div class="col-6 col-md-4 col-lg-2">
327 + <div class="admin-metric">
328 + <div class="admin-metric-icon bg-success"><i class="bi bi-currency-exchange"></i></div>
329 + <div><div class="admin-metric-value">@Model.CurrencyCount</div><div class="admin-metric-label">@Localizer["Currencies"]</div></div>
330 + </div>
331 + </div>
332 + <div class="col-6 col-md-4 col-lg-2">
333 + <div class="admin-metric">
334 + <div class="admin-metric-icon bg-purple"><i class="bi bi-stars"></i></div>
335 + <div><div class="admin-metric-value">@Model.WishlistCount</div><div class="admin-metric-label">@Localizer["Wishlist"]</div></div>
336 + </div>
337 + </div>
338 + <div class="col-6 col-md-4 col-lg-2">
339 + <div class="admin-metric">
340 + <div class="admin-metric-icon bg-primary"><i class="bi bi-bar-chart"></i></div>
341 + <div><div class="admin-metric-value">@Model.PollCount</div><div class="admin-metric-label">@Localizer["Polls"]</div></div>
342 + </div>
343 + </div>
344 + <div class="col-6 col-md-4 col-lg-2">
345 + <div class="admin-metric">
346 + <div class="admin-metric-icon bg-danger"><i class="bi bi-envelope"></i></div>
347 + <div><div class="admin-metric-value">@Model.InvitationCount</div><div class="admin-metric-label">@Localizer["Invitations"]</div></div>
348 + </div>
349 + </div>
350 +</div>
351 +
352 +<!-- Quick actions -->
353 +<div class="admin-card">
354 + <div class="admin-card-header">
355 + <span><i class="bi bi-plus-circle"></i>@Localizer["Quick actions"]</span>
356 + </div>
357 + <div class="admin-card-body">
358 + <div class="row g-2">
359 + <div class="col-md-3 col-6">
360 + <a asp-area="Admin" asp-controller="Trips" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Trip"]</a>
361 + </div>
362 + <div class="col-md-3 col-6">
363 + <a asp-area="Admin" asp-controller="Expenses" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Expense"]</a>
364 + </div>
365 + <div class="col-md-3 col-6">
366 + <a asp-area="Admin" asp-controller="Currencies" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Currency"]</a>
367 + </div>
368 + <div class="col-md-3 col-6">
369 + <a asp-area="Admin" asp-controller="BudgetCategories" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Category"]</a>
370 + </div>
371 + </div>
372 + </div>
373 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
@@ -0,0 +1,71 @@
1 +@model AdminExpenseFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-receipt"></i> @Localizer["Create"] @Localizer["Expense"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Expense"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="Expense.TripId" class="control-label"></label>
19 + <select asp-for="Expense.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="Expense.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="Expense.PaidByUserId" class="control-label"></label>
24 + <select asp-for="Expense.PaidByUserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="Expense.PaidByUserId" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="Expense.Amount" class="control-label"></label>
29 + <input asp-for="Expense.Amount" class="form-control" />
30 + <span asp-validation-for="Expense.Amount" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="Expense.Description" class="control-label"></label>
34 + <input asp-for="Expense.Description" class="form-control" />
35 + <span asp-validation-for="Expense.Description" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <label asp-for="Expense.ExpenseDate" class="control-label"></label>
39 + <input asp-for="Expense.ExpenseDate" class="form-control" type="date" />
40 + <span asp-validation-for="Expense.ExpenseDate" class="text-danger"></span>
41 + </div>
42 + <div class="form-group mb-3">
43 + <label asp-for="Expense.SplitMethod" class="control-label"></label>
44 + <select asp-for="Expense.SplitMethod" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESplitMethod>()"></select>
45 + <span asp-validation-for="Expense.SplitMethod" class="text-danger"></span>
46 + </div>
47 + <div class="form-group mb-3">
48 + <label asp-for="Expense.CurrencyId" class="control-label"></label>
49 + <select asp-for="Expense.CurrencyId" class="form-control" asp-items="Model.CurrencyList">
50 + <option value="">-- @Localizer["Select"] --</option>
51 + </select>
52 + <span asp-validation-for="Expense.CurrencyId" class="text-danger"></span>
53 + </div>
54 + <div class="form-group mb-3">
55 + <label asp-for="Expense.BudgetCategoryId" class="control-label"></label>
56 + <select asp-for="Expense.BudgetCategoryId" class="form-control" asp-items="Model.BudgetCategoryList">
57 + <option value="">-- @Localizer["Select"] --</option>
58 + </select>
59 + <span asp-validation-for="Expense.BudgetCategoryId" class="text-danger"></span>
60 + </div>
61 + <div class="form-group mb-3 d-flex gap-2">
62 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
63 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
64 + </div>
65 + </form>
66 + </div>
67 +</div>
68 +
69 +@section Scripts {
70 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +31 −0
@@ -0,0 +1,31 @@
1 +@model AdminDeleteViewModel<ExpenseBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["Expense"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Description"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Description</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Amount"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Amount</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["ExpenseDate"]</dt>
17 + <dd class="col-sm-10">@Model.Item.ExpenseDate.ToString("d")</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
20 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
21 +
22 + <dt class="col-sm-2">@Localizer["PaidByUser"]</dt>
23 + <dd class="col-sm-10">@Model.Item.PaidByUser?.Email</dd>
24 + </dl>
25 +
26 + <form asp-action="Delete">
27 + <input type="hidden" asp-for="Item.Id" />
28 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
29 + <a asp-action="Index">@Localizer["Back"]</a>
30 + </form>
31 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Details.cshtml +37 −0
@@ -0,0 +1,37 @@
1 +@model AdminDetailsViewModel<ExpenseBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["Expense"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Description"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Description</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Amount"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Amount</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["ExpenseDate"]</dt>
16 + <dd class="col-sm-10">@Model.Item.ExpenseDate.ToString("d")</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt>
19 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
22 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
23 +
24 + <dt class="col-sm-2">@Localizer["PaidByUser"]</dt>
25 + <dd class="col-sm-10">@Model.Item.PaidByUser?.Email</dd>
26 +
27 + <dt class="col-sm-2">@Localizer["Currency"]</dt>
28 + <dd class="col-sm-10">@Model.Item.Currency?.Code</dd>
29 +
30 + <dt class="col-sm-2">@Localizer["BudgetCategory"]</dt>
31 + <dd class="col-sm-10">@Model.Item.BudgetCategory?.Name</dd>
32 + </dl>
33 +</div>
34 +<div>
35 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
36 + <a asp-action="Index">@Localizer["Back"]</a>
37 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
@@ -0,0 +1,72 @@
1 +@model AdminExpenseFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-receipt"></i> @Localizer["Edit"] @Localizer["Expense"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Expense"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="Expense.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="Expense.TripId" class="control-label"></label>
20 + <select asp-for="Expense.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="Expense.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label asp-for="Expense.PaidByUserId" class="control-label"></label>
25 + <select asp-for="Expense.PaidByUserId" class="form-control" asp-items="Model.UserList"></select>
26 + <span asp-validation-for="Expense.PaidByUserId" class="text-danger"></span>
27 + </div>
28 + <div class="form-group mb-3">
29 + <label asp-for="Expense.Amount" class="control-label"></label>
30 + <input asp-for="Expense.Amount" class="form-control" />
31 + <span asp-validation-for="Expense.Amount" class="text-danger"></span>
32 + </div>
33 + <div class="form-group mb-3">
34 + <label asp-for="Expense.Description" class="control-label"></label>
35 + <input asp-for="Expense.Description" class="form-control" />
36 + <span asp-validation-for="Expense.Description" class="text-danger"></span>
37 + </div>
38 + <div class="form-group mb-3">
39 + <label asp-for="Expense.ExpenseDate" class="control-label"></label>
40 + <input asp-for="Expense.ExpenseDate" class="form-control" type="date" />
41 + <span asp-validation-for="Expense.ExpenseDate" class="text-danger"></span>
42 + </div>
43 + <div class="form-group mb-3">
44 + <label asp-for="Expense.SplitMethod" class="control-label"></label>
45 + <select asp-for="Expense.SplitMethod" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESplitMethod>()"></select>
46 + <span asp-validation-for="Expense.SplitMethod" class="text-danger"></span>
47 + </div>
48 + <div class="form-group mb-3">
49 + <label asp-for="Expense.CurrencyId" class="control-label"></label>
50 + <select asp-for="Expense.CurrencyId" class="form-control" asp-items="Model.CurrencyList">
51 + <option value="">-- @Localizer["Select"] --</option>
52 + </select>
53 + <span asp-validation-for="Expense.CurrencyId" class="text-danger"></span>
54 + </div>
55 + <div class="form-group mb-3">
56 + <label asp-for="Expense.BudgetCategoryId" class="control-label"></label>
57 + <select asp-for="Expense.BudgetCategoryId" class="form-control" asp-items="Model.BudgetCategoryList">
58 + <option value="">-- @Localizer["Select"] --</option>
59 + </select>
60 + <span asp-validation-for="Expense.BudgetCategoryId" class="text-danger"></span>
61 + </div>
62 + <div class="form-group mb-3 d-flex gap-2">
63 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
64 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
65 + </div>
66 + </form>
67 + </div>
68 +</div>
69 +
70 +@section Scripts {
71 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
72 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Index.cshtml +80 −0
@@ -0,0 +1,80 @@
1 +@model AdminExpenseIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-cash-coin me-2"></i>@Localizer["Expenses"]</h1>
5 + <p class="lead">@Localizer["Manage all expenses across trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
26 + </div>
27 + <div class="col-auto">
28 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
29 + </div>
30 + </form>
31 + </div>
32 +</div>
33 +
34 +<div class="admin-card">
35 + <div class="admin-card-body p-0">
36 + @if (Model.Items.Any())
37 + {
38 + <table class="table admin-table mb-0">
39 + <thead>
40 + <tr>
41 + <th>@Localizer["Description"]</th>
42 + <th>@Localizer["Trip"]</th>
43 + <th>@Localizer["PaidByUser"]</th>
44 + <th>@Localizer["SplitMethod"]</th>
45 + <th class="text-end">@Localizer["Amount"]</th>
46 + <th>@Localizer["ExpenseDate"]</th>
47 + <th class="text-end">@Localizer["Actions"]</th>
48 + </tr>
49 + </thead>
50 + <tbody>
51 + @foreach (var item in Model.Items)
52 + {
53 + <tr>
54 + <td>@item.Description</td>
55 + <td>@item.Trip?.Name</td>
56 + <td>@item.PaidByUser?.Email</td>
57 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.SplitMethod)</td>
58 + <td class="text-end fw-bold">@item.Amount</td>
59 + <td>@item.ExpenseDate.ToString("d")</td>
60 + <td class="text-end">
61 + <div class="admin-action-group">
62 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
63 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
64 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
65 + </div>
66 + </td>
67 + </tr>
68 + }
69 + </tbody>
70 + </table>
71 + }
72 + else
73 + {
74 + <div class="admin-empty">
75 + <i class="bi bi-inbox"></i>
76 + <div>@Localizer["No items yet"]</div>
77 + </div>
78 + }
79 + </div>
80 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
@@ -0,0 +1,58 @@
1 +@model AdminInvitationFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-plus"></i> @Localizer["Create"] @Localizer["Invitation"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Invitation"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 +
18 + <div class="form-group mb-3">
19 + <label asp-for="Invitation.TripId" class="control-label">@Localizer["Trip"]</label>
20 + <select asp-for="Invitation.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="Invitation.TripId" class="text-danger"></span>
22 + </div>
23 +
24 + <div class="form-group mb-3">
25 + <label asp-for="Invitation.InvitedByUserId" class="control-label">@Localizer["InvitedBy"]</label>
26 + <select asp-for="Invitation.InvitedByUserId" class="form-control" asp-items="Model.UserList"></select>
27 + <span asp-validation-for="Invitation.InvitedByUserId" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="form-group mb-3">
31 + <label asp-for="Invitation.Token" class="control-label"></label>
32 + <input asp-for="Invitation.Token" class="form-control" placeholder="@Localizer["Auto-generated if blank"]" />
33 + <span asp-validation-for="Invitation.Token" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="form-group mb-3">
37 + <label asp-for="Invitation.Status" class="control-label"></label>
38 + <select asp-for="Invitation.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus>()"></select>
39 + <span asp-validation-for="Invitation.Status" class="text-danger"></span>
40 + </div>
41 +
42 + <div class="form-group mb-3">
43 + <label asp-for="Invitation.ExpiresAt" class="control-label"></label>
44 + <input asp-for="Invitation.ExpiresAt" class="form-control" type="datetime-local" />
45 + <span asp-validation-for="Invitation.ExpiresAt" class="text-danger"></span>
46 + </div>
47 +
48 + <div class="form-group mb-3 d-flex gap-2">
49 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
50 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
51 + </div>
52 + </form>
53 + </div>
54 +</div>
55 +
56 +@section Scripts {
57 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
58 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +39 −0
@@ -0,0 +1,39 @@
1 +@model AdminDeleteViewModel<TripInvitationBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-x text-danger"></i> @Localizer["Delete invitation"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card border-danger">
11 + <div class="admin-card-body">
12 + <div class="alert alert-danger">
13 + <i class="bi bi-exclamation-triangle me-2"></i>
14 + @Localizer["Are you sure you want to delete this invitation?"]
15 + </div>
16 +
17 + <dl class="row">
18 + <dt class="col-sm-3">@Localizer["Trip"]</dt>
19 + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd>
20 +
21 + <dt class="col-sm-3">@Localizer["InvitedBy"]</dt>
22 + <dd class="col-sm-9">@Model.Item.InvitedByUser?.Email</dd>
23 +
24 + <dt class="col-sm-3">@Localizer["Token"]</dt>
25 + <dd class="col-sm-9"><code>@Model.Item.Token</code></dd>
26 +
27 + <dt class="col-sm-3">@Localizer["Status"]</dt>
28 + <dd class="col-sm-9">@Model.Item.Status</dd>
29 + </dl>
30 +
31 + <form asp-action="Delete">
32 + <input type="hidden" name="id" value="@Model.Item.Id" />
33 + <div class="d-flex gap-2">
34 + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button>
35 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
36 + </div>
37 + </form>
38 + </div>
39 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Details.cshtml +40 −0
@@ -0,0 +1,40 @@
1 +@model AdminDetailsViewModel<TripInvitationBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-paper"></i> @Localizer["Invitation details"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <dl class="row">
13 + <dt class="col-sm-3">@Localizer["Trip"]</dt>
14 + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd>
15 +
16 + <dt class="col-sm-3">@Localizer["InvitedBy"]</dt>
17 + <dd class="col-sm-9">@Model.Item.InvitedByUser?.Email</dd>
18 +
19 + <dt class="col-sm-3">@Localizer["Token"]</dt>
20 + <dd class="col-sm-9"><code>@Model.Item.Token</code></dd>
21 +
22 + <dt class="col-sm-3">@Localizer["Status"]</dt>
23 + <dd class="col-sm-9"><span class="badge bg-secondary">@Model.Item.Status</span></dd>
24 +
25 + <dt class="col-sm-3">@Localizer["ExpiresAt"]</dt>
26 + <dd class="col-sm-9">@Model.Item.ExpiresAt.ToString("yyyy-MM-dd HH:mm")</dd>
27 +
28 + @if (Model.Item.RespondedAt.HasValue)
29 + {
30 + <dt class="col-sm-3">@Localizer["RespondedAt"]</dt>
31 + <dd class="col-sm-9">@Model.Item.RespondedAt.Value.ToString("yyyy-MM-dd HH:mm")</dd>
32 + }
33 + </dl>
34 +
35 + <div class="d-flex gap-2">
36 + <a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary"><i class="bi bi-pencil me-1"></i>@Localizer["Edit"]</a>
37 + <a asp-action="Delete" asp-route-id="@Model.Item.Id" class="btn btn-outline-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</a>
38 + </div>
39 + </div>
40 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
@@ -0,0 +1,59 @@
1 +@model AdminInvitationFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-paper"></i> @Localizer["Edit"] @Localizer["Invitation"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Invitation"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <input type="hidden" asp-for="Invitation.Id" />
17 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
18 +
19 + <div class="form-group mb-3">
20 + <label asp-for="Invitation.TripId" class="control-label">@Localizer["Trip"]</label>
21 + <select asp-for="Invitation.TripId" class="form-control" asp-items="Model.TripList"></select>
22 + <span asp-validation-for="Invitation.TripId" class="text-danger"></span>
23 + </div>
24 +
25 + <div class="form-group mb-3">
26 + <label asp-for="Invitation.InvitedByUserId" class="control-label">@Localizer["InvitedBy"]</label>
27 + <select asp-for="Invitation.InvitedByUserId" class="form-control" asp-items="Model.UserList"></select>
28 + <span asp-validation-for="Invitation.InvitedByUserId" class="text-danger"></span>
29 + </div>
30 +
31 + <div class="form-group mb-3">
32 + <label asp-for="Invitation.Token" class="control-label"></label>
33 + <input asp-for="Invitation.Token" class="form-control" />
34 + <span asp-validation-for="Invitation.Token" class="text-danger"></span>
35 + </div>
36 +
37 + <div class="form-group mb-3">
38 + <label asp-for="Invitation.Status" class="control-label"></label>
39 + <select asp-for="Invitation.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus>()"></select>
40 + <span asp-validation-for="Invitation.Status" class="text-danger"></span>
41 + </div>
42 +
43 + <div class="form-group mb-3">
44 + <label asp-for="Invitation.ExpiresAt" class="control-label"></label>
45 + <input asp-for="Invitation.ExpiresAt" class="form-control" type="datetime-local" />
46 + <span asp-validation-for="Invitation.ExpiresAt" class="text-danger"></span>
47 + </div>
48 +
49 + <div class="form-group mb-3 d-flex gap-2">
50 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
51 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
52 + </div>
53 + </form>
54 + </div>
55 +</div>
56 +
57 +@section Scripts {
58 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
59 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Index.cshtml +65 −0
@@ -0,0 +1,65 @@
1 +@model AdminInvitationIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-envelope me-2"></i>@Localizer["Invitations"]</h1>
5 + <p class="lead">@Localizer["Review invitation tokens and statuses"]</p>
6 + </div>
7 +</div>
8 +
9 +<div class="admin-card mb-3">
10 + <div class="admin-card-body">
11 + <form method="get" class="row g-2 align-items-end">
12 + <div class="col-auto">
13 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
14 + </div>
15 + <div class="col-auto">
16 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
17 + </div>
18 + </form>
19 + </div>
20 +</div>
21 +
22 +<div class="admin-card">
23 + <div class="admin-card-body p-0">
24 + @if (Model.Items.Any())
25 + {
26 + <table class="table admin-table mb-0">
27 + <thead>
28 + <tr>
29 + <th>@Localizer["Token"]</th>
30 + <th>@Localizer["Trip"]</th>
31 + <th>@Localizer["InvitedBy"]</th>
32 + <th>@Localizer["ExpiresAt"]</th>
33 + <th>@Localizer["Status"]</th>
34 + <th class="text-end">@Localizer["Actions"]</th>
35 + </tr>
36 + </thead>
37 + <tbody>
38 + @foreach (var item in Model.Items)
39 + {
40 + <tr>
41 + <td><code>@(item.Token.Length > 16 ? item.Token[..16] + "..." : item.Token)</code></td>
42 + <td>@item.Trip?.Name</td>
43 + <td>@item.InvitedByUser?.Email</td>
44 + <td>@item.ExpiresAt.ToString("g")</td>
45 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
46 + <td class="text-end">
47 + <div class="admin-action-group">
48 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
49 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
50 + </div>
51 + </td>
52 + </tr>
53 + }
54 + </tbody>
55 + </table>
56 + }
57 + else
58 + {
59 + <div class="admin-empty">
60 + <i class="bi bi-inbox"></i>
61 + <div>@Localizer["No items yet"]</div>
62 + </div>
63 + }
64 + </div>
65 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
@@ -0,0 +1,48 @@
1 +@model AdminPollFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-bar-chart-fill"></i> @Localizer["Create"] @Localizer["Poll"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Poll"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create" method="post">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="mb-3">
18 + <label asp-for="Poll.TripId" class="form-label"></label>
19 + <select asp-for="Poll.TripId" asp-items="Model.TripList" class="form-select"></select>
20 + </div>
21 + <div class="mb-3">
22 + <label asp-for="Poll.CreatedByUserId" class="form-label"></label>
23 + <select asp-for="Poll.CreatedByUserId" asp-items="Model.UserList" class="form-select"></select>
24 + </div>
25 + <div class="mb-3">
26 + <label asp-for="Poll.Question" class="form-label"></label>
27 + <input asp-for="Poll.Question" class="form-control" />
28 + <span asp-validation-for="Poll.Question" class="text-danger"></span>
29 + </div>
30 + <div class="mb-3 form-check">
31 + <input asp-for="Poll.AllowMultipleVotes" class="form-check-input" />
32 + <label asp-for="Poll.AllowMultipleVotes" class="form-check-label"></label>
33 + </div>
34 + <div class="mb-3 form-check">
35 + <input asp-for="Poll.IsAnonymous" class="form-check-input" />
36 + <label asp-for="Poll.IsAnonymous" class="form-check-label"></label>
37 + </div>
38 + <div class="d-flex gap-2">
39 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Create"]</button>
40 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
41 + </div>
42 + </form>
43 + </div>
44 +</div>
45 +
46 +@section Scripts {
47 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
48 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Delete.cshtml +13 −0
@@ -0,0 +1,13 @@
1 +@model AdminDeleteViewModel<TripPollBllDto>
2 +
3 +<h1>@Localizer["Delete"] @Localizer["Poll"]</h1>
4 +
5 +<div class="alert alert-danger">
6 + @Localizer["AreYouSure"] - "<strong>@Model.Item.Question</strong>" (@Model.Item.Trip?.Name)?
7 +</div>
8 +
9 +<form asp-action="Delete" method="post">
10 + <input type="hidden" asp-for="Item.Id" />
11 + <button type="submit" class="btn btn-danger">@Localizer["Delete"]</button>
12 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
13 +</form>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Details.cshtml +37 −0
@@ -0,0 +1,37 @@
1 +@model AdminDetailsViewModel<TripPollBllDto>
2 +
3 +<h1>@Localizer["Poll"] @Localizer["Details"]</h1>
4 +
5 +<div class="card shadow-sm">
6 + <div class="card-body">
7 + <h5>@Model.Item.Question</h5>
8 + <p class="text-muted">@Localizer["Trip"]: @Model.Item.Trip?.Name | @Localizer["Created By"]: @(Model.Item.CreatedByUser != null ? $"{Model.Item.CreatedByUser.FirstName} {Model.Item.CreatedByUser.LastName}" : "")</p>
9 + <p>@Localizer["Multi-Vote"]: @(Model.Item.AllowMultipleVotes ? Localizer["Yes"] : Localizer["No"]) | @Localizer["Anonymous"]: @(Model.Item.IsAnonymous ? Localizer["Yes"] : Localizer["No"])</p>
10 + @if (Model.Item.ClosedAt.HasValue)
11 + {
12 + <p class="text-danger">@Localizer["Closed at"]: @Model.Item.ClosedAt.Value.ToString("g")</p>
13 + }
14 +
15 + @if (Model.Item.Options != null)
16 + {
17 + <h6 class="mt-3">@Localizer["Options"]</h6>
18 + <table class="table">
19 + <thead>
20 + <tr><th>@Localizer["Text"]</th><th>@Localizer["Votes"]</th></tr>
21 + </thead>
22 + <tbody>
23 + @foreach (var option in Model.Item.Options.OrderBy(o => o.DisplayOrder))
24 + {
25 + <tr>
26 + <td>@option.Text</td>
27 + <td>@option.VoteCount</td>
28 + </tr>
29 + }
30 + </tbody>
31 + </table>
32 + }
33 + </div>
34 +</div>
35 +
36 +<a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary mt-3">@Localizer["Edit"]</a>
37 +<a asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Back to List"]</a>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@model AdminPollFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-bar-chart-fill"></i> @Localizer["Edit"] @Localizer["Poll"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Poll"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit" method="post">
16 + <input type="hidden" asp-for="Poll.Id" />
17 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
18 + <div class="mb-3">
19 + <label asp-for="Poll.TripId" class="form-label"></label>
20 + <select asp-for="Poll.TripId" asp-items="Model.TripList" class="form-select"></select>
21 + </div>
22 + <div class="mb-3">
23 + <label asp-for="Poll.Question" class="form-label"></label>
24 + <input asp-for="Poll.Question" class="form-control" />
25 + <span asp-validation-for="Poll.Question" class="text-danger"></span>
26 + </div>
27 + <div class="mb-3 form-check">
28 + <input asp-for="Poll.AllowMultipleVotes" class="form-check-input" />
29 + <label asp-for="Poll.AllowMultipleVotes" class="form-check-label"></label>
30 + </div>
31 + <div class="mb-3 form-check">
32 + <input asp-for="Poll.IsAnonymous" class="form-check-input" />
33 + <label asp-for="Poll.IsAnonymous" class="form-check-label"></label>
34 + </div>
35 + <div class="d-flex gap-2">
36 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
37 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
38 + </div>
39 + </form>
40 + </div>
41 +</div>
42 +
43 +@section Scripts {
44 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
45 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Index.cshtml +73 −0
@@ -0,0 +1,73 @@
1 +@model AdminPollIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-bar-chart me-2"></i>@Localizer["Polls"]</h1>
5 + <p class="lead">@Localizer["Manage polls across trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Question"]</th>
33 + <th>@Localizer["Trip"]</th>
34 + <th>@Localizer["Created By"]</th>
35 + <th>@Localizer["Options"]</th>
36 + <th>@Localizer["Multi-Vote"]</th>
37 + <th>@Localizer["Anonymous"]</th>
38 + <th>@Localizer["Closed"]</th>
39 + <th class="text-end">@Localizer["Actions"]</th>
40 + </tr>
41 + </thead>
42 + <tbody>
43 + @foreach (var poll in Model.Items)
44 + {
45 + <tr>
46 + <td>@poll.Question</td>
47 + <td>@poll.Trip?.Name</td>
48 + <td>@(poll.CreatedByUser != null ? $"{poll.CreatedByUser.FirstName} {poll.CreatedByUser.LastName}" : "")</td>
49 + <td>@(poll.Options?.Count ?? 0)</td>
50 + <td>@(poll.AllowMultipleVotes ? Localizer["Yes"] : Localizer["No"])</td>
51 + <td>@(poll.IsAnonymous ? Localizer["Yes"] : Localizer["No"])</td>
52 + <td>@(poll.ClosedAt.HasValue ? Localizer["Yes"] : Localizer["No"])</td>
53 + <td class="text-end">
54 + <div class="admin-action-group">
55 + <a asp-action="Details" asp-route-id="@poll.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
56 + <a asp-action="Edit" asp-route-id="@poll.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
57 + <a asp-action="Delete" asp-route-id="@poll.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
58 + </div>
59 + </td>
60 + </tr>
61 + }
62 + </tbody>
63 + </table>
64 + }
65 + else
66 + {
67 + <div class="admin-empty">
68 + <i class="bi bi-inbox"></i>
69 + <div>@Localizer["No items yet"]</div>
70 + </div>
71 + }
72 + </div>
73 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
@@ -0,0 +1,55 @@
1 +@model AdminSettlementPaymentFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Create"] @Localizer["Settlement payment"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <form asp-action="Create">
13 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
14 +
15 + <div class="form-group mb-3">
16 + <label asp-for="Payment.SettlementPlanId" class="control-label"></label>
17 + <select asp-for="Payment.SettlementPlanId" class="form-control" asp-items="Model.SettlementPlanList"></select>
18 + <span asp-validation-for="Payment.SettlementPlanId" class="text-danger"></span>
19 + </div>
20 +
21 + <div class="form-group mb-3">
22 + <label asp-for="Payment.FromUserId" class="control-label">@Localizer["From user"]</label>
23 + <select asp-for="Payment.FromUserId" class="form-control" asp-items="Model.UserList"></select>
24 + <span asp-validation-for="Payment.FromUserId" class="text-danger"></span>
25 + </div>
26 +
27 + <div class="form-group mb-3">
28 + <label asp-for="Payment.ToUserId" class="control-label">@Localizer["To user"]</label>
29 + <select asp-for="Payment.ToUserId" class="form-control" asp-items="Model.UserList"></select>
30 + <span asp-validation-for="Payment.ToUserId" class="text-danger"></span>
31 + </div>
32 +
33 + <div class="form-group mb-3">
34 + <label asp-for="Payment.Amount" class="control-label"></label>
35 + <input asp-for="Payment.Amount" class="form-control" />
36 + <span asp-validation-for="Payment.Amount" class="text-danger"></span>
37 + </div>
38 +
39 + <div class="form-group mb-3">
40 + <label asp-for="Payment.Status" class="control-label"></label>
41 + <select asp-for="Payment.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.EPaymentStatus>()"></select>
42 + <span asp-validation-for="Payment.Status" class="text-danger"></span>
43 + </div>
44 +
45 + <div class="form-group mb-3 d-flex gap-2">
46 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
47 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
48 + </div>
49 + </form>
50 + </div>
51 +</div>
52 +
53 +@section Scripts {
54 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
55 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +39 −0
@@ -0,0 +1,39 @@
1 +@model AdminDeleteViewModel<SettlementPaymentBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-stack text-danger"></i> @Localizer["Delete settlement payment"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card border-danger">
11 + <div class="admin-card-body">
12 + <div class="alert alert-danger">
13 + <i class="bi bi-exclamation-triangle me-2"></i>
14 + @Localizer["Are you sure you want to delete this settlement payment?"]
15 + </div>
16 +
17 + <dl class="row">
18 + <dt class="col-sm-3">@Localizer["From user"]</dt>
19 + <dd class="col-sm-9">@Model.Item.FromUserFullName</dd>
20 +
21 + <dt class="col-sm-3">@Localizer["To user"]</dt>
22 + <dd class="col-sm-9">@Model.Item.ToUserFullName</dd>
23 +
24 + <dt class="col-sm-3">@Localizer["Amount"]</dt>
25 + <dd class="col-sm-9">@Model.Item.Amount.ToString("0.00")</dd>
26 +
27 + <dt class="col-sm-3">@Localizer["Status"]</dt>
28 + <dd class="col-sm-9">@Model.Item.Status</dd>
29 + </dl>
30 +
31 + <form asp-action="Delete">
32 + <input type="hidden" name="id" value="@Model.Item.Id" />
33 + <div class="d-flex gap-2">
34 + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button>
35 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
36 + </div>
37 + </form>
38 + </div>
39 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +43 −0
@@ -0,0 +1,43 @@
1 +@model AdminDetailsViewModel<SettlementPaymentBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Settlement payment details"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <dl class="row">
13 + <dt class="col-sm-3">@Localizer["From user"]</dt>
14 + <dd class="col-sm-9">@Model.Item.FromUserFullName <small class="text-muted">@Model.Item.FromUser?.Email</small></dd>
15 +
16 + <dt class="col-sm-3">@Localizer["To user"]</dt>
17 + <dd class="col-sm-9">@Model.Item.ToUserFullName <small class="text-muted">@Model.Item.ToUser?.Email</small></dd>
18 +
19 + <dt class="col-sm-3">@Localizer["Amount"]</dt>
20 + <dd class="col-sm-9">@Model.Item.Amount.ToString("0.00")</dd>
21 +
22 + <dt class="col-sm-3">@Localizer["Status"]</dt>
23 + <dd class="col-sm-9"><span class="badge bg-secondary">@Model.Item.Status</span></dd>
24 +
25 + @if (Model.Item.MarkedPaidAt.HasValue)
26 + {
27 + <dt class="col-sm-3">@Localizer["MarkedPaidAt"]</dt>
28 + <dd class="col-sm-9">@Model.Item.MarkedPaidAt.Value.ToString("yyyy-MM-dd HH:mm")</dd>
29 + }
30 +
31 + @if (Model.Item.ConfirmedAt.HasValue)
32 + {
33 + <dt class="col-sm-3">@Localizer["ConfirmedAt"]</dt>
34 + <dd class="col-sm-9">@Model.Item.ConfirmedAt.Value.ToString("yyyy-MM-dd HH:mm")</dd>
35 + }
36 + </dl>
37 +
38 + <div class="d-flex gap-2">
39 + <a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary"><i class="bi bi-pencil me-1"></i>@Localizer["Edit"]</a>
40 + <a asp-action="Delete" asp-route-id="@Model.Item.Id" class="btn btn-outline-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</a>
41 + </div>
42 + </div>
43 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
@@ -0,0 +1,56 @@
1 +@model AdminSettlementPaymentFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-stack"></i> @Localizer["Edit"] @Localizer["Settlement payment"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <form asp-action="Edit">
13 + <input type="hidden" asp-for="Payment.Id" />
14 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
15 +
16 + <div class="form-group mb-3">
17 + <label asp-for="Payment.SettlementPlanId" class="control-label"></label>
18 + <select asp-for="Payment.SettlementPlanId" class="form-control" asp-items="Model.SettlementPlanList"></select>
19 + <span asp-validation-for="Payment.SettlementPlanId" class="text-danger"></span>
20 + </div>
21 +
22 + <div class="form-group mb-3">
23 + <label asp-for="Payment.FromUserId" class="control-label">@Localizer["From user"]</label>
24 + <select asp-for="Payment.FromUserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="Payment.FromUserId" class="text-danger"></span>
26 + </div>
27 +
28 + <div class="form-group mb-3">
29 + <label asp-for="Payment.ToUserId" class="control-label">@Localizer["To user"]</label>
30 + <select asp-for="Payment.ToUserId" class="form-control" asp-items="Model.UserList"></select>
31 + <span asp-validation-for="Payment.ToUserId" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="form-group mb-3">
35 + <label asp-for="Payment.Amount" class="control-label"></label>
36 + <input asp-for="Payment.Amount" class="form-control" />
37 + <span asp-validation-for="Payment.Amount" class="text-danger"></span>
38 + </div>
39 +
40 + <div class="form-group mb-3">
41 + <label asp-for="Payment.Status" class="control-label"></label>
42 + <select asp-for="Payment.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.EPaymentStatus>()"></select>
43 + <span asp-validation-for="Payment.Status" class="text-danger"></span>
44 + </div>
45 +
46 + <div class="form-group mb-3 d-flex gap-2">
47 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
48 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
49 + </div>
50 + </form>
51 + </div>
52 +</div>
53 +
54 +@section Scripts {
55 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
56 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +67 −0
@@ -0,0 +1,67 @@
1 +@model AdminSettlementPaymentIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-credit-card-2-front me-2"></i>@Localizer["SettlementPayments"]</h1>
5 + <p class="lead">@Localizer["Review settlement payments across trips"]</p>
6 + </div>
7 +</div>
8 +
9 +<div class="admin-card mb-3">
10 + <div class="admin-card-body">
11 + <form method="get" class="row g-2 align-items-end">
12 + <div class="col-auto">
13 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
14 + </div>
15 + <div class="col-auto">
16 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
17 + </div>
18 + </form>
19 + </div>
20 +</div>
21 +
22 +<div class="admin-card">
23 + <div class="admin-card-body p-0">
24 + @if (Model.Items.Any())
25 + {
26 + <table class="table admin-table mb-0">
27 + <thead>
28 + <tr>
29 + <th>@Localizer["FromUser"]</th>
30 + <th>@Localizer["ToUser"]</th>
31 + <th class="text-end">@Localizer["Amount"]</th>
32 + <th>@Localizer["MarkedPaidAt"]</th>
33 + <th>@Localizer["ConfirmedAt"]</th>
34 + <th>@Localizer["Status"]</th>
35 + <th class="text-end">@Localizer["Actions"]</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + @foreach (var item in Model.Items)
40 + {
41 + <tr>
42 + <td>@item.FromUser?.Email</td>
43 + <td>@item.ToUser?.Email</td>
44 + <td class="text-end fw-bold">@item.Amount.ToString("F2")</td>
45 + <td>@item.MarkedPaidAt?.ToString("g")</td>
46 + <td>@item.ConfirmedAt?.ToString("g")</td>
47 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
48 + <td class="text-end">
49 + <div class="admin-action-group">
50 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
51 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
52 + </div>
53 + </td>
54 + </tr>
55 + }
56 + </tbody>
57 + </table>
58 + }
59 + else
60 + {
61 + <div class="admin-empty">
62 + <i class="bi bi-inbox"></i>
63 + <div>@Localizer["No items yet"]</div>
64 + </div>
65 + }
66 + </div>
67 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
@@ -0,0 +1,52 @@
1 +@model AdminSettlementPlanFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Create"] @Localizer["SettlementPlan"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["SettlementPlan"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="SettlementPlan.TripId" class="control-label"></label>
19 + <select asp-for="SettlementPlan.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="SettlementPlan.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="SettlementPlan.CreatedByUserId" class="control-label"></label>
24 + <select asp-for="SettlementPlan.CreatedByUserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="SettlementPlan.CreatedByUserId" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="SettlementPlan.TotalAmount" class="control-label"></label>
29 + <input asp-for="SettlementPlan.TotalAmount" class="form-control" />
30 + <span asp-validation-for="SettlementPlan.TotalAmount" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="SettlementPlan.Status" class="control-label"></label>
34 + <select asp-for="SettlementPlan.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESettlementStatus>()"></select>
35 + <span asp-validation-for="SettlementPlan.Status" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <label asp-for="SettlementPlan.CompletedAt" class="control-label"></label>
39 + <input asp-for="SettlementPlan.CompletedAt" class="form-control" type="date" />
40 + <span asp-validation-for="SettlementPlan.CompletedAt" class="text-danger"></span>
41 + </div>
42 + <div class="form-group mb-3 d-flex gap-2">
43 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
44 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
45 + </div>
46 + </form>
47 + </div>
48 +</div>
49 +
50 +@section Scripts {
51 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
52 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<SettlementPlanBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["SettlementPlan"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["TotalAmount"]</dt>
14 + <dd class="col-sm-10">@Model.Item.TotalAmount</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Status"]</dt>
17 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["CreatedByUser"]</dt>
20 + <dd class="col-sm-10">@Model.Item.CreatedByUser?.Email</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDetailsViewModel<SettlementPlanBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["SettlementPlan"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["CreatedByUser"]</dt>
13 + <dd class="col-sm-10">@Model.Item.CreatedByUser?.Email</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["TotalAmount"]</dt>
16 + <dd class="col-sm-10">@Model.Item.TotalAmount</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["Status"]</dt>
19 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["CompletedAt"]</dt>
22 + <dd class="col-sm-10">@Model.Item.CompletedAt?.ToString("d")</dd>
23 + </dl>
24 +</div>
25 +<div>
26 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
27 + <a asp-action="Index">@Localizer["Back"]</a>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
@@ -0,0 +1,53 @@
1 +@model AdminSettlementPlanFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Edit"] @Localizer["SettlementPlan"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["SettlementPlan"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="SettlementPlan.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="SettlementPlan.TripId" class="control-label"></label>
20 + <select asp-for="SettlementPlan.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="SettlementPlan.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label asp-for="SettlementPlan.CreatedByUserId" class="control-label"></label>
25 + <select asp-for="SettlementPlan.CreatedByUserId" class="form-control" asp-items="Model.UserList"></select>
26 + <span asp-validation-for="SettlementPlan.CreatedByUserId" class="text-danger"></span>
27 + </div>
28 + <div class="form-group mb-3">
29 + <label asp-for="SettlementPlan.TotalAmount" class="control-label"></label>
30 + <input asp-for="SettlementPlan.TotalAmount" class="form-control" />
31 + <span asp-validation-for="SettlementPlan.TotalAmount" class="text-danger"></span>
32 + </div>
33 + <div class="form-group mb-3">
34 + <label asp-for="SettlementPlan.Status" class="control-label"></label>
35 + <select asp-for="SettlementPlan.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESettlementStatus>()"></select>
36 + <span asp-validation-for="SettlementPlan.Status" class="text-danger"></span>
37 + </div>
38 + <div class="form-group mb-3">
39 + <label asp-for="SettlementPlan.CompletedAt" class="control-label"></label>
40 + <input asp-for="SettlementPlan.CompletedAt" class="form-control" type="date" />
41 + <span asp-validation-for="SettlementPlan.CompletedAt" class="text-danger"></span>
42 + </div>
43 + <div class="form-group mb-3 d-flex gap-2">
44 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
45 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
46 + </div>
47 + </form>
48 + </div>
49 +</div>
50 +
51 +@section Scripts {
52 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
53 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +73 −0
@@ -0,0 +1,73 @@
1 +@model AdminSettlementPlanIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-diagram-3 me-2"></i>@Localizer["SettlementPlans"]</h1>
5 + <p class="lead">@Localizer["Manage settlement plans for trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
26 + </div>
27 + </form>
28 + </div>
29 +</div>
30 +
31 +<div class="admin-card">
32 + <div class="admin-card-body p-0">
33 + @if (Model.Items.Any())
34 + {
35 + <table class="table admin-table mb-0">
36 + <thead>
37 + <tr>
38 + <th>@Localizer["Trip"]</th>
39 + <th class="text-end">@Localizer["TotalAmount"]</th>
40 + <th>@Localizer["CreatedByUser"]</th>
41 + <th>@Localizer["Status"]</th>
42 + <th class="text-end">@Localizer["Actions"]</th>
43 + </tr>
44 + </thead>
45 + <tbody>
46 + @foreach (var item in Model.Items)
47 + {
48 + <tr>
49 + <td>@item.Trip?.Name</td>
50 + <td class="text-end fw-bold">@item.TotalAmount</td>
51 + <td>@item.CreatedByUser?.Email</td>
52 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
53 + <td class="text-end">
54 + <div class="admin-action-group">
55 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
56 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
57 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
58 + </div>
59 + </td>
60 + </tr>
61 + }
62 + </tbody>
63 + </table>
64 + }
65 + else
66 + {
67 + <div class="admin-empty">
68 + <i class="bi bi-inbox"></i>
69 + <div>@Localizer["No items yet"]</div>
70 + </div>
71 + }
72 + </div>
73 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +126 −0
@@ -0,0 +1,126 @@
1 +@using Microsoft.AspNetCore.Identity
2 +@inject SignInManager<AppUser> _signInManager
3 +@{
4 + var pageTitle = (Model as ITitledViewModel)?.Title;
5 + if (string.IsNullOrWhiteSpace(pageTitle)) pageTitle = "Admin";
6 + var ctx = ViewContext.RouteData.Values;
7 + var currentController = (ctx["controller"] as string ?? "").ToLowerInvariant();
8 + bool IsActive(string name) => currentController == name.ToLowerInvariant();
9 +}
10 +
11 +<!DOCTYPE html>
12 +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName">
13 +<head>
14 + <meta charset="utf-8" />
15 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
16 + <meta name="theme-color" content="#e8604c" />
17 + <title>@pageTitle - SplitApp Admin</title>
18 +
19 + <link rel="preconnect" href="https://fonts.googleapis.com" />
20 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
21 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
22 +
23 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
24 + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
25 + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
26 + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" />
27 + <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
28 +</head>
29 +<body class="admin-body">
30 + <div id="sa-toast-container" class="sa-toast-container"></div>
31 + <div id="sa-tempdata-messages" style="display:none"
32 + data-success="@TempData["Success"]"
33 + data-error="@TempData["Error"]"
34 + data-warning="@TempData["Warning"]"></div>
35 +
36 + <div class="admin-shell">
37 + <aside class="admin-sidebar">
38 + <div class="admin-sidebar-brand">
39 + <i class="bi bi-airplane-fill"></i>
40 + <span>SplitApp</span>
41 + <small class="admin-sidebar-brand-sub">Admin</small>
42 + </div>
43 + <nav class="admin-sidebar-nav">
44 + <a class="admin-nav-link @(IsActive("Dashboard") ? "active" : "")" asp-area="Admin" asp-controller="Dashboard" asp-action="Index">
45 + <i class="bi bi-speedometer2"></i><span>@Localizer["Dashboard"]</span>
46 + </a>
47 + <div class="admin-nav-section">@Localizer["Core"]</div>
48 + <a class="admin-nav-link @(IsActive("Trips") ? "active" : "")" asp-area="Admin" asp-controller="Trips" asp-action="Index">
49 + <i class="bi bi-suitcase-lg"></i><span>@Localizer["Trips"]</span>
50 + </a>
51 + <a class="admin-nav-link @(IsActive("Expenses") ? "active" : "")" asp-area="Admin" asp-controller="Expenses" asp-action="Index">
52 + <i class="bi bi-cash-coin"></i><span>@Localizer["Expenses"]</span>
53 + </a>
54 + <a class="admin-nav-link @(IsActive("BudgetCategories") ? "active" : "")" asp-area="Admin" asp-controller="BudgetCategories" asp-action="Index">
55 + <i class="bi bi-tags"></i><span>@Localizer["Budget Categories"]</span>
56 + </a>
57 + <a class="admin-nav-link @(IsActive("Currencies") ? "active" : "")" asp-area="Admin" asp-controller="Currencies" asp-action="Index">
58 + <i class="bi bi-currency-exchange"></i><span>@Localizer["Currencies"]</span>
59 + </a>
60 + <div class="admin-nav-section">@Localizer["Activity"]</div>
61 + <a class="admin-nav-link @(IsActive("Polls") ? "active" : "")" asp-area="Admin" asp-controller="Polls" asp-action="Index">
62 + <i class="bi bi-bar-chart"></i><span>@Localizer["Polls"]</span>
63 + </a>
64 + <a class="admin-nav-link @(IsActive("Wishlist") ? "active" : "")" asp-area="Admin" asp-controller="Wishlist" asp-action="Index">
65 + <i class="bi bi-stars"></i><span>@Localizer["Wishlist"]</span>
66 + </a>
67 + <a class="admin-nav-link @(IsActive("Invitations") ? "active" : "")" asp-area="Admin" asp-controller="Invitations" asp-action="Index">
68 + <i class="bi bi-envelope"></i><span>@Localizer["Invitations"]</span>
69 + </a>
70 + <div class="admin-nav-section">@Localizer["Settlements"]</div>
71 + <a class="admin-nav-link @(IsActive("SettlementPlans") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPlans" asp-action="Index">
72 + <i class="bi bi-diagram-3"></i><span>@Localizer["Settlement Plans"]</span>
73 + </a>
74 + <a class="admin-nav-link @(IsActive("SettlementPayments") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPayments" asp-action="Index">
75 + <i class="bi bi-credit-card-2-front"></i><span>@Localizer["Settlement Payments"]</span>
76 + </a>
77 + <a class="admin-nav-link @(IsActive("SplitPresets") ? "active" : "")" asp-area="Admin" asp-controller="SplitPresets" asp-action="Index">
78 + <i class="bi bi-pie-chart"></i><span>@Localizer["Split Presets"]</span>
79 + </a>
80 + <a class="admin-nav-link @(IsActive("TripParticipants") ? "active" : "")" asp-area="Admin" asp-controller="TripParticipants" asp-action="Index">
81 + <i class="bi bi-people"></i><span>@Localizer["Trip Participants"]</span>
82 + </a>
83 + <div class="admin-nav-section">@Localizer["System"]</div>
84 + <a class="admin-nav-link @(IsActive("Users") ? "active" : "")" asp-area="Admin" asp-controller="Users" asp-action="Index">
85 + <i class="bi bi-person-gear"></i><span>@Localizer["Users"]</span>
86 + </a>
87 + </nav>
88 + <div class="admin-sidebar-footer">
89 + <a asp-area="" asp-controller="Home" asp-action="Index" class="admin-back-link">
90 + <i class="bi bi-arrow-left"></i>@Localizer["Back to site"]
91 + </a>
92 + </div>
93 + </aside>
94 +
95 + <div class="admin-main">
96 + <header class="admin-topbar">
97 + <button class="admin-topbar-toggle d-md-none" type="button" onclick="document.body.classList.toggle('admin-sidebar-open')" aria-label="Toggle sidebar">
98 + <i class="bi bi-list"></i>
99 + </button>
100 + <div class="admin-topbar-title">
101 + <i class="bi bi-shield-lock"></i> @pageTitle
102 + </div>
103 + <div class="admin-topbar-actions">
104 + <partial name="_LanguageSelection" />
105 + <partial name="_LoginPartial" />
106 + </div>
107 + </header>
108 +
109 + <main role="main" class="admin-content sa-animate-fade-in">
110 + @RenderBody()
111 + </main>
112 +
113 + <footer class="admin-footer">
114 + <span><i class="bi bi-airplane-fill me-1"></i> SplitApp Admin &copy; 2026 TalTech</span>
115 + <span>@Thread.CurrentThread.CurrentUICulture.Name</span>
116 + </footer>
117 + </div>
118 + </div>
119 +
120 + <script src="~/lib/jquery/dist/jquery.min.js"></script>
121 + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
122 + <script src="~/js/splitapp.js" asp-append-version="true"></script>
123 + <script src="~/js/site.js" asp-append-version="true"></script>
124 + @await RenderSectionAsync("Scripts", required: false)
125 +</body>
126 +</html>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
@@ -0,0 +1,52 @@
1 +@model AdminSplitPresetFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-pie-chart"></i> @Localizer["Create"] @Localizer["SplitPreset"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["SplitPreset"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 +
18 + <div class="form-group mb-3">
19 + <label asp-for="SplitPreset.Name" class="control-label"></label>
20 + <input asp-for="SplitPreset.Name" class="form-control" />
21 + <span asp-validation-for="SplitPreset.Name" class="text-danger"></span>
22 + </div>
23 +
24 + <div class="form-group mb-3">
25 + <label asp-for="SplitPreset.SplitMethod" class="control-label"></label>
26 + <select asp-for="SplitPreset.SplitMethod" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESplitMethod>()"></select>
27 + <span asp-validation-for="SplitPreset.SplitMethod" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="form-group mb-3">
31 + <label asp-for="SplitPreset.TripId" class="control-label">@Localizer["Trip"]</label>
32 + <select asp-for="SplitPreset.TripId" class="form-control" asp-items="Model.TripList"></select>
33 + <span asp-validation-for="SplitPreset.TripId" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="form-group mb-3">
37 + <label asp-for="SplitPreset.CreatedById" class="control-label">@Localizer["CreatedBy"]</label>
38 + <select asp-for="SplitPreset.CreatedById" class="form-control" asp-items="Model.UserList"></select>
39 + <span asp-validation-for="SplitPreset.CreatedById" class="text-danger"></span>
40 + </div>
41 +
42 + <div class="form-group mb-3 d-flex gap-2">
43 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
44 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
45 + </div>
46 + </form>
47 + </div>
48 +</div>
49 +
50 +@section Scripts {
51 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
52 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<SplitPresetBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["SplitPreset"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Name"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt>
14 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
17 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
20 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +50 −0
@@ -0,0 +1,50 @@
1 +@model AdminDetailsViewModel<SplitPresetBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["SplitPreset"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Name"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt>
13 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
16 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
19 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
20 + </dl>
21 +</div>
22 +
23 +@if (Model.Item.Members != null && Model.Item.Members.Any())
24 +{
25 + <h5>@Localizer["Members"]</h5>
26 + <table class="table">
27 + <thead>
28 + <tr>
29 + <th>@Localizer["User"]</th>
30 + <th>@Localizer["ShareWeight"]</th>
31 + <th>@Localizer["Percentage"]</th>
32 + </tr>
33 + </thead>
34 + <tbody>
35 + @foreach (var member in Model.Item.Members)
36 + {
37 + <tr>
38 + <td>@member.UserFullName</td>
39 + <td>@member.ShareWeight</td>
40 + <td>@(member.Percentage != null ? $"{member.Percentage}%" : "")</td>
41 + </tr>
42 + }
43 + </tbody>
44 + </table>
45 +}
46 +
47 +<div>
48 + <a asp-action="Delete" asp-route-id="@Model.Item.Id">@Localizer["Delete"]</a> |
49 + <a asp-action="Index">@Localizer["Back"]</a>
50 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +64 −0
@@ -0,0 +1,64 @@
1 +@model AdminSplitPresetIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-pie-chart me-2"></i>@Localizer["SplitPresets"]</h1>
5 + <p class="lead">@Localizer["Review split presets used on trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary"><i class="bi bi-plus-circle me-1"></i>@Localizer["Create"]</a>
8 +</div>
9 +
10 +<div class="admin-card mb-3">
11 + <div class="admin-card-body">
12 + <form method="get" class="row g-2 align-items-end">
13 + <div class="col-auto">
14 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
15 + </div>
16 + <div class="col-auto">
17 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
18 + </div>
19 + </form>
20 + </div>
21 +</div>
22 +
23 +<div class="admin-card">
24 + <div class="admin-card-body p-0">
25 + @if (Model.Items.Any())
26 + {
27 + <table class="table admin-table mb-0">
28 + <thead>
29 + <tr>
30 + <th>@Localizer["Name"]</th>
31 + <th>@Localizer["SplitMethod"]</th>
32 + <th>@Localizer["Trip"]</th>
33 + <th>@Localizer["CreatedBy"]</th>
34 + <th class="text-end">@Localizer["Actions"]</th>
35 + </tr>
36 + </thead>
37 + <tbody>
38 + @foreach (var item in Model.Items)
39 + {
40 + <tr>
41 + <td>@item.Name</td>
42 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.SplitMethod)</td>
43 + <td>@item.Trip?.Name</td>
44 + <td>@item.CreatedBy?.Email</td>
45 + <td class="text-end">
46 + <div class="admin-action-group">
47 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
48 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
49 + </div>
50 + </td>
51 + </tr>
52 + }
53 + </tbody>
54 + </table>
55 + }
56 + else
57 + {
58 + <div class="admin-empty">
59 + <i class="bi bi-inbox"></i>
60 + <div>@Localizer["No items yet"]</div>
61 + </div>
62 + }
63 + </div>
64 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
@@ -0,0 +1,53 @@
1 +@model AdminTripParticipantFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-people-fill"></i> @Localizer["Create"] @Localizer["TripParticipant"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["TripParticipant"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="TripParticipant.TripId" class="control-label"></label>
19 + <select asp-for="TripParticipant.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="TripParticipant.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="TripParticipant.UserId" class="control-label"></label>
24 + <select asp-for="TripParticipant.UserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="TripParticipant.UserId" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="TripParticipant.Role" class="control-label"></label>
29 + <select asp-for="TripParticipant.Role" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EParticipantRole>()"></select>
30 + <span asp-validation-for="TripParticipant.Role" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="TripParticipant.Nickname" class="control-label"></label>
34 + <input asp-for="TripParticipant.Nickname" class="form-control" />
35 + <span asp-validation-for="TripParticipant.Nickname" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <div class="form-check">
39 + <input asp-for="TripParticipant.IsActive" class="form-check-input" />
40 + <label asp-for="TripParticipant.IsActive" class="form-check-label"></label>
41 + </div>
42 + </div>
43 + <div class="form-group mb-3 d-flex gap-2">
44 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
45 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
46 + </div>
47 + </form>
48 + </div>
49 +</div>
50 +
51 +@section Scripts {
52 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
53 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<TripParticipantBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["TripParticipant"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["User"]</dt>
14 + <dd class="col-sm-10">@Model.Item.User?.Email</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Role"]</dt>
17 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Role)</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["IsActive"]</dt>
20 + <dd class="col-sm-10">@(Model.Item.IsActive ? Localizer["Yes"] : Localizer["No"])</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +34 −0
@@ -0,0 +1,34 @@
1 +@model AdminDetailsViewModel<TripParticipantBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["TripParticipant"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["User"]</dt>
13 + <dd class="col-sm-10">@Model.Item.User?.Email</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Role"]</dt>
16 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Role)</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["Nickname"]</dt>
19 + <dd class="col-sm-10">@Model.Item.Nickname</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["JoinedAt"]</dt>
22 + <dd class="col-sm-10">@Model.Item.JoinedAt.ToString("d")</dd>
23 +
24 + <dt class="col-sm-2">@Localizer["LeftAt"]</dt>
25 + <dd class="col-sm-10">@Model.Item.LeftAt?.ToString("d")</dd>
26 +
27 + <dt class="col-sm-2">@Localizer["IsActive"]</dt>
28 + <dd class="col-sm-10">@(Model.Item.IsActive ? Localizer["Yes"] : Localizer["No"])</dd>
29 + </dl>
30 +</div>
31 +<div>
32 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
33 + <a asp-action="Index">@Localizer["Back"]</a>
34 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
@@ -0,0 +1,54 @@
1 +@model AdminTripParticipantFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-people-fill"></i> @Localizer["Edit"] @Localizer["TripParticipant"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["TripParticipant"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="TripParticipant.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="TripParticipant.TripId" class="control-label"></label>
20 + <select asp-for="TripParticipant.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="TripParticipant.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label asp-for="TripParticipant.UserId" class="control-label"></label>
25 + <select asp-for="TripParticipant.UserId" class="form-control" asp-items="Model.UserList"></select>
26 + <span asp-validation-for="TripParticipant.UserId" class="text-danger"></span>
27 + </div>
28 + <div class="form-group mb-3">
29 + <label asp-for="TripParticipant.Role" class="control-label"></label>
30 + <select asp-for="TripParticipant.Role" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EParticipantRole>()"></select>
31 + <span asp-validation-for="TripParticipant.Role" class="text-danger"></span>
32 + </div>
33 + <div class="form-group mb-3">
34 + <label asp-for="TripParticipant.Nickname" class="control-label"></label>
35 + <input asp-for="TripParticipant.Nickname" class="form-control" />
36 + <span asp-validation-for="TripParticipant.Nickname" class="text-danger"></span>
37 + </div>
38 + <div class="form-group mb-3">
39 + <div class="form-check">
40 + <input asp-for="TripParticipant.IsActive" class="form-check-input" />
41 + <label asp-for="TripParticipant.IsActive" class="form-check-label"></label>
42 + </div>
43 + </div>
44 + <div class="form-group mb-3 d-flex gap-2">
45 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
46 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 +</div>
51 +
52 +@section Scripts {
53 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
54 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +78 −0
@@ -0,0 +1,78 @@
1 +@model AdminTripParticipantIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-people me-2"></i>@Localizer["TripParticipants"]</h1>
5 + <p class="lead">@Localizer["Manage trip participants and roles"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
26 + </div>
27 + <div class="col-auto">
28 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
29 + </div>
30 + </form>
31 + </div>
32 +</div>
33 +
34 +<div class="admin-card">
35 + <div class="admin-card-body p-0">
36 + @if (Model.Items.Any())
37 + {
38 + <table class="table admin-table mb-0">
39 + <thead>
40 + <tr>
41 + <th>@Localizer["User"]</th>
42 + <th>@Localizer["Trip"]</th>
43 + <th>@Localizer["Role"]</th>
44 + <th>@Localizer["JoinedAt"]</th>
45 + <th>@Localizer["IsActive"]</th>
46 + <th class="text-end">@Localizer["Actions"]</th>
47 + </tr>
48 + </thead>
49 + <tbody>
50 + @foreach (var item in Model.Items)
51 + {
52 + <tr>
53 + <td>@item.User?.Email</td>
54 + <td>@item.Trip?.Name</td>
55 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Role)</td>
56 + <td>@item.JoinedAt.ToString("d")</td>
57 + <td>@(item.IsActive ? Localizer["Yes"] : Localizer["No"])</td>
58 + <td class="text-end">
59 + <div class="admin-action-group">
60 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
61 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
62 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
63 + </div>
64 + </td>
65 + </tr>
66 + }
67 + </tbody>
68 + </table>
69 + }
70 + else
71 + {
72 + <div class="admin-empty">
73 + <i class="bi bi-inbox"></i>
74 + <div>@Localizer["No items yet"]</div>
75 + </div>
76 + }
77 + </div>
78 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
@@ -0,0 +1,62 @@
1 +@model AdminTripFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-geo-alt-fill"></i> @Localizer["Create"] @Localizer["Trip"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Trip"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="Trip.Name" class="control-label"></label>
19 + <input asp-for="Trip.Name" class="form-control" />
20 + <span asp-validation-for="Trip.Name" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="Trip.Description" class="control-label"></label>
24 + <textarea asp-for="Trip.Description" class="form-control"></textarea>
25 + <span asp-validation-for="Trip.Description" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="Trip.Destination" class="control-label"></label>
29 + <input asp-for="Trip.Destination" class="form-control" />
30 + <span asp-validation-for="Trip.Destination" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="Trip.StartDate" class="control-label"></label>
34 + <input asp-for="Trip.StartDate" class="form-control" type="date" />
35 + <span asp-validation-for="Trip.StartDate" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <label asp-for="Trip.EndDate" class="control-label"></label>
39 + <input asp-for="Trip.EndDate" class="form-control" type="date" />
40 + <span asp-validation-for="Trip.EndDate" class="text-danger"></span>
41 + </div>
42 + <div class="form-group mb-3">
43 + <label asp-for="Trip.Status" class="control-label"></label>
44 + <select asp-for="Trip.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.ETripStatus>()"></select>
45 + <span asp-validation-for="Trip.Status" class="text-danger"></span>
46 + </div>
47 + <div class="form-group mb-3">
48 + <label asp-for="Trip.DefaultCurrencyId" class="control-label"></label>
49 + <select asp-for="Trip.DefaultCurrencyId" class="form-control" asp-items="Model.CurrencyList"></select>
50 + <span asp-validation-for="Trip.DefaultCurrencyId" class="text-danger"></span>
51 + </div>
52 + <div class="form-group mb-3 d-flex gap-2">
53 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
54 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
55 + </div>
56 + </form>
57 + </div>
58 +</div>
59 +
60 +@section Scripts {
61 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
62 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<TripBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["Trip"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Name"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Destination"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Destination</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Status"]</dt>
17 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
20 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Details.cshtml +37 −0
@@ -0,0 +1,37 @@
1 +@model AdminDetailsViewModel<TripBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["Trip"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Name"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Description"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Description</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Destination"]</dt>
16 + <dd class="col-sm-10">@Model.Item.Destination</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["StartDate"]</dt>
19 + <dd class="col-sm-10">@Model.Item.StartDate?.ToString("d")</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["EndDate"]</dt>
22 + <dd class="col-sm-10">@Model.Item.EndDate?.ToString("d")</dd>
23 +
24 + <dt class="col-sm-2">@Localizer["Status"]</dt>
25 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
26 +
27 + <dt class="col-sm-2">@Localizer["DefaultCurrency"]</dt>
28 + <dd class="col-sm-10">@Model.Item.DefaultCurrency?.Code</dd>
29 +
30 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
31 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
32 + </dl>
33 +</div>
34 +<div>
35 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
36 + <a asp-action="Index">@Localizer["Back"]</a>
37 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
@@ -0,0 +1,64 @@
1 +@model AdminTripFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-geo-alt-fill"></i> @Localizer["Edit"] @Localizer["Trip"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Trip"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="Trip.Id" />
18 + <input type="hidden" asp-for="Trip.CreatedById" />
19 + <div class="form-group mb-3">
20 + <label asp-for="Trip.Name" class="control-label"></label>
21 + <input asp-for="Trip.Name" class="form-control" />
22 + <span asp-validation-for="Trip.Name" class="text-danger"></span>
23 + </div>
24 + <div class="form-group mb-3">
25 + <label asp-for="Trip.Description" class="control-label"></label>
26 + <textarea asp-for="Trip.Description" class="form-control"></textarea>
27 + <span asp-validation-for="Trip.Description" class="text-danger"></span>
28 + </div>
29 + <div class="form-group mb-3">
30 + <label asp-for="Trip.Destination" class="control-label"></label>
31 + <input asp-for="Trip.Destination" class="form-control" />
32 + <span asp-validation-for="Trip.Destination" class="text-danger"></span>
33 + </div>
34 + <div class="form-group mb-3">
35 + <label asp-for="Trip.StartDate" class="control-label"></label>
36 + <input asp-for="Trip.StartDate" class="form-control" type="date" />
37 + <span asp-validation-for="Trip.StartDate" class="text-danger"></span>
38 + </div>
39 + <div class="form-group mb-3">
40 + <label asp-for="Trip.EndDate" class="control-label"></label>
41 + <input asp-for="Trip.EndDate" class="form-control" type="date" />
42 + <span asp-validation-for="Trip.EndDate" class="text-danger"></span>
43 + </div>
44 + <div class="form-group mb-3">
45 + <label asp-for="Trip.Status" class="control-label"></label>
46 + <select asp-for="Trip.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.ETripStatus>()"></select>
47 + <span asp-validation-for="Trip.Status" class="text-danger"></span>
48 + </div>
49 + <div class="form-group mb-3">
50 + <label asp-for="Trip.DefaultCurrencyId" class="control-label"></label>
51 + <select asp-for="Trip.DefaultCurrencyId" class="form-control" asp-items="Model.CurrencyList"></select>
52 + <span asp-validation-for="Trip.DefaultCurrencyId" class="text-danger"></span>
53 + </div>
54 + <div class="form-group mb-3 d-flex gap-2">
55 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
56 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
57 + </div>
58 + </form>
59 + </div>
60 +</div>
61 +
62 +@section Scripts {
63 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
64 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Index.cshtml +69 −0
@@ -0,0 +1,69 @@
1 +@model AdminTripIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-suitcase-lg me-2"></i>@Localizer["Trips"]</h1>
5 + <p class="lead">@Localizer["Manage all trips across the platform"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Name"]</th>
33 + <th>@Localizer["Destination"]</th>
34 + <th>@Localizer["StartDate"]</th>
35 + <th>@Localizer["CreatedBy"]</th>
36 + <th>@Localizer["Status"]</th>
37 + <th class="text-end">@Localizer["Actions"]</th>
38 + </tr>
39 + </thead>
40 + <tbody>
41 + @foreach (var item in Model.Items)
42 + {
43 + <tr>
44 + <td>@item.Name</td>
45 + <td>@item.Destination</td>
46 + <td>@item.StartDate?.ToString("d")</td>
47 + <td>@item.CreatedBy?.Email</td>
48 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
49 + <td class="text-end">
50 + <div class="admin-action-group">
51 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
52 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
53 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
54 + </div>
55 + </td>
56 + </tr>
57 + }
58 + </tbody>
59 + </table>
60 + }
61 + else
62 + {
63 + <div class="admin-empty">
64 + <i class="bi bi-inbox"></i>
65 + <div>@Localizer["No items yet"]</div>
66 + </div>
67 + }
68 + </div>
69 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
@@ -0,0 +1,44 @@
1 +@model AdminUserDetailsViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-x text-danger"></i> @Localizer["Delete user"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card border-danger">
11 + <div class="admin-card-body">
12 + <div class="alert alert-danger">
13 + <i class="bi bi-exclamation-triangle me-2"></i>
14 + @Localizer["Are you sure you want to delete this user? This action cannot be undone."]
15 + </div>
16 +
17 + <dl class="row">
18 + <dt class="col-sm-3">@Localizer["Email"]</dt>
19 + <dd class="col-sm-9">@Model.Email</dd>
20 +
21 + <dt class="col-sm-3">@Localizer["FirstName"]</dt>
22 + <dd class="col-sm-9">@Model.FirstName</dd>
23 +
24 + <dt class="col-sm-3">@Localizer["LastName"]</dt>
25 + <dd class="col-sm-9">@Model.LastName</dd>
26 +
27 + <dt class="col-sm-3">@Localizer["Roles"]</dt>
28 + <dd class="col-sm-9">
29 + @foreach (var role in Model.Roles)
30 + {
31 + <span class="badge bg-secondary me-1">@role</span>
32 + }
33 + </dd>
34 + </dl>
35 +
36 + <form asp-action="Delete">
37 + <input type="hidden" name="id" value="@Model.Id" />
38 + <div class="d-flex gap-2">
39 + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button>
40 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
41 + </div>
42 + </form>
43 + </div>
44 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
@@ -0,0 +1,44 @@
1 +@model AdminUserDetailsViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-circle"></i> @Localizer["User details"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <dl class="row">
13 + <dt class="col-sm-3">@Localizer["Email"]</dt>
14 + <dd class="col-sm-9">@Model.Email</dd>
15 +
16 + <dt class="col-sm-3">@Localizer["FirstName"]</dt>
17 + <dd class="col-sm-9">@Model.FirstName</dd>
18 +
19 + <dt class="col-sm-3">@Localizer["LastName"]</dt>
20 + <dd class="col-sm-9">@Model.LastName</dd>
21 +
22 + <dt class="col-sm-3">@Localizer["Roles"]</dt>
23 + <dd class="col-sm-9">
24 + @if (Model.Roles.Any())
25 + {
26 + foreach (var role in Model.Roles)
27 + {
28 + <span class="badge bg-secondary me-1">@role</span>
29 + }
30 + }
31 + else
32 + {
33 + <span class="text-muted">@Localizer["No roles"]</span>
34 + }
35 + </dd>
36 + </dl>
37 +
38 + <div class="d-flex gap-2">
39 + <a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary"><i class="bi bi-pencil me-1"></i>@Localizer["Edit"]</a>
40 + <a asp-action="EditRoles" asp-route-id="@Model.Id" class="btn btn-outline-primary"><i class="bi bi-shield me-1"></i>@Localizer["Edit roles"]</a>
41 + <a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</a>
42 + </div>
43 + </div>
44 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
@@ -0,0 +1,44 @@
1 +@model AdminUserEditViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-gear"></i> @Localizer["Edit user"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <form asp-action="Edit">
13 + <input type="hidden" asp-for="Id" />
14 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
15 +
16 + <div class="form-group mb-3">
17 + <label asp-for="Email" class="control-label"></label>
18 + <input asp-for="Email" class="form-control" readonly />
19 + <small class="text-muted">@Localizer["Email is read-only"]</small>
20 + </div>
21 +
22 + <div class="form-group mb-3">
23 + <label asp-for="FirstName" class="control-label"></label>
24 + <input asp-for="FirstName" class="form-control" />
25 + <span asp-validation-for="FirstName" class="text-danger"></span>
26 + </div>
27 +
28 + <div class="form-group mb-3">
29 + <label asp-for="LastName" class="control-label"></label>
30 + <input asp-for="LastName" class="form-control" />
31 + <span asp-validation-for="LastName" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="form-group mb-3 d-flex gap-2">
35 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
36 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
37 + </div>
38 + </form>
39 + </div>
40 +</div>
41 +
42 +@section Scripts {
43 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
44 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
@@ -0,0 +1,29 @@
1 +@model AdminEditRolesViewModel
2 +
3 +<h1>@Localizer["Edit Roles"]</h1>
4 +<h4>@Model.UserName (@Model.UserEmail)</h4>
5 +<hr />
6 +
7 +<div class="row">
8 + <div class="col-md-4">
9 + <form asp-action="EditRoles">
10 + @for (var i = 0; i < Model.Roles.Count; i++)
11 + {
12 + <input type="hidden" name="Roles[@i].RoleName" value="@Model.Roles[i].RoleName" />
13 + <div class="form-check mb-2">
14 + <input type="checkbox" class="form-check-input"
15 + name="Roles[@i].IsAssigned" value="true"
16 + @(Model.Roles[i].IsAssigned ? "checked" : "") />
17 + <label class="form-check-label">@Model.Roles[i].RoleName</label>
18 + </div>
19 + }
20 + <div class="form-group mt-3">
21 + <input type="submit" value="@Localizer["Save"]" class="btn btn-primary" />
22 + </div>
23 + </form>
24 + </div>
25 +</div>
26 +
27 +<div class="mt-3">
28 + <a asp-action="Index">@Localizer["Back"]</a>
29 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
@@ -0,0 +1,46 @@
1 +@model AdminUserIndexViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-gear me-2"></i>@Localizer["User Management"]</h1>
6 + <p class="lead">@Localizer["Users"]</p>
7 + </div>
8 +</div>
9 +
10 +<table class="table table-striped">
11 + <thead>
12 + <tr>
13 + <th>@Localizer["Email"]</th>
14 + <th>@Localizer["Name"]</th>
15 + <th>@Localizer["Roles"]</th>
16 + <th>@Localizer["Actions"]</th>
17 + </tr>
18 + </thead>
19 + <tbody>
20 + @foreach (var user in Model.Users)
21 + {
22 + <tr>
23 + <td>@user.Email</td>
24 + <td>@user.FirstName @user.LastName</td>
25 + <td>
26 + @foreach (var role in user.Roles)
27 + {
28 + <span class="badge bg-primary">@role</span>
29 + }
30 + </td>
31 + <td>
32 + <div class="admin-action-group">
33 + <a asp-action="Details" asp-route-id="@user.Id" class="btn btn-sm btn-outline-secondary" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
34 + <a asp-action="Edit" asp-route-id="@user.Id" class="btn btn-sm btn-outline-primary" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
35 + <a asp-action="EditRoles" asp-route-id="@user.Id" class="btn btn-sm btn-outline-primary" title="@Localizer["Edit Roles"]"><i class="bi bi-shield"></i></a>
36 + <a asp-action="Delete" asp-route-id="@user.Id" class="btn btn-sm btn-outline-danger" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
37 + </div>
38 + </td>
39 + </tr>
40 + }
41 + </tbody>
42 +</table>
43 +
44 +<div>
45 + <a asp-area="Admin" asp-controller="Dashboard" asp-action="Index">@Localizer["Back"]</a>
46 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
@@ -0,0 +1,64 @@
1 +@model AdminWishlistFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-heart-fill"></i> @Localizer["Create"] @Localizer["Wishlist Item"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Wishlist Item"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create" method="post">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="mb-3">
18 + <label asp-for="Item.TripId" class="form-label"></label>
19 + <select asp-for="Item.TripId" asp-items="Model.TripList" class="form-select"></select>
20 + </div>
21 + <div class="mb-3">
22 + <label asp-for="Item.AddedByUserId" class="form-label"></label>
23 + <select asp-for="Item.AddedByUserId" asp-items="Model.UserList" class="form-select"></select>
24 + </div>
25 + <div class="mb-3">
26 + <label asp-for="Item.Title" class="form-label"></label>
27 + <input asp-for="Item.Title" class="form-control" />
28 + <span asp-validation-for="Item.Title" class="text-danger"></span>
29 + </div>
30 + <div class="mb-3">
31 + <label asp-for="Item.Description" class="form-label"></label>
32 + <textarea asp-for="Item.Description" class="form-control"></textarea>
33 + </div>
34 + <div class="mb-3">
35 + <label asp-for="Item.Category" class="form-label"></label>
36 + <select asp-for="Item.Category" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistCategory>()" class="form-select"></select>
37 + </div>
38 + <div class="mb-3">
39 + <label asp-for="Item.Priority" class="form-label"></label>
40 + <select asp-for="Item.Priority" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistPriority>()" class="form-select"></select>
41 + </div>
42 + <div class="mb-3">
43 + <label asp-for="Item.EstimatedCost" class="form-label"></label>
44 + <input asp-for="Item.EstimatedCost" class="form-control" />
45 + </div>
46 + <div class="mb-3">
47 + <label asp-for="Item.Url" class="form-label"></label>
48 + <input asp-for="Item.Url" class="form-control" />
49 + </div>
50 + <div class="mb-3">
51 + <label asp-for="Item.Location" class="form-label"></label>
52 + <input asp-for="Item.Location" class="form-control" />
53 + </div>
54 + <div class="d-flex gap-2">
55 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Create"]</button>
56 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
57 + </div>
58 + </form>
59 + </div>
60 +</div>
61 +
62 +@section Scripts {
63 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
64 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +13 −0
@@ -0,0 +1,13 @@
1 +@model AdminDeleteViewModel<TripWishlistItemBllDto>
2 +
3 +<h1>@Localizer["Delete"] @Localizer["Wishlist Item"]</h1>
4 +
5 +<div class="alert alert-danger">
6 + @Localizer["AreYouSure"] - <strong>@Model.Item.Title</strong> (@Model.Item.Trip?.Name)?
7 +</div>
8 +
9 +<form asp-action="Delete" method="post">
10 + <input type="hidden" asp-for="Item.Id" />
11 + <button type="submit" class="btn btn-danger">@Localizer["Delete"]</button>
12 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
13 +</form>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +35 −0
@@ -0,0 +1,35 @@
1 +@model AdminDetailsViewModel<TripWishlistItemBllDto>
2 +
3 +<h1>@Localizer["Wishlist Item"] @Localizer["Details"]</h1>
4 +
5 +<div class="card shadow-sm">
6 + <div class="card-body">
7 + <dl class="row">
8 + <dt class="col-sm-3">@Localizer["Title"]</dt>
9 + <dd class="col-sm-9">@Model.Item.Title</dd>
10 + <dt class="col-sm-3">@Localizer["Category"]</dt>
11 + <dd class="col-sm-9">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Category)</dd>
12 + <dt class="col-sm-3">@Localizer["Priority"]</dt>
13 + <dd class="col-sm-9">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Priority)</dd>
14 + <dt class="col-sm-3">@Localizer["Trip"]</dt>
15 + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd>
16 + <dt class="col-sm-3">@Localizer["Added By"]</dt>
17 + <dd class="col-sm-9">@(Model.Item.AddedByUser != null ? $"{Model.Item.AddedByUser.FirstName} {Model.Item.AddedByUser.LastName}" : "")</dd>
18 + <dt class="col-sm-3">@Localizer["Description"]</dt>
19 + <dd class="col-sm-9">@Model.Item.Description</dd>
20 + <dt class="col-sm-3">@Localizer["Estimated Cost"]</dt>
21 + <dd class="col-sm-9">@Model.Item.EstimatedCost?.ToString("N2")</dd>
22 + <dt class="col-sm-3">@Localizer["URL"]</dt>
23 + <dd class="col-sm-9">@Model.Item.Url</dd>
24 + <dt class="col-sm-3">@Localizer["Location"]</dt>
25 + <dd class="col-sm-9">@Model.Item.Location</dd>
26 + <dt class="col-sm-3">@Localizer["Completed"]</dt>
27 + <dd class="col-sm-9">@(Model.Item.IsCompleted ? Localizer["Yes"] : Localizer["No"])</dd>
28 + <dt class="col-sm-3">@Localizer["Votes"]</dt>
29 + <dd class="col-sm-9">@Model.Item.VoteCount</dd>
30 + </dl>
31 + </div>
32 +</div>
33 +
34 +<a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary mt-3">@Localizer["Edit"]</a>
35 +<a asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Back to List"]</a>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
@@ -0,0 +1,57 @@
1 +@model AdminWishlistFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-heart-fill"></i> @Localizer["Edit"] @Localizer["Wishlist Item"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Wishlist Item"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit" method="post">
16 + <input type="hidden" asp-for="Item.Id" />
17 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
18 + <div class="mb-3">
19 + <label asp-for="Item.TripId" class="form-label"></label>
20 + <select asp-for="Item.TripId" asp-items="Model.TripList" class="form-select"></select>
21 + </div>
22 + <div class="mb-3">
23 + <label asp-for="Item.Title" class="form-label"></label>
24 + <input asp-for="Item.Title" class="form-control" />
25 + <span asp-validation-for="Item.Title" class="text-danger"></span>
26 + </div>
27 + <div class="mb-3">
28 + <label asp-for="Item.Description" class="form-label"></label>
29 + <textarea asp-for="Item.Description" class="form-control"></textarea>
30 + </div>
31 + <div class="mb-3">
32 + <label asp-for="Item.Category" class="form-label"></label>
33 + <select asp-for="Item.Category" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistCategory>()" class="form-select"></select>
34 + </div>
35 + <div class="mb-3">
36 + <label asp-for="Item.Priority" class="form-label"></label>
37 + <select asp-for="Item.Priority" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistPriority>()" class="form-select"></select>
38 + </div>
39 + <div class="mb-3">
40 + <label asp-for="Item.EstimatedCost" class="form-label"></label>
41 + <input asp-for="Item.EstimatedCost" class="form-control" />
42 + </div>
43 + <div class="mb-3 form-check">
44 + <input asp-for="Item.IsCompleted" class="form-check-input" />
45 + <label asp-for="Item.IsCompleted" class="form-check-label"></label>
46 + </div>
47 + <div class="d-flex gap-2">
48 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
49 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
50 + </div>
51 + </form>
52 + </div>
53 +</div>
54 +
55 +@section Scripts {
56 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
57 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +71 −0
@@ -0,0 +1,71 @@
1 +@model AdminWishlistIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-stars me-2"></i>@Localizer["Wishlist Items"]</h1>
5 + <p class="lead">@Localizer["Manage wishlist items across trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Title"]</th>
33 + <th>@Localizer["Category"]</th>
34 + <th>@Localizer["Priority"]</th>
35 + <th>@Localizer["Trip"]</th>
36 + <th>@Localizer["Added By"]</th>
37 + <th>@Localizer["Completed"]</th>
38 + <th class="text-end">@Localizer["Actions"]</th>
39 + </tr>
40 + </thead>
41 + <tbody>
42 + @foreach (var item in Model.Items)
43 + {
44 + <tr>
45 + <td>@item.Title</td>
46 + <td><span class="badge bg-info">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Category)</span></td>
47 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Priority)</td>
48 + <td>@item.Trip?.Name</td>
49 + <td>@(item.AddedByUser != null ? $"{item.AddedByUser.FirstName} {item.AddedByUser.LastName}" : "")</td>
50 + <td>@(item.IsCompleted ? Localizer["Yes"] : Localizer["No"])</td>
51 + <td class="text-end">
52 + <div class="admin-action-group">
53 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
54 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
55 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
56 + </div>
57 + </td>
58 + </tr>
59 + }
60 + </tbody>
61 + </table>
62 + }
63 + else
64 + {
65 + <div class="admin-empty">
66 + <i class="bi bi-inbox"></i>
67 + <div>@Localizer["No items yet"]</div>
68 + </div>
69 + }
70 + </div>
71 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewImports.cshtml +16 −0
@@ -0,0 +1,16 @@
1 +@using SplitApp.WebApp
2 +@using SplitApp.WebApp.Areas.Admin.Models
3 +@using SplitApp.WebApp.Hosting.Helpers
4 +@using SplitApp.WebApp.Application.DTO
5 +@using SplitApp.WebApp.Controllers
6 +@using SplitApp.Modules.Trips.Domain.Entities
7 +@using SplitApp.Modules.Trips.Domain.Enums
8 +@using SplitApp.Modules.Expenses.Domain.Entities
9 +@using SplitApp.Modules.Expenses.Domain.Enums
10 +@using SplitApp.Modules.Users.Domain.Entities
11 +@using SplitApp.Shared.Kernel.Localization
12 +@using Microsoft.Extensions.Localization
13 +@using Microsoft.AspNetCore.Mvc.Localization
14 +@using Microsoft.AspNetCore.Mvc.Rendering
15 +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
16 +@inject IStringLocalizer<App.Resources.Views.Shared> Localizer
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
@@ -0,0 +1,3 @@
1 +@{
2 + Layout = "/Areas/Admin/Views/Shared/_Layout.cshtml";
3 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml +59 −0
@@ -0,0 +1,59 @@
1 +@page
2 +@model RegisterModel
3 +@{
4 + ViewData["Title"] = "Register";
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-5">
9 + <div class="card shadow-sm mt-4">
10 + <div class="card-body p-4">
11 + <h2 class="text-center mb-3">@ViewData["Title"]</h2>
12 + <form id="registerForm" asp-route-returnUrl="@Model.ReturnUrl" method="post">
13 + <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
14 +
15 + <div class="row mb-3">
16 + <div class="col-6">
17 + <label asp-for="Input.FirstName" class="form-label"></label>
18 + <input asp-for="Input.FirstName" class="form-control" autocomplete="given-name" placeholder="First name" />
19 + <span asp-validation-for="Input.FirstName" class="text-danger"></span>
20 + </div>
21 + <div class="col-6">
22 + <label asp-for="Input.LastName" class="form-label"></label>
23 + <input asp-for="Input.LastName" class="form-control" autocomplete="family-name" placeholder="Last name" />
24 + <span asp-validation-for="Input.LastName" class="text-danger"></span>
25 + </div>
26 + </div>
27 +
28 + <div class="mb-3">
29 + <label asp-for="Input.Email" class="form-label"></label>
30 + <input asp-for="Input.Email" class="form-control" autocomplete="email" aria-required="true" placeholder="name@example.com" />
31 + <span asp-validation-for="Input.Email" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="mb-3">
35 + <label asp-for="Input.Password" class="form-label"></label>
36 + <input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Password" />
37 + <span asp-validation-for="Input.Password" class="text-danger"></span>
38 + </div>
39 +
40 + <div class="mb-3">
41 + <label asp-for="Input.ConfirmPassword" class="form-label"></label>
42 + <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Confirm password" />
43 + <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
44 + </div>
45 +
46 + <button id="registerSubmit" type="submit" class="btn btn-primary w-100 py-2">Register</button>
47 + </form>
48 +
49 + <div class="text-center mt-3">
50 + <p class="mb-0">Already have an account? <a asp-page="./Login" asp-route-returnUrl="@Model.ReturnUrl">Log in</a></p>
51 + </div>
52 + </div>
53 + </div>
54 + </div>
55 +</div>
56 +
57 +@section Scripts {
58 + <partial name="_ValidationScriptsPartial" />
59 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
@@ -0,0 +1,93 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Modules.Users.Domain.Entities;
3 +using Microsoft.AspNetCore.Identity;
4 +using Microsoft.AspNetCore.Mvc;
5 +using Microsoft.AspNetCore.Mvc.RazorPages;
6 +
7 +namespace SplitApp.WebApp.Areas.Identity.Pages.Account;
8 +
9 +public class RegisterModel : PageModel
10 +{
11 + private readonly SignInManager<AppUser> _signInManager;
12 + private readonly UserManager<AppUser> _userManager;
13 +
14 + public RegisterModel(
15 + UserManager<AppUser> userManager,
16 + SignInManager<AppUser> signInManager)
17 + {
18 + _userManager = userManager;
19 + _signInManager = signInManager;
20 + }
21 +
22 + [BindProperty]
23 + public InputModel Input { get; set; } = default!;
24 +
25 + public string? ReturnUrl { get; set; }
26 +
27 + public class InputModel
28 + {
29 + [Required]
30 + [StringLength(128)]
31 + [Display(Name = "First Name")]
32 + public string FirstName { get; set; } = default!;
33 +
34 + [Required]
35 + [StringLength(128)]
36 + [Display(Name = "Last Name")]
37 + public string LastName { get; set; } = default!;
38 +
39 + [Required]
40 + [EmailAddress]
41 + [Display(Name = "Email")]
42 + public string Email { get; set; } = default!;
43 +
44 + [Required]
45 + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
46 + [DataType(DataType.Password)]
47 + [Display(Name = "Password")]
48 + public string Password { get; set; } = default!;
49 +
50 + [DataType(DataType.Password)]
51 + [Display(Name = "Confirm password")]
52 + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
53 + public string ConfirmPassword { get; set; } = default!;
54 + }
55 +
56 + public void OnGet(string? returnUrl = null)
57 + {
58 + ReturnUrl = returnUrl;
59 + }
60 +
61 + public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
62 + {
63 + returnUrl ??= Url.Content("~/");
64 +
65 + if (ModelState.IsValid)
66 + {
67 + var user = new AppUser
68 + {
69 + UserName = Input.Email,
70 + Email = Input.Email,
71 + FirstName = Input.FirstName,
72 + LastName = Input.LastName,
73 + EmailConfirmed = true
74 + };
75 +
76 + var result = await _userManager.CreateAsync(user, Input.Password);
77 +
78 + if (result.Succeeded)
79 + {
80 + await _userManager.AddToRoleAsync(user, "user");
81 + await _signInManager.SignInAsync(user, isPersistent: false);
82 + return LocalRedirect(returnUrl);
83 + }
84 +
85 + foreach (var error in result.Errors)
86 + {
87 + ModelState.AddModelError(string.Empty, error.Description);
88 + }
89 + }
90 +
91 + return Page();
92 + }
93 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
@@ -0,0 +1,5 @@
1 +@using Microsoft.AspNetCore.Identity
2 +@using SplitApp.Modules.Users.Domain.Entities
3 +@using SplitApp.WebApp.Areas.Identity.Pages
4 +@using SplitApp.WebApp.Areas.Identity.Pages.Account
5 +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
@@ -0,0 +1,3 @@
1 +@{
2 + Layout = "/Views/Shared/_Layout.cshtml";
3 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/BudgetController.cs +213 −0
@@ -0,0 +1,213 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Modules.Users.Domain.Entities;
9 +using SplitApp.Shared.Kernel.Domain;
10 +using SplitApp.Shared.Kernel.Localization;
11 +using Microsoft.AspNetCore.Authorization;
12 +using Microsoft.AspNetCore.Identity;
13 +using Microsoft.AspNetCore.Mvc;
14 +
15 +namespace SplitApp.WebApp.Controllers;
16 +
17 +[Authorize]
18 +public class BudgetController : Controller
19 +{
20 + private readonly ITripService _tripService;
21 + private readonly IBudgetCategoryService _budgetCategoryService;
22 + private readonly UserManager<AppUser> _userManager;
23 +
24 + public BudgetController(
25 + ITripService tripService,
26 + IBudgetCategoryService budgetCategoryService,
27 + UserManager<AppUser> userManager)
28 + {
29 + _tripService = tripService;
30 + _budgetCategoryService = budgetCategoryService;
31 + _userManager = userManager;
32 + }
33 +
34 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
35 +
36 + // GET: Budget?tripId=xxx
37 + public async Task<IActionResult> Index(Guid tripId)
38 + {
39 + var userId = GetUserId();
40 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
41 +
42 + var trip = await _tripService.GetByIdAsync(tripId, userId);
43 + if (trip == null) return NotFound();
44 +
45 + var categories = await _budgetCategoryService.GetByTripIdAsync(tripId, userId);
46 +
47 + var model = categories.Select(c => new BudgetCategoryViewModel
48 + {
49 + Id = c.Id,
50 + Name = c.Name,
51 + IconName = c.IconName,
52 + PlannedAmount = c.PlannedAmount ?? 0,
53 + SpentAmount = c.SpentAmount,
54 + DisplayOrder = c.DisplayOrder
55 + }).ToList();
56 +
57 + ViewData["TripId"] = tripId;
58 + ViewData["TripName"] = trip.Name;
59 + ViewData["TotalPlanned"] = model.Sum(m => m.PlannedAmount);
60 + ViewData["TotalSpent"] = model.Sum(m => m.SpentAmount);
61 + ViewData["IsOrganizer"] = await _tripService.IsOrganizerAsync(tripId, userId);
62 +
63 + return View(model);
64 + }
65 +
66 + // GET: Budget/CreateCategory?tripId=xxx
67 + public async Task<IActionResult> CreateCategory(Guid tripId)
68 + {
69 + var userId = GetUserId();
70 + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid();
71 +
72 + ViewData["TripId"] = tripId;
73 + return View(new BudgetCategoryBllDto { TripId = tripId });
74 + }
75 +
76 + // POST: Budget/CreateCategory
77 + [HttpPost]
78 + [ValidateAntiForgeryToken]
79 + public async Task<IActionResult> CreateCategory(BudgetCategoryBllDto category, string? name)
80 + {
81 + var userId = GetUserId();
82 +
83 + category.Name = new LangStr(name ?? "", "en");
84 + ModelState.Remove(nameof(BudgetCategory.Name));
85 +
86 + if (string.IsNullOrWhiteSpace(name))
87 + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required.");
88 +
89 + if (ModelState.IsValid)
90 + {
91 + var (created, errorCode) = await _budgetCategoryService.CreateAsync(category, userId);
92 + if (created == null)
93 + {
94 + if (errorCode == "forbidden") return Forbid();
95 + return NotFound();
96 + }
97 + return RedirectToAction(nameof(Index), new { tripId = category.TripId });
98 + }
99 +
100 + ViewData["TripId"] = category.TripId;
101 + return View(category);
102 + }
103 +
104 + // GET: Budget/EditCategory/5
105 + public async Task<IActionResult> EditCategory(Guid id)
106 + {
107 + var userId = GetUserId();
108 +
109 + var category = await _budgetCategoryService.GetByIdAsync(id);
110 + if (category == null) return NotFound();
111 +
112 + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid();
113 +
114 + ViewData["TripId"] = category.TripId;
115 + return View(category);
116 + }
117 +
118 + // POST: Budget/EditCategory/5
119 + [HttpPost]
120 + [ValidateAntiForgeryToken]
121 + public async Task<IActionResult> EditCategory(Guid id, BudgetCategoryBllDto category, string? name)
122 + {
123 + if (id != category.Id) return NotFound();
124 +
125 + var userId = GetUserId();
126 +
127 + var existing = await _budgetCategoryService.GetByIdAsync(id);
128 + if (existing == null) return NotFound();
129 +
130 + if (!await _tripService.IsOrganizerAsync(existing.TripId, userId)) return Forbid();
131 +
132 + category.Name = new LangStr(name ?? "", "en");
133 + ModelState.Remove(nameof(BudgetCategory.Name));
134 +
135 + if (string.IsNullOrWhiteSpace(name))
136 + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required.");
137 +
138 + if (ModelState.IsValid)
139 + {
140 + var (ok, errorCode) = await _budgetCategoryService.UpdateAsync(id, category, userId);
141 + if (!ok)
142 + {
143 + return errorCode switch
144 + {
145 + "forbidden" => Forbid(),
146 + _ => NotFound()
147 + };
148 + }
149 + return RedirectToAction(nameof(Index), new { tripId = existing.TripId });
150 + }
151 +
152 + ViewData["TripId"] = existing.TripId;
153 + return View(category);
154 + }
155 +
156 + // GET: Budget/DeleteCategory/5
157 + public async Task<IActionResult> DeleteCategory(Guid id)
158 + {
159 + var userId = GetUserId();
160 +
161 + var category = await _budgetCategoryService.GetByIdAsync(id);
162 + if (category == null) return NotFound();
163 +
164 + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid();
165 +
166 + ViewData["TripId"] = category.TripId;
167 + return View(category);
168 + }
169 +
170 + // POST: Budget/DeleteCategory/5
171 + [HttpPost, ActionName("DeleteCategory")]
172 + [ValidateAntiForgeryToken]
173 + public async Task<IActionResult> DeleteCategoryConfirmed(Guid id)
174 + {
175 + var userId = GetUserId();
176 +
177 + var category = await _budgetCategoryService.GetByIdAsync(id);
178 + if (category == null) return NotFound();
179 +
180 + var tripId = category.TripId;
181 + var (ok, errorCode) = await _budgetCategoryService.DeleteAsync(id, userId);
182 + if (!ok)
183 + {
184 + return errorCode switch
185 + {
186 + "forbidden" => Forbid(),
187 + _ => NotFound()
188 + };
189 + }
190 + return RedirectToAction(nameof(Index), new { tripId });
191 + }
192 +}
193 +
194 +public class BudgetCategoryViewModel
195 +{
196 + public Guid Id { get; set; }
197 + public string Name { get; set; } = default!;
198 + public string? IconName { get; set; }
199 + public decimal PlannedAmount { get; set; }
200 + public decimal SpentAmount { get; set; }
201 + public int DisplayOrder { get; set; }
202 +
203 + public int ProgressPercentage =>
204 + PlannedAmount > 0 ? (int)(SpentAmount / PlannedAmount * 100) : 0;
205 +
206 + public string ProgressBarClass =>
207 + ProgressPercentage switch
208 + {
209 + > 90 => "bg-danger",
210 + > 70 => "bg-warning",
211 + _ => "bg-success"
212 + };
213 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/ExpensesController.cs +270 −0
@@ -0,0 +1,270 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Modules.Users.Domain.Entities;
9 +using Microsoft.AspNetCore.Authorization;
10 +using Microsoft.AspNetCore.Identity;
11 +using Microsoft.AspNetCore.Mvc;
12 +using Microsoft.AspNetCore.Mvc.Rendering;
13 +
14 +namespace SplitApp.WebApp.Controllers;
15 +
16 +[Authorize]
17 +public class ExpensesController : Controller
18 +{
19 + private readonly IExpenseService _expenseService;
20 + private readonly ITripService _tripService;
21 + private readonly IBudgetCategoryService _budgetCategoryService;
22 + private readonly UserManager<AppUser> _userManager;
23 +
24 + public ExpensesController(
25 + IExpenseService expenseService,
26 + ITripService tripService,
27 + IBudgetCategoryService budgetCategoryService,
28 + UserManager<AppUser> userManager)
29 + {
30 + _expenseService = expenseService;
31 + _tripService = tripService;
32 + _budgetCategoryService = budgetCategoryService;
33 + _userManager = userManager;
34 + }
35 +
36 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
37 +
38 + // GET: Expenses?tripId=xxx
39 + public async Task<IActionResult> Index(Guid tripId)
40 + {
41 + var userId = GetUserId();
42 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
43 +
44 + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId);
45 + if (trip == null) return NotFound();
46 +
47 + var expenses = await _expenseService.GetByTripIdAsync(tripId, userId);
48 +
49 + var model = new ExpensesIndexViewModel
50 + {
51 + TripId = tripId,
52 + TripName = trip.Name,
53 + DefaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR",
54 + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "\u20ac",
55 + TripStatus = trip.Status.ToString(),
56 + Expenses = expenses
57 + };
58 +
59 + return View(model);
60 + }
61 +
62 + // GET: Expenses/Create?tripId=xxx
63 + public async Task<IActionResult> Create(Guid tripId)
64 + {
65 + var userId = GetUserId();
66 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
67 +
68 + var trip = await _tripService.GetByIdAsync(tripId, userId);
69 + if (trip != null && trip.Status != ETripStatus.Active)
70 + return RedirectToAction(nameof(Index), new { tripId });
71 +
72 + await PopulateDropdowns(tripId);
73 + ViewData["TripId"] = tripId;
74 +
75 + var expense = new ExpenseBllDto
76 + {
77 + TripId = tripId,
78 + PaidByUserId = userId,
79 + ExpenseDate = DateTime.UtcNow,
80 + SplitMethod = ESplitMethod.EqualAll
81 + };
82 +
83 + return View(expense);
84 + }
85 +
86 + // POST: Expenses/Create
87 + [HttpPost]
88 + [ValidateAntiForgeryToken]
89 + public async Task<IActionResult> Create(ExpenseBllDto expense, Guid[] selectedParticipants, decimal[] splitAmounts, decimal[] splitPercentages)
90 + {
91 + var userId = GetUserId();
92 + if (!await _tripService.IsParticipantAsync(expense.TripId, userId)) return Forbid();
93 +
94 + if (ModelState.IsValid)
95 + {
96 + await _expenseService.CreateExpenseWithSplitsAsync(expense, selectedParticipants, splitAmounts, splitPercentages);
97 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
98 + }
99 +
100 + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId);
101 + ViewData["TripId"] = expense.TripId;
102 + return View(expense);
103 + }
104 +
105 + // GET: Expenses/Edit/5
106 + public async Task<IActionResult> Edit(Guid id)
107 + {
108 + var userId = GetUserId();
109 +
110 + var expense = await _expenseService.GetRawByIdAsync(id);
111 + if (expense == null) return NotFound();
112 +
113 + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid();
114 +
115 + var trip = await _tripService.GetRawByIdAsync(expense.TripId);
116 + if (trip != null && trip.Status != ETripStatus.Active)
117 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
118 +
119 + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId);
120 + ViewData["TripId"] = expense.TripId;
121 +
122 + return View(expense);
123 + }
124 +
125 + // POST: Expenses/Edit/5
126 + [HttpPost]
127 + [ValidateAntiForgeryToken]
128 + public async Task<IActionResult> Edit(Guid id, ExpenseBllDto expense)
129 + {
130 + if (id != expense.Id) return NotFound();
131 +
132 + var userId = GetUserId();
133 +
134 + if (ModelState.IsValid)
135 + {
136 + var result = await _expenseService.UpdateExpenseAsync(id, expense, userId);
137 + if (!result.success)
138 + {
139 + return result.errorCode switch
140 + {
141 + "notfound" => NotFound(),
142 + "forbidden" => Forbid(),
143 + "badstatus" => RedirectToAction(nameof(Index), new { tripId = expense.TripId }),
144 + _ => NotFound()
145 + };
146 + }
147 + // Need to get tripId from existing since it's not in incoming after success
148 + var updated = await _expenseService.GetRawByIdAsync(id);
149 + return RedirectToAction(nameof(Index), new { tripId = updated?.TripId ?? expense.TripId });
150 + }
151 +
152 + var existingEntity = await _expenseService.GetRawByIdAsync(id);
153 + if (existingEntity == null) return NotFound();
154 +
155 + await PopulateDropdowns(existingEntity.TripId, expense.BudgetCategoryId, expense.CurrencyId);
156 + ViewData["TripId"] = existingEntity.TripId;
157 + return View(expense);
158 + }
159 +
160 + // GET: Expenses/Delete/5
161 + public async Task<IActionResult> Delete(Guid id)
162 + {
163 + var userId = GetUserId();
164 +
165 + var expense = await _expenseService.GetByIdWithDetailsAsync(id, userId);
166 + if (expense == null)
167 + {
168 + // Either NotFound or not a participant
169 + var raw = await _expenseService.GetRawByIdAsync(id);
170 + if (raw == null) return NotFound();
171 + return Forbid();
172 + }
173 +
174 + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid();
175 +
176 + var trip = await _tripService.GetRawByIdAsync(expense.TripId);
177 + if (trip != null && trip.Status != ETripStatus.Active)
178 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
179 +
180 + ViewData["TripId"] = expense.TripId;
181 + return View(expense);
182 + }
183 +
184 + // POST: Expenses/Delete/5
185 + [HttpPost, ActionName("Delete")]
186 + [ValidateAntiForgeryToken]
187 + public async Task<IActionResult> DeleteConfirmed(Guid id)
188 + {
189 + var userId = GetUserId();
190 +
191 + var expense = await _expenseService.GetRawByIdAsync(id);
192 + if (expense == null) return NotFound();
193 +
194 + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid();
195 +
196 + var trip = await _tripService.GetRawByIdAsync(expense.TripId);
197 + if (trip != null && trip.Status != ETripStatus.Active)
198 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
199 +
200 + var tripId = expense.TripId;
201 +
202 + await _expenseService.DeleteExpenseWithSplitsAsync(id);
203 +
204 + return RedirectToAction(nameof(Index), new { tripId });
205 + }
206 +
207 + private async Task PopulateDropdowns(Guid tripId, Guid? selectedCategoryId = null, Guid? selectedCurrencyId = null)
208 + {
209 + var categories = await _budgetCategoryService.GetByTripIdRawAsync(tripId);
210 + ViewData["BudgetCategoryId"] = new SelectList(categories, "Id", "Name", selectedCategoryId);
211 +
212 + var currencies = await _tripService.GetAllCurrenciesAsync();
213 + ViewData["CurrencyId"] = new SelectList(currencies, "Id", "Code", selectedCurrencyId);
214 +
215 + ViewData["SplitMethods"] = new SelectList(
216 + Enum.GetValues<ESplitMethod>().Select(e => new { Value = (int)e, Text = e.ToString() }),
217 + "Value", "Text");
218 +
219 + var userId = GetUserId();
220 + var participants = await _tripService.GetParticipantsAsync(tripId, userId);
221 +
222 + ViewData["Participants"] = participants;
223 + ViewData["PaidByUserId"] = new SelectList(
224 + participants.Select(p => new
225 + {
226 + Value = p.UserId,
227 + Text = $"{p.User!.FirstName} {p.User.LastName}"
228 + }),
229 + "Value", "Text", userId);
230 +
231 + // Load split presets for this trip
232 + var presets = await _expenseService.GetSplitPresetsByTripAsync(tripId, userId);
233 + ViewData["SplitPresets"] = presets;
234 + }
235 +
236 + // POST: Expenses/SavePreset
237 + [HttpPost]
238 + [ValidateAntiForgeryToken]
239 + public async Task<IActionResult> SavePreset(Guid tripId, string presetName, int splitMethod, Guid[] selectedParticipants, decimal[] splitPercentages)
240 + {
241 + var userId = GetUserId();
242 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
243 +
244 + await _expenseService.SavePresetAsync(tripId, presetName, (ESplitMethod)splitMethod, selectedParticipants, splitPercentages, userId);
245 + return RedirectToAction(nameof(Create), new { tripId });
246 + }
247 +
248 + // POST: Expenses/DeletePreset
249 + [HttpPost]
250 + [ValidateAntiForgeryToken]
251 + public async Task<IActionResult> DeletePreset(Guid id, Guid tripId)
252 + {
253 + var userId = GetUserId();
254 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
255 +
256 + await _expenseService.DeletePresetAsync(id, tripId, userId);
257 +
258 + return RedirectToAction(nameof(Create), new { tripId });
259 + }
260 +}
261 +
262 +public class ExpensesIndexViewModel
263 +{
264 + public Guid TripId { get; set; }
265 + public string TripName { get; set; } = default!;
266 + public string DefaultCurrencyCode { get; set; } = default!;
267 + public string CurrencySymbol { get; set; } = default!;
268 + public string TripStatus { get; set; } = default!;
269 + public List<ExpenseBllDto> Expenses { get; set; } = new();
270 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/HomeController.cs +35 −0
@@ -0,0 +1,35 @@
1 +using System.Diagnostics;
2 +using Microsoft.AspNetCore.Localization;
3 +using Microsoft.AspNetCore.Mvc;
4 +using SplitApp.WebApp.Models;
5 +
6 +namespace SplitApp.WebApp.Controllers;
7 +
8 +public class HomeController : Controller
9 +{
10 + public IActionResult Index()
11 + {
12 + return View();
13 + }
14 +
15 + public IActionResult Privacy()
16 + {
17 + return View();
18 + }
19 +
20 + public IActionResult SetLanguage(string culture, string returnUrl)
21 + {
22 + Response.Cookies.Append(
23 + CookieRequestCultureProvider.DefaultCookieName,
24 + CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
25 + new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
26 + );
27 + return LocalRedirect(returnUrl);
28 + }
29 +
30 + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
31 + public IActionResult Error()
32 + {
33 + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
34 + }
35 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/MembersController.cs +181 −0
@@ -0,0 +1,181 @@
1 +using SplitApp.WebApp.Application.Services;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Identity;
10 +using Microsoft.AspNetCore.Mvc;
11 +
12 +namespace SplitApp.WebApp.Controllers;
13 +
14 +[Authorize]
15 +public class MembersController : Controller
16 +{
17 + private readonly ITripService _tripService;
18 + private readonly IInvitationService _invitationService;
19 + private readonly UserManager<AppUser> _userManager;
20 +
21 + public MembersController(
22 + ITripService tripService,
23 + IInvitationService invitationService,
24 + UserManager<AppUser> userManager)
25 + {
26 + _tripService = tripService;
27 + _invitationService = invitationService;
28 + _userManager = userManager;
29 + }
30 +
31 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
32 +
33 + // GET: Members?tripId=xxx
34 + public async Task<IActionResult> Index(Guid tripId)
35 + {
36 + var userId = GetUserId();
37 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
38 +
39 + var trip = await _tripService.GetByIdAsync(tripId, userId);
40 + if (trip == null) return NotFound();
41 +
42 + var participants = await _tripService.GetParticipantsAsync(tripId, userId);
43 +
44 + var isOrganizer = participants.Any(p => p.UserId == userId && p.Role == EParticipantRole.Organizer);
45 +
46 + var pendingInvitations = await _invitationService.GetPendingByTripIdAsync(tripId, userId);
47 +
48 + ViewData["TripId"] = tripId;
49 + ViewData["TripName"] = trip.Name;
50 + ViewData["CurrentUserId"] = userId;
51 + ViewData["IsOrganizer"] = isOrganizer;
52 + ViewData["PendingInvitations"] = pendingInvitations;
53 +
54 + return View(participants);
55 + }
56 +
57 + // GET: Members/Invite?tripId=xxx
58 + public async Task<IActionResult> Invite(Guid tripId)
59 + {
60 + var userId = GetUserId();
61 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
62 +
63 + var trip = await _tripService.GetByIdAsync(tripId, userId);
64 + if (trip == null) return NotFound();
65 +
66 + ViewData["TripId"] = tripId;
67 + ViewData["TripName"] = trip.Name;
68 +
69 + return View();
70 + }
71 +
72 + // POST: Members/Invite
73 + [HttpPost]
74 + [ValidateAntiForgeryToken]
75 + public async Task<IActionResult> Invite(Guid tripId, int _)
76 + {
77 + var userId = GetUserId();
78 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
79 +
80 + var trip = await _tripService.GetByIdAsync(tripId, userId);
81 + if (trip == null) return NotFound();
82 +
83 + var invitation = await _invitationService.CreateInvitationAsync(tripId, userId);
84 +
85 + var inviteUrl = Url.Action("AcceptInvitation", "Members", new { token = invitation.Token }, Request.Scheme);
86 +
87 + ViewData["TripId"] = tripId;
88 + ViewData["TripName"] = trip.Name;
89 + ViewData["InviteUrl"] = inviteUrl;
90 + ViewData["Token"] = invitation.Token;
91 +
92 + return View("InviteGenerated");
93 + }
94 +
95 + // GET: Members/AcceptInvitation?token=xxx
96 + [AllowAnonymous]
97 + public async Task<IActionResult> AcceptInvitation(string token)
98 + {
99 + var invitation = await _invitationService.GetByTokenAsync(token);
100 +
101 + if (invitation == null) return NotFound();
102 +
103 + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow)
104 + {
105 + ViewData["Error"] = "This invitation has expired or is no longer valid.";
106 + return View("InvitationInvalid");
107 + }
108 +
109 + // Load trip for the view
110 + invitation.Trip = await _tripService.GetRawByIdAsync(invitation.TripId);
111 +
112 + ViewData["Token"] = token;
113 + return View(invitation);
114 + }
115 +
116 + // POST: Members/AcceptInvitation
117 + [HttpPost]
118 + [ValidateAntiForgeryToken]
119 + public async Task<IActionResult> AcceptInvitation(string token, int _)
120 + {
121 + var userId = GetUserId();
122 +
123 + var invitation = await _invitationService.GetByTokenAsync(token);
124 + if (invitation == null) return NotFound();
125 +
126 + var success = await _invitationService.AcceptInvitationAsync(token, userId);
127 +
128 + if (!success)
129 + {
130 + ViewData["Error"] = "This invitation has expired or is no longer valid.";
131 + return View("InvitationInvalid");
132 + }
133 +
134 + return RedirectToAction("Details", "Trips", new { id = invitation.TripId });
135 + }
136 +
137 + // POST: Members/Remove
138 + [HttpPost]
139 + [ValidateAntiForgeryToken]
140 + public async Task<IActionResult> Remove(Guid tripId, Guid participantId)
141 + {
142 + var userId = GetUserId();
143 +
144 + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid();
145 +
146 + var participant = await _tripService.GetParticipantByIdAsync(participantId);
147 + if (participant == null || participant.TripId != tripId) return NotFound();
148 +
149 + // Cannot remove yourself
150 + if (participant.UserId == userId)
151 + {
152 + TempData["Error"] = "You cannot remove yourself from the trip.";
153 + return RedirectToAction(nameof(Index), new { tripId });
154 + }
155 +
156 + await _tripService.RemoveParticipantByIdAsync(tripId, participantId, userId);
157 +
158 + return RedirectToAction(nameof(Index), new { tripId });
159 + }
160 +
161 + // POST: Members/RevokeInvitation
162 + [HttpPost]
163 + [ValidateAntiForgeryToken]
164 + public async Task<IActionResult> RevokeInvitation(Guid id, Guid tripId)
165 + {
166 + var userId = GetUserId();
167 +
168 + var (ok, errorCode) = await _invitationService.RevokeInvitationAsync(id, tripId, userId);
169 + if (!ok)
170 + {
171 + return errorCode switch
172 + {
173 + "forbidden" => Forbid(),
174 + "notfound" => NotFound(),
175 + _ => NotFound()
176 + };
177 + }
178 +
179 + return RedirectToAction(nameof(Index), new { tripId });
180 + }
181 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/PollsClientController.cs +148 −0
@@ -0,0 +1,148 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Modules.Users.Domain.Entities;
9 +using Microsoft.AspNetCore.Authorization;
10 +using Microsoft.AspNetCore.Identity;
11 +using Microsoft.AspNetCore.Mvc;
12 +
13 +namespace SplitApp.WebApp.Controllers;
14 +
15 +[Authorize]
16 +public class PollsClientController : Controller
17 +{
18 + private readonly ITripService _tripService;
19 + private readonly IPollService _pollService;
20 + private readonly UserManager<AppUser> _userManager;
21 +
22 + public PollsClientController(
23 + ITripService tripService,
24 + IPollService pollService,
25 + UserManager<AppUser> userManager)
26 + {
27 + _tripService = tripService;
28 + _pollService = pollService;
29 + _userManager = userManager;
30 + }
31 +
32 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
33 +
34 + // GET: PollsClient?tripId=xxx
35 + public async Task<IActionResult> Index(Guid tripId)
36 + {
37 + var userId = GetUserId();
38 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
39 +
40 + var trip = await _tripService.GetByIdAsync(tripId, userId);
41 + if (trip == null) return NotFound();
42 +
43 + var polls = await _pollService.GetByTripIdAsync(tripId, userId);
44 +
45 + ViewData["TripId"] = tripId;
46 + ViewData["TripName"] = trip.Name;
47 +
48 + return View(polls);
49 + }
50 +
51 + // GET: PollsClient/Create?tripId=xxx
52 + public async Task<IActionResult> Create(Guid tripId)
53 + {
54 + var userId = GetUserId();
55 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
56 +
57 + ViewData["TripId"] = tripId;
58 + return View();
59 + }
60 +
61 + // POST: PollsClient/Create
62 + [HttpPost]
63 + [ValidateAntiForgeryToken]
64 + public async Task<IActionResult> Create(Guid tripId, string question, bool allowMultipleVotes,
65 + bool isAnonymous, string option1, string option2, string? option3, string? option4, string? option5)
66 + {
67 + var userId = GetUserId();
68 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
69 +
70 + if (string.IsNullOrWhiteSpace(question) || string.IsNullOrWhiteSpace(option1) ||
71 + string.IsNullOrWhiteSpace(option2))
72 + {
73 + ModelState.AddModelError("", "Question and at least 2 options are required.");
74 + ViewData["TripId"] = tripId;
75 + return View();
76 + }
77 +
78 + var poll = new TripPollBllDto
79 + {
80 + TripId = tripId,
81 + CreatedByUserId = userId,
82 + Question = question,
83 + AllowMultipleVotes = allowMultipleVotes,
84 + IsAnonymous = isAnonymous
85 + };
86 +
87 + var options = new[] { option1, option2, option3, option4, option5 }
88 + .Where(o => !string.IsNullOrWhiteSpace(o))
89 + .Select(o => o!)
90 + .ToList();
91 +
92 + var created = await _pollService.CreatePollWithOptionsAsync(poll, options);
93 +
94 + return RedirectToAction(nameof(Details), new { id = created.Id });
95 + }
96 +
97 + // GET: PollsClient/Details/5
98 + public async Task<IActionResult> Details(Guid id)
99 + {
100 + var userId = GetUserId();
101 + var poll = await _pollService.GetByIdWithDetailsAsync(id, userId);
102 +
103 + if (poll == null) return NotFound();
104 +
105 + ViewData["TripId"] = poll.TripId;
106 + ViewData["UserId"] = userId;
107 + ViewData["IsCreator"] = poll.CreatedByUserId == userId;
108 +
109 + return View(poll);
110 + }
111 +
112 + // POST: PollsClient/Vote
113 + [HttpPost]
114 + [ValidateAntiForgeryToken]
115 + public async Task<IActionResult> Vote(Guid pollId, Guid optionId)
116 + {
117 + var userId = GetUserId();
118 + var poll = await _pollService.GetByIdAsync(pollId, userId);
119 +
120 + if (poll == null) return NotFound();
121 +
122 + if (poll.ClosedAt != null) return RedirectToAction(nameof(Details), new { id = pollId });
123 +
124 + await _pollService.ToggleVoteAsync(pollId, optionId, userId);
125 +
126 + return RedirectToAction(nameof(Details), new { id = pollId });
127 + }
128 +
129 + // POST: PollsClient/Close/5
130 + [HttpPost]
131 + [ValidateAntiForgeryToken]
132 + public async Task<IActionResult> Close(Guid id)
133 + {
134 + var userId = GetUserId();
135 + var (ok, errorCode) = await _pollService.ClosePollAsync(id, userId, organizerAllowed: false);
136 + if (!ok)
137 + {
138 + return errorCode switch
139 + {
140 + "notfound" => NotFound(),
141 + "forbidden" => Forbid(),
142 + _ => NotFound()
143 + };
144 + }
145 +
146 + return RedirectToAction(nameof(Details), new { id });
147 + }
148 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/SettlementController.cs +194 −0
@@ -0,0 +1,194 @@
1 +using SplitApp.WebApp.Application.Services;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Users.Domain.Entities;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Identity;
10 +using Microsoft.AspNetCore.Mvc;
11 +
12 +namespace SplitApp.WebApp.Controllers;
13 +
14 +[Authorize]
15 +public class SettlementController : Controller
16 +{
17 + private readonly ITripService _tripService;
18 + private readonly ISettlementService _settlementService;
19 + private readonly UserManager<AppUser> _userManager;
20 +
21 + public SettlementController(
22 + ITripService tripService,
23 + ISettlementService settlementService,
24 + UserManager<AppUser> userManager)
25 + {
26 + _tripService = tripService;
27 + _settlementService = settlementService;
28 + _userManager = userManager;
29 + }
30 +
31 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
32 +
33 + // GET: Settlement?tripId=xxx
34 + public async Task<IActionResult> Index(Guid tripId)
35 + {
36 + var userId = GetUserId();
37 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
38 +
39 + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId);
40 + if (trip == null) return NotFound();
41 +
42 + // Calculate balances via service
43 + var balanceEntries = await _settlementService.CalculateBalancesAsync(tripId);
44 +
45 + // Map to view model
46 + var balances = balanceEntries.Select(b => new SettlementBalanceViewModel
47 + {
48 + UserId = b.UserId,
49 + UserName = b.UserName,
50 + TotalPaid = b.TotalPaid,
51 + TotalOwed = b.TotalOwed
52 + }).OrderByDescending(b => b.NetBalance).ToList();
53 +
54 + // Get existing settlement plan (only exists after trip is finalized)
55 + var latestPlan = await _settlementService.GetLatestPlanRawAsync(tripId);
56 +
57 + // Preview payments when trip is active (not saved to DB)
58 + var previewPayments = trip.Status == ETripStatus.Active
59 + ? _settlementService.PreviewSettlement(balanceEntries)
60 + : new List<PreviewPayment>();
61 +
62 + var model = new SettlementIndexViewModel
63 + {
64 + TripId = tripId,
65 + TripName = trip.Name,
66 + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "$",
67 + TripStatus = trip.Status.ToString(),
68 + IsOrganizer = await _tripService.IsOrganizerAsync(tripId, userId),
69 + CurrentUserId = userId,
70 + Balances = balances,
71 + LatestPlan = latestPlan,
72 + PreviewPayments = previewPayments
73 + };
74 +
75 + return View(model);
76 + }
77 +
78 + // POST: Settlement/Finalize
79 + [HttpPost]
80 + [ValidateAntiForgeryToken]
81 + public async Task<IActionResult> Finalize(Guid tripId)
82 + {
83 + var userId = GetUserId();
84 + var (ok, errorCode) = await _tripService.FinalizeTripAsync(tripId, userId);
85 + if (!ok)
86 + {
87 + return errorCode switch
88 + {
89 + "forbidden" => Forbid(),
90 + "notfound" => NotFound(),
91 + "badstatus" => BadRequest(),
92 + _ => NotFound()
93 + };
94 + }
95 +
96 + return RedirectToAction(nameof(Index), new { tripId });
97 + }
98 +
99 + // POST: Settlement/Reopen
100 + [HttpPost]
101 + [ValidateAntiForgeryToken]
102 + public async Task<IActionResult> Reopen(Guid tripId)
103 + {
104 + var userId = GetUserId();
105 + var (ok, errorCode) = await _tripService.ReopenTripAsync(tripId, userId);
106 + if (!ok)
107 + {
108 + return errorCode switch
109 + {
110 + "forbidden" => Forbid(),
111 + "notfound" => NotFound(),
112 + "badstatus" => RedirectToAction(nameof(Index), new { tripId }),
113 + "payments-confirmed" => RedirectToAction(nameof(Index), new { tripId }),
114 + _ => NotFound()
115 + };
116 + }
117 +
118 + return RedirectToAction(nameof(Index), new { tripId });
119 + }
120 +
121 + // POST: Settlement/MarkPaid/5
122 + [HttpPost]
123 + [ValidateAntiForgeryToken]
124 + public async Task<IActionResult> MarkPaid(Guid paymentId)
125 + {
126 + var userId = GetUserId();
127 + var payment = await _settlementService.GetPaymentByIdAsync(paymentId);
128 + if (payment == null) return NotFound();
129 +
130 + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId);
131 + if (plan == null) return NotFound();
132 +
133 + var (ok, errorCode) = await _settlementService.MarkPaidGuardedAsync(paymentId, userId);
134 + if (!ok)
135 + {
136 + return errorCode switch
137 + {
138 + "forbidden" => Forbid(),
139 + "notfound" => NotFound(),
140 + _ => NotFound()
141 + };
142 + }
143 +
144 + return RedirectToAction(nameof(Index), new { tripId = plan.TripId });
145 + }
146 +
147 + // POST: Settlement/ConfirmReceipt/5
148 + [HttpPost]
149 + [ValidateAntiForgeryToken]
150 + public async Task<IActionResult> ConfirmReceipt(Guid paymentId)
151 + {
152 + var userId = GetUserId();
153 + var payment = await _settlementService.GetPaymentByIdAsync(paymentId);
154 + if (payment == null) return NotFound();
155 +
156 + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId);
157 + if (plan == null) return NotFound();
158 +
159 + var (ok, errorCode) = await _settlementService.ConfirmPaymentGuardedAsync(paymentId, userId);
160 + if (!ok)
161 + {
162 + return errorCode switch
163 + {
164 + "forbidden" => Forbid(),
165 + "notfound" => NotFound(),
166 + _ => NotFound()
167 + };
168 + }
169 +
170 + return RedirectToAction(nameof(Index), new { tripId = plan.TripId });
171 + }
172 +}
173 +
174 +public class SettlementIndexViewModel
175 +{
176 + public Guid TripId { get; set; }
177 + public string TripName { get; set; } = default!;
178 + public string CurrencySymbol { get; set; } = default!;
179 + public string TripStatus { get; set; } = default!;
180 + public bool IsOrganizer { get; set; }
181 + public Guid CurrentUserId { get; set; }
182 + public List<SettlementBalanceViewModel> Balances { get; set; } = new();
183 + public SplitApp.WebApp.Application.DTO.SettlementPlanBllDto? LatestPlan { get; set; }
184 + public List<PreviewPayment> PreviewPayments { get; set; } = new();
185 +}
186 +
187 +public class SettlementBalanceViewModel
188 +{
189 + public Guid UserId { get; set; }
190 + public string UserName { get; set; } = default!;
191 + public decimal TotalPaid { get; set; }
192 + public decimal TotalOwed { get; set; }
193 + public decimal NetBalance => TotalPaid - TotalOwed;
194 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/TripsController.cs +264 −0
@@ -0,0 +1,264 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Modules.Users.Domain.Entities;
9 +using Microsoft.AspNetCore.Authorization;
10 +using Microsoft.AspNetCore.Identity;
11 +using Microsoft.AspNetCore.Mvc;
12 +using Microsoft.AspNetCore.Mvc.Rendering;
13 +using SplitApp.WebApp.Hosting.Helpers;
14 +
15 +namespace SplitApp.WebApp.Controllers;
16 +
17 +[Authorize]
18 +public class TripsController : Controller
19 +{
20 + private readonly ITripService _tripService;
21 + private readonly IExpenseService _expenseService;
22 + private readonly IBudgetCategoryService _budgetCategoryService;
23 + private readonly UserManager<AppUser> _userManager;
24 +
25 + public TripsController(
26 + ITripService tripService,
27 + IExpenseService expenseService,
28 + IBudgetCategoryService budgetCategoryService,
29 + UserManager<AppUser> userManager)
30 + {
31 + _tripService = tripService;
32 + _expenseService = expenseService;
33 + _budgetCategoryService = budgetCategoryService;
34 + _userManager = userManager;
35 + }
36 +
37 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
38 +
39 + // GET: Trips
40 + public async Task<IActionResult> Index()
41 + {
42 + var userId = GetUserId();
43 +
44 + var trips = await _tripService.GetUserTripsAsync(userId);
45 +
46 + var model = new List<TripIndexViewModel>();
47 + foreach (var trip in trips)
48 + {
49 + var participant = trip.Participants?.FirstOrDefault(p => p.UserId == userId && p.IsActive);
50 + if (participant == null) continue;
51 +
52 + model.Add(new TripIndexViewModel
53 + {
54 + Id = trip.Id,
55 + Name = trip.Name,
56 + Destination = trip.Destination,
57 + Status = trip.Status,
58 + StartDate = trip.StartDate,
59 + EndDate = trip.EndDate,
60 + Role = participant.Role,
61 + CurrencyCode = trip.DefaultCurrency?.Code ?? ""
62 + });
63 + }
64 +
65 + return View(model);
66 + }
67 +
68 + // GET: Trips/Details/5
69 + public async Task<IActionResult> Details(Guid id)
70 + {
71 + var userId = GetUserId();
72 +
73 + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId);
74 + if (trip == null) return NotFound();
75 +
76 + // Get participant role
77 + var participants = await _tripService.GetParticipantsAsync(id, userId);
78 + var participant = participants.FirstOrDefault(p => p.UserId == userId);
79 +
80 + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR";
81 +
82 + // Get expenses with details for balance calculation
83 + var expensesAll = await _expenseService.GetByTripIdAsync(id, userId);
84 +
85 + var recentExpenses = expensesAll
86 + .OrderByDescending(e => e.ExpenseDate)
87 + .Take(5)
88 + .ToList();
89 +
90 + var totalExpenses = expensesAll.Sum(e =>
91 + CurrencyConverter.Convert(e.Amount, e.Currency?.Code ?? defaultCurrencyCode, defaultCurrencyCode));
92 +
93 + // Calculate balances for each participant
94 + var balances = new Dictionary<Guid, SettlementBalanceViewModel>();
95 + foreach (var p in participants)
96 + {
97 + balances[p.UserId] = new SettlementBalanceViewModel
98 + {
99 + UserId = p.UserId,
100 + UserName = !string.IsNullOrWhiteSpace(p.User?.FullName)
101 + ? p.User!.FullName
102 + : (p.User?.Email ?? "Unknown"),
103 + TotalPaid = 0,
104 + TotalOwed = 0
105 + };
106 + }
107 +
108 + foreach (var expense in expensesAll)
109 + {
110 + var expenseWithSplits = await _expenseService.GetByIdWithDetailsAsync(expense.Id, userId);
111 + if (expenseWithSplits == null) continue;
112 +
113 + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode;
114 + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode);
115 +
116 + if (balances.ContainsKey(expenseWithSplits.PaidByUserId))
117 + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount;
118 +
119 + if (expenseWithSplits.Splits != null)
120 + {
121 + foreach (var split in expenseWithSplits.Splits)
122 + {
123 + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode);
124 + if (balances.ContainsKey(split.UserId))
125 + balances[split.UserId].TotalOwed += convertedSplit;
126 + }
127 + }
128 + }
129 +
130 + // Calculate budget totals (only category-assigned expenses count against budget)
131 + var budgetCategories = await _budgetCategoryService.GetByTripIdAsync(id, userId);
132 + var totalPlanned = budgetCategories.Sum(c => c.PlannedAmount ?? 0);
133 + var totalBudgetSpent = budgetCategories.Sum(c => c.SpentAmount);
134 + var budgetUsedPct = totalPlanned > 0 ? (int)(totalBudgetSpent * 100 / totalPlanned) : 0;
135 +
136 + // Current user's balance
137 + var currentUserBalance = balances.ContainsKey(userId) ? balances[userId].NetBalance : 0;
138 +
139 + ViewData["TripId"] = id;
140 + ViewData["TripName"] = trip.Name;
141 + ViewData["ParticipantCount"] = participants.Count;
142 + ViewData["RecentExpenses"] = recentExpenses;
143 + ViewData["TotalExpenses"] = totalExpenses;
144 + ViewData["UserRole"] = participant?.Role ?? EParticipantRole.Participant;
145 + ViewData["Balances"] = balances.Values.OrderByDescending(b => b.NetBalance).ToList();
146 + ViewData["CurrentUserBalance"] = currentUserBalance;
147 + ViewData["BudgetUsedPct"] = budgetUsedPct;
148 + ViewData["TotalPlanned"] = totalPlanned;
149 + ViewData["CurrencySymbol"] = trip.DefaultCurrency?.Symbol ?? "\u20ac";
150 +
151 + return View(trip);
152 + }
153 +
154 + // GET: Trips/Create
155 + public async Task<IActionResult> Create()
156 + {
157 + await PopulateCurrencyDropdown();
158 + return View();
159 + }
160 +
161 + // POST: Trips/Create
162 + [HttpPost]
163 + [ValidateAntiForgeryToken]
164 + public async Task<IActionResult> Create(TripBllDto trip)
165 + {
166 + var userId = GetUserId();
167 +
168 + if (ModelState.IsValid)
169 + {
170 + var created = await _tripService.CreateTripAsync(trip, userId);
171 + return RedirectToAction(nameof(Details), new { id = created.Id });
172 + }
173 +
174 + await PopulateCurrencyDropdown(trip.DefaultCurrencyId);
175 + return View(trip);
176 + }
177 +
178 + // GET: Trips/Edit/5
179 + public async Task<IActionResult> Edit(Guid id)
180 + {
181 + var userId = GetUserId();
182 +
183 + var trip = await _tripService.GetByIdForOrganizerAsync(id, userId);
184 + if (trip == null)
185 + {
186 + // Distinguish not-organizer vs not-found
187 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
188 + return NotFound();
189 + }
190 +
191 + await PopulateCurrencyDropdown(trip.DefaultCurrencyId);
192 + return View(trip);
193 + }
194 +
195 + // POST: Trips/Edit/5
196 + [HttpPost]
197 + [ValidateAntiForgeryToken]
198 + public async Task<IActionResult> Edit(Guid id, TripBllDto trip)
199 + {
200 + if (id != trip.Id) return NotFound();
201 +
202 + var userId = GetUserId();
203 +
204 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
205 +
206 + if (ModelState.IsValid)
207 + {
208 + var updated = await _tripService.UpdateAsync(trip, userId);
209 + if (updated == null) return NotFound();
210 + return RedirectToAction(nameof(Details), new { id });
211 + }
212 +
213 + await PopulateCurrencyDropdown(trip.DefaultCurrencyId);
214 + return View(trip);
215 + }
216 +
217 + // GET: Trips/Delete/5
218 + public async Task<IActionResult> Delete(Guid id)
219 + {
220 + var userId = GetUserId();
221 +
222 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
223 +
224 + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId);
225 + if (trip == null) return NotFound();
226 +
227 + return View(trip);
228 + }
229 +
230 + // POST: Trips/Delete/5
231 + [HttpPost, ActionName("Delete")]
232 + [ValidateAntiForgeryToken]
233 + public async Task<IActionResult> DeleteConfirmed(Guid id)
234 + {
235 + var userId = GetUserId();
236 +
237 + var success = await _tripService.DeleteAsync(id, userId);
238 + if (!success)
239 + {
240 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
241 + return NotFound();
242 + }
243 +
244 + return RedirectToAction(nameof(Index));
245 + }
246 +
247 + private async Task PopulateCurrencyDropdown(Guid? selectedId = null)
248 + {
249 + var currencies = await _tripService.GetAllCurrenciesAsync();
250 + ViewData["DefaultCurrencyId"] = new SelectList(currencies, "Id", "Code", selectedId);
251 + }
252 +}
253 +
254 +public class TripIndexViewModel
255 +{
256 + public Guid Id { get; set; }
257 + public string Name { get; set; } = default!;
258 + public string? Destination { get; set; }
259 + public ETripStatus Status { get; set; }
260 + public DateTime? StartDate { get; set; }
261 + public DateTime? EndDate { get; set; }
262 + public EParticipantRole Role { get; set; }
263 + public string CurrencyCode { get; set; } = default!;
264 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/WishlistClientController.cs +281 −0
@@ -0,0 +1,281 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Modules.Users.Domain.Entities;
9 +using Microsoft.AspNetCore.Authorization;
10 +using Microsoft.AspNetCore.Identity;
11 +using Microsoft.AspNetCore.Mvc;
12 +using Microsoft.AspNetCore.Mvc.Rendering;
13 +
14 +namespace SplitApp.WebApp.Controllers;
15 +
16 +[Authorize]
17 +public class WishlistClientController : Controller
18 +{
19 + private readonly ITripService _tripService;
20 + private readonly IWishlistService _wishlistService;
21 + private readonly UserManager<AppUser> _userManager;
22 +
23 + public WishlistClientController(
24 + ITripService tripService,
25 + IWishlistService wishlistService,
26 + UserManager<AppUser> userManager)
27 + {
28 + _tripService = tripService;
29 + _wishlistService = wishlistService;
30 + _userManager = userManager;
31 + }
32 +
33 + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!);
34 +
35 + // GET: WishlistClient?tripId=xxx
36 + public async Task<IActionResult> Index(Guid tripId)
37 + {
38 + var userId = GetUserId();
39 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
40 +
41 + var trip = await _tripService.GetByIdAsync(tripId, userId);
42 + if (trip == null) return NotFound();
43 +
44 + var items = await _wishlistService.GetByTripIdAsync(tripId, userId);
45 +
46 + ViewData["CurrentUserId"] = userId;
47 +
48 + var model = items.Select(item => new WishlistItemViewModel
49 + {
50 + Id = item.Id,
51 + AddedByUserId = item.AddedByUserId,
52 + Title = item.Title,
53 + Description = item.Description,
54 + Category = item.Category,
55 + Priority = item.Priority,
56 + EstimatedCost = item.EstimatedCost,
57 + Url = item.Url,
58 + Location = item.Location,
59 + IsCompleted = item.IsCompleted,
60 + AddedByName = item.AddedByUserFullName ?? "Unknown",
61 + VoteCount = item.VoteCount,
62 + UserHasVoted = item.VoterUserIds.Contains(userId)
63 + }).ToList();
64 +
65 + ViewData["TripId"] = tripId;
66 + ViewData["TripName"] = trip.Name;
67 +
68 + return View(model);
69 + }
70 +
71 + // GET: WishlistClient/Create?tripId=xxx
72 + public async Task<IActionResult> Create(Guid tripId)
73 + {
74 + var userId = GetUserId();
75 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
76 +
77 + ViewData["TripId"] = tripId;
78 + PopulateEnumDropdowns();
79 + return View(new TripWishlistItemBllDto { TripId = tripId });
80 + }
81 +
82 + // POST: WishlistClient/Create
83 + [HttpPost]
84 + [ValidateAntiForgeryToken]
85 + public async Task<IActionResult> Create(TripWishlistItemBllDto item)
86 + {
87 + var userId = GetUserId();
88 +
89 + if (ModelState.IsValid)
90 + {
91 + var (created, errorCode) = await _wishlistService.CreateAsync(item, userId);
92 + if (created == null)
93 + {
94 + if (errorCode == "forbidden") return Forbid();
95 + return NotFound();
96 + }
97 + return RedirectToAction(nameof(Index), new { tripId = item.TripId });
98 + }
99 +
100 + ViewData["TripId"] = item.TripId;
101 + PopulateEnumDropdowns();
102 + return View(item);
103 + }
104 +
105 + // POST: WishlistClient/Vote/5
106 + [HttpPost]
107 + [ValidateAntiForgeryToken]
108 + public async Task<IActionResult> Vote(Guid id)
109 + {
110 + var userId = GetUserId();
111 +
112 + var item = await _wishlistService.GetByIdRawAsync(id);
113 + if (item == null) return NotFound();
114 +
115 + var (ok, errorCode) = await _wishlistService.ToggleVoteAsync(id, userId);
116 + if (!ok)
117 + {
118 + return errorCode switch
119 + {
120 + "notfound" => NotFound(),
121 + "forbidden" => Forbid(),
122 + _ => NotFound()
123 + };
124 + }
125 +
126 + return RedirectToAction(nameof(Index), new { tripId = item.TripId });
127 + }
128 +
129 + // POST: WishlistClient/Complete/5
130 + [HttpPost]
131 + [ValidateAntiForgeryToken]
132 + public async Task<IActionResult> Complete(Guid id)
133 + {
134 + var userId = GetUserId();
135 +
136 + var item = await _wishlistService.GetByIdRawAsync(id);
137 + if (item == null) return NotFound();
138 +
139 + var (ok, errorCode) = await _wishlistService.ToggleCompleteAsync(id, userId);
140 + if (!ok)
141 + {
142 + return errorCode switch
143 + {
144 + "notfound" => NotFound(),
145 + "forbidden" => Forbid(),
146 + _ => NotFound()
147 + };
148 + }
149 +
150 + return RedirectToAction(nameof(Index), new { tripId = item.TripId });
151 + }
152 +
153 + // GET: WishlistClient/Edit/5
154 + public async Task<IActionResult> Edit(Guid id)
155 + {
156 + var userId = GetUserId();
157 +
158 + var item = await _wishlistService.GetByIdAsync(id, userId);
159 + if (item == null)
160 + {
161 + var raw = await _wishlistService.GetByIdRawAsync(id);
162 + if (raw == null) return NotFound();
163 + return Forbid();
164 + }
165 +
166 + if (item.AddedByUserId != userId) return Forbid();
167 +
168 + ViewData["TripId"] = item.TripId;
169 + PopulateEnumDropdowns();
170 + return View(item);
171 + }
172 +
173 + // POST: WishlistClient/Edit/5
174 + [HttpPost]
175 + [ValidateAntiForgeryToken]
176 + public async Task<IActionResult> Edit(Guid id, TripWishlistItemBllDto item)
177 + {
178 + if (id != item.Id) return NotFound();
179 +
180 + var userId = GetUserId();
181 +
182 + if (ModelState.IsValid)
183 + {
184 + var preUpdate = await _wishlistService.GetByIdRawAsync(id);
185 + if (preUpdate == null) return NotFound();
186 +
187 + var (ok, errorCode) = await _wishlistService.UpdateAsync(id, item, userId);
188 + if (!ok)
189 + {
190 + return errorCode switch
191 + {
192 + "notfound" => NotFound(),
193 + "forbidden" => Forbid(),
194 + _ => NotFound()
195 + };
196 + }
197 +
198 + return RedirectToAction(nameof(Index), new { tripId = preUpdate.TripId });
199 + }
200 +
201 + var raw = await _wishlistService.GetByIdRawAsync(id);
202 + if (raw == null) return NotFound();
203 + ViewData["TripId"] = raw.TripId;
204 + PopulateEnumDropdowns();
205 + return View(item);
206 + }
207 +
208 + // GET: WishlistClient/Delete/5
209 + public async Task<IActionResult> Delete(Guid id)
210 + {
211 + var userId = GetUserId();
212 +
213 + var item = await _wishlistService.GetByIdAsync(id, userId);
214 + if (item == null)
215 + {
216 + var raw = await _wishlistService.GetByIdRawAsync(id);
217 + if (raw == null) return NotFound();
218 + return Forbid();
219 + }
220 + if (item.AddedByUserId != userId) return Forbid();
221 +
222 + // Re-fetch with includes via trip items list
223 + var tripItems = await _wishlistService.GetByTripIdAsync(item.TripId, userId);
224 + var itemWithDetails = tripItems.FirstOrDefault(w => w.Id == id);
225 +
226 + ViewData["TripId"] = item.TripId;
227 + return View(itemWithDetails ?? item);
228 + }
229 +
230 + // POST: WishlistClient/Delete/5
231 + [HttpPost, ActionName("Delete")]
232 + [ValidateAntiForgeryToken]
233 + public async Task<IActionResult> DeleteConfirmed(Guid id)
234 + {
235 + var userId = GetUserId();
236 +
237 + var existing = await _wishlistService.GetByIdRawAsync(id);
238 + if (existing == null) return NotFound();
239 + var tripId = existing.TripId;
240 +
241 + var (ok, errorCode) = await _wishlistService.DeleteAsync(id, userId);
242 + if (!ok)
243 + {
244 + return errorCode switch
245 + {
246 + "notfound" => NotFound(),
247 + "forbidden" => Forbid(),
248 + _ => NotFound()
249 + };
250 + }
251 +
252 + return RedirectToAction(nameof(Index), new { tripId });
253 + }
254 +
255 + private void PopulateEnumDropdowns()
256 + {
257 + ViewData["Categories"] = new SelectList(
258 + Enum.GetValues<EWishlistCategory>().Select(e => new { Value = (int)e, Text = e.ToString() }),
259 + "Value", "Text");
260 + ViewData["Priorities"] = new SelectList(
261 + Enum.GetValues<EWishlistPriority>().Select(e => new { Value = (int)e, Text = e.ToString() }),
262 + "Value", "Text");
263 + }
264 +}
265 +
266 +public class WishlistItemViewModel
267 +{
268 + public Guid Id { get; set; }
269 + public Guid AddedByUserId { get; set; }
270 + public string Title { get; set; } = default!;
271 + public string? Description { get; set; }
272 + public EWishlistCategory Category { get; set; }
273 + public EWishlistPriority Priority { get; set; }
274 + public decimal? EstimatedCost { get; set; }
275 + public string? Url { get; set; }
276 + public string? Location { get; set; }
277 + public bool IsCompleted { get; set; }
278 + public string AddedByName { get; set; } = default!;
279 + public int VoteCount { get; set; }
280 + public bool UserHasVoted { get; set; }
281 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/AppDataInit.cs +363 −0
@@ -0,0 +1,363 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
5 +using SplitApp.Modules.Trips.Domain.Entities;
6 +using SplitApp.Modules.Trips.Domain.Enums;
7 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +using SplitApp.Modules.Users.Infrastructure.Persistence;
9 +using SplitApp.Shared.Kernel.Localization;
10 +
11 +namespace SplitApp.WebApp.Hosting;
12 +
13 +public static class AppDataInit
14 +{
15 + public static void SeedExampleData(IServiceProvider services)
16 + {
17 + using var scope = services.CreateScope();
18 + var users = scope.ServiceProvider.GetRequiredService<UsersDbContext>();
19 + var trips = scope.ServiceProvider.GetRequiredService<TripsDbContext>();
20 + var expenses = scope.ServiceProvider.GetRequiredService<ExpensesDbContext>();
21 +
22 + if (trips.Trips.Any()) return;
23 +
24 + var admin = users.Users.First(u => u.Email == "admin@taltech.ee").Id;
25 + var testUser = users.Users.First(u => u.Email == "user@taltech.ee").Id;
26 + var alice = users.Users.First(u => u.Email == "alice@taltech.ee").Id;
27 + var bob = users.Users.First(u => u.Email == "bob@taltech.ee").Id;
28 + var charlie = users.Users.First(u => u.Email == "charlie@taltech.ee").Id;
29 + var diana = users.Users.First(u => u.Email == "diana@taltech.ee").Id;
30 +
31 + var eur = expenses.Currencies.First(c => c.Code == "EUR").Id;
32 + var usd = expenses.Currencies.First(c => c.Code == "USD").Id;
33 + var gbp = expenses.Currencies.First(c => c.Code == "GBP").Id;
34 +
35 + var now = DateTime.UtcNow;
36 +
37 + // ──────────────────────────────────────────────────────────────
38 + // Trip 1: Barcelona Weekend — Active, 4 participants
39 + // ──────────────────────────────────────────────────────────────
40 + var trip1 = new Trip
41 + {
42 + Name = "Barcelona Weekend",
43 + Description = "A long weekend exploring Barcelona with friends",
44 + Destination = "Barcelona, Spain",
45 + StartDate = new DateTime(2026, 4, 10, 0, 0, 0, DateTimeKind.Utc),
46 + EndDate = new DateTime(2026, 4, 13, 0, 0, 0, DateTimeKind.Utc),
47 + Status = ETripStatus.Active,
48 + DefaultCurrencyId = eur,
49 + CreatedById = admin,
50 + };
51 + trips.Trips.Add(trip1);
52 +
53 + trips.TripParticipants.AddRange(
54 + new TripParticipant { TripId = trip1.Id, UserId = admin, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-10), IsActive = true },
55 + new TripParticipant { TripId = trip1.Id, UserId = alice, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-9), IsActive = true },
56 + new TripParticipant { TripId = trip1.Id, UserId = bob, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-8), IsActive = true },
57 + new TripParticipant { TripId = trip1.Id, UserId = charlie, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-7), IsActive = true });
58 +
59 + var cat1Food = new BudgetCategory { TripId = trip1.Id, Name = Lang("Food & Drinks", "Toit ja joogid"), IconName = "cup-hot", PlannedAmount = 400m, DisplayOrder = 0 };
60 + var cat1Transport = new BudgetCategory { TripId = trip1.Id, Name = Lang("Transport", "Transport"), IconName = "bus-front", PlannedAmount = 150m, DisplayOrder = 1 };
61 + var cat1Activities = new BudgetCategory { TripId = trip1.Id, Name = Lang("Activities", "Tegevused"), IconName = "binoculars", PlannedAmount = 200m, DisplayOrder = 2 };
62 + var cat1Accommodation = new BudgetCategory { TripId = trip1.Id, Name = Lang("Accommodation", "Majutus"), IconName = "house", PlannedAmount = 500m, DisplayOrder = 3 };
63 + trips.BudgetCategories.AddRange(cat1Food, cat1Transport, cat1Activities, cat1Accommodation);
64 +
65 + var poll1 = new TripPoll { TripId = trip1.Id, CreatedByUserId = admin, Question = "Where should we eat on the last night?" };
66 + trips.TripPolls.Add(poll1);
67 + var poll1A = new TripPollOption { PollId = poll1.Id, Text = "Can Culleretes (oldest restaurant)", DisplayOrder = 0 };
68 + var poll1B = new TripPollOption { PollId = poll1.Id, Text = "El Xampanyet (tapas)", DisplayOrder = 1 };
69 + var poll1C = new TripPollOption { PollId = poll1.Id, Text = "Cerveceria Catalana", DisplayOrder = 2 };
70 + trips.TripPollOptions.AddRange(poll1A, poll1B, poll1C);
71 + trips.TripPollVotes.AddRange(
72 + new TripPollVote { PollOptionId = poll1B.Id, UserId = admin },
73 + new TripPollVote { PollOptionId = poll1A.Id, UserId = alice },
74 + new TripPollVote { PollOptionId = poll1B.Id, UserId = bob });
75 +
76 + trips.TripWishlistItems.AddRange(
77 + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = alice, Title = "Casa Batllo", Description = "Gaudi's famous building on Passeig de Gracia", Category = EWishlistCategory.Place, Priority = EWishlistPriority.MustDo, EstimatedCost = 35m, Location = "Passeig de Gracia 43", DisplayOrder = 0 },
78 + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = bob, Title = "Beach volleyball", Description = "Play at Barceloneta beach in the morning", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.NiceToHave, Location = "Barceloneta Beach", DisplayOrder = 1 },
79 + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = charlie, Title = "Flamenco show", Description = "Evening flamenco performance", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.MustDo, EstimatedCost = 45m, DisplayOrder = 2 },
80 + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = admin, Title = "La Paradeta seafood", Description = "Fresh seafood market-style restaurant", Category = EWishlistCategory.Restaurant, Priority = EWishlistPriority.Optional, Location = "Carrer Comercial 7", DisplayOrder = 3 });
81 +
82 + // ──────────────────────────────────────────────────────────────
83 + // Trip 2: London Business Trip — Settled, 3 participants
84 + // ──────────────────────────────────────────────────────────────
85 + var trip2 = new Trip
86 + {
87 + Name = "London Business Trip",
88 + Description = "Conference and team meetings in London",
89 + Destination = "London, UK",
90 + StartDate = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc),
91 + EndDate = new DateTime(2026, 3, 4, 0, 0, 0, DateTimeKind.Utc),
92 + Status = ETripStatus.Settled,
93 + DefaultCurrencyId = gbp,
94 + CreatedById = testUser,
95 + };
96 + trips.Trips.Add(trip2);
97 + trips.TripParticipants.AddRange(
98 + new TripParticipant { TripId = trip2.Id, UserId = testUser, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-30), IsActive = true },
99 + new TripParticipant { TripId = trip2.Id, UserId = admin, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-29), IsActive = true },
100 + new TripParticipant { TripId = trip2.Id, UserId = diana, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-28), IsActive = true });
101 +
102 + // ──────────────────────────────────────────────────────────────
103 + // Trip 3: Summer Cabin — Active, 5 participants, partial settlement
104 + // ──────────────────────────────────────────────────────────────
105 + var trip3 = new Trip
106 + {
107 + Name = "Summer Cabin Getaway",
108 + Description = "Relaxing weekend at a cabin by the lake",
109 + Destination = "Otepää, Estonia",
110 + StartDate = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc),
111 + EndDate = new DateTime(2026, 5, 18, 0, 0, 0, DateTimeKind.Utc),
112 + Status = ETripStatus.Active,
113 + DefaultCurrencyId = eur,
114 + CreatedById = alice,
115 + };
116 + trips.Trips.Add(trip3);
117 + trips.TripParticipants.AddRange(
118 + new TripParticipant { TripId = trip3.Id, UserId = alice, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-3), IsActive = true },
119 + new TripParticipant { TripId = trip3.Id, UserId = bob, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-2), IsActive = true },
120 + new TripParticipant { TripId = trip3.Id, UserId = charlie, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-2), IsActive = true },
121 + new TripParticipant { TripId = trip3.Id, UserId = diana, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-1), IsActive = true },
122 + new TripParticipant { TripId = trip3.Id, UserId = admin, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-1), IsActive = true });
123 +
124 + var poll3 = new TripPoll { TripId = trip3.Id, CreatedByUserId = alice, Question = "What activity for Saturday afternoon?", AllowMultipleVotes = true };
125 + trips.TripPolls.Add(poll3);
126 + var poll3A = new TripPollOption { PollId = poll3.Id, Text = "Hiking to the viewpoint", DisplayOrder = 0 };
127 + var poll3B = new TripPollOption { PollId = poll3.Id, Text = "Fishing at the lake", DisplayOrder = 1 };
128 + var poll3C = new TripPollOption { PollId = poll3.Id, Text = "Board games at the cabin", DisplayOrder = 2 };
129 + var poll3D = new TripPollOption { PollId = poll3.Id, Text = "Cycling around the area", DisplayOrder = 3 };
130 + trips.TripPollOptions.AddRange(poll3A, poll3B, poll3C, poll3D);
131 + trips.TripPollVotes.AddRange(
132 + new TripPollVote { PollOptionId = poll3A.Id, UserId = alice },
133 + new TripPollVote { PollOptionId = poll3B.Id, UserId = alice },
134 + new TripPollVote { PollOptionId = poll3A.Id, UserId = bob },
135 + new TripPollVote { PollOptionId = poll3C.Id, UserId = charlie },
136 + new TripPollVote { PollOptionId = poll3B.Id, UserId = diana },
137 + new TripPollVote { PollOptionId = poll3D.Id, UserId = admin },
138 + new TripPollVote { PollOptionId = poll3A.Id, UserId = admin });
139 +
140 + trips.TripWishlistItems.AddRange(
141 + new TripWishlistItem { TripId = trip3.Id, AddedByUserId = bob, Title = "Smoke sauna experience", Description = "Traditional Estonian smoke sauna at the lakeside", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.MustDo, EstimatedCost = 15m, DisplayOrder = 0 },
142 + new TripWishlistItem { TripId = trip3.Id, AddedByUserId = diana, Title = "Visit Otepää Adventure Park",Description = "Rope courses and zip lines in the forest", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.NiceToHave,EstimatedCost = 25m, Location = "Otepää Adventure Park", DisplayOrder = 1 },
143 + new TripWishlistItem { TripId = trip3.Id, AddedByUserId = alice, Title = "Pühajärve beach", Description = "Swimming and sunbathing at the sacred lake", Category = EWishlistCategory.Place, Priority = EWishlistPriority.MustDo, Location = "Pühajärv", DisplayOrder = 2, IsCompleted = true, CompletedAt = now.AddHours(-5) });
144 +
145 + trips.TripInvitations.Add(new TripInvitation
146 + {
147 + TripId = trip3.Id, InvitedByUserId = alice,
148 + Token = Guid.NewGuid().ToString("N"),
149 + Status = EInvitationStatus.Pending,
150 + ExpiresAt = now.AddDays(7),
151 + });
152 +
153 + // ──────────────────────────────────────────────────────────────
154 + // Trip 4: NYC Adventure — Archived, 3 participants
155 + // ──────────────────────────────────────────────────────────────
156 + var trip4 = new Trip
157 + {
158 + Name = "NYC Adventure",
159 + Description = "Week in New York City exploring Manhattan and Brooklyn",
160 + Destination = "New York, USA",
161 + StartDate = new DateTime(2025, 12, 20, 0, 0, 0, DateTimeKind.Utc),
162 + EndDate = new DateTime(2025, 12, 27, 0, 0, 0, DateTimeKind.Utc),
163 + Status = ETripStatus.Archived,
164 + DefaultCurrencyId = usd,
165 + CreatedById = bob,
166 + };
167 + trips.Trips.Add(trip4);
168 + trips.TripParticipants.AddRange(
169 + new TripParticipant { TripId = trip4.Id, UserId = bob, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-90), IsActive = true },
170 + new TripParticipant { TripId = trip4.Id, UserId = alice, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-89), IsActive = true },
171 + new TripParticipant { TripId = trip4.Id, UserId = diana, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-88), IsActive = true });
172 +
173 + var poll4 = new TripPoll
174 + {
175 + TripId = trip4.Id, CreatedByUserId = bob,
176 + Question = "Best day of the trip?",
177 + ClosedAt = new DateTime(2025, 12, 27, 12, 0, 0, DateTimeKind.Utc),
178 + };
179 + trips.TripPolls.Add(poll4);
180 + var poll4A = new TripPollOption { PollId = poll4.Id, Text = "Broadway night", DisplayOrder = 0 };
181 + var poll4B = new TripPollOption { PollId = poll4.Id, Text = "Central Park day", DisplayOrder = 1 };
182 + var poll4C = new TripPollOption { PollId = poll4.Id, Text = "Brooklyn Bridge walk", DisplayOrder = 2 };
183 + trips.TripPollOptions.AddRange(poll4A, poll4B, poll4C);
184 + trips.TripPollVotes.AddRange(
185 + new TripPollVote { PollOptionId = poll4A.Id, UserId = alice },
186 + new TripPollVote { PollOptionId = poll4A.Id, UserId = diana },
187 + new TripPollVote { PollOptionId = poll4B.Id, UserId = bob });
188 +
189 + // Save Trips first so all Trip/Participant/BudgetCategory IDs are committed
190 + // before Expenses references them by Guid (cross-schema, no FK).
191 + trips.SaveChanges();
192 +
193 + // ──────────────────────────────────────────────────────────────
194 + // Trip 1 expenses: 11 items, equal split among 4 (one subset)
195 + // ──────────────────────────────────────────────────────────────
196 + var trip1Members = new[] { admin, alice, bob, charlie };
197 + var trip1Expenses = new (string desc, decimal amount, Guid paidBy, Guid? catId, DateTime date, ESplitMethod split)[]
198 + {
199 + ("Airbnb apartment (3 nights)", 480.00m, admin, cat1Accommodation.Id, now.AddDays(-5), ESplitMethod.EqualAll),
200 + ("Airport taxi", 35.00m, alice, cat1Transport.Id, now.AddDays(-5), ESplitMethod.EqualAll),
201 + ("Grocery shopping", 62.50m, bob, cat1Food.Id, now.AddDays(-4), ESplitMethod.EqualAll),
202 + ("Dinner at La Boqueria", 128.00m, admin, cat1Food.Id, now.AddDays(-4), ESplitMethod.EqualAll),
203 + ("Sagrada Familia tickets", 104.00m, charlie, cat1Activities.Id, now.AddDays(-3), ESplitMethod.EqualAll),
204 + ("Metro passes (4x)", 44.00m, alice, cat1Transport.Id, now.AddDays(-3), ESplitMethod.EqualAll),
205 + ("Tapas bar lunch", 76.00m, bob, cat1Food.Id, now.AddDays(-3), ESplitMethod.EqualAll),
206 + ("Park Guell entry", 40.00m, admin, cat1Activities.Id, now.AddDays(-2), ESplitMethod.EqualAll),
207 + ("Sangria and snacks", 48.50m, charlie, cat1Food.Id, now.AddDays(-2), ESplitMethod.EqualAll),
208 + ("Souvenir shopping", 55.00m, alice, cat1Food.Id, now.AddDays(-1), ESplitMethod.EqualSubset),
209 + ("Return taxi to airport", 38.00m, bob, cat1Transport.Id, now.AddDays(-1), ESplitMethod.EqualAll),
210 + };
211 +
212 + foreach (var (desc, amount, paidBy, catId, date, split) in trip1Expenses)
213 + {
214 + var ex = new Expense
215 + {
216 + TripId = trip1.Id, PaidByUserId = paidBy, Amount = amount,
217 + Description = desc, ExpenseDate = date, BudgetCategoryId = catId,
218 + CurrencyId = eur, SplitMethod = split,
219 + };
220 + expenses.Expenses.Add(ex);
221 + AddEqualSplits(expenses, ex, split == ESplitMethod.EqualSubset
222 + ? new[] { alice, bob, charlie }
223 + : trip1Members);
224 + }
225 +
226 + // SplitPresets (Expenses module)
227 + var preset1All = new SplitPreset { TripId = trip1.Id, Name = "Everyone equal", SplitMethod = ESplitMethod.EqualAll, CreatedById = admin };
228 + var preset1Hotel = new SplitPreset { TripId = trip1.Id, Name = "Hotel group", SplitMethod = ESplitMethod.EqualSubset, CreatedById = admin };
229 + expenses.SplitPresets.AddRange(preset1All, preset1Hotel);
230 + expenses.SplitPresetMembers.AddRange(
231 + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = admin },
232 + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = alice },
233 + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = bob });
234 +
235 + // ──────────────────────────────────────────────────────────────
236 + // Trip 2 expenses + completed settlement
237 + // ──────────────────────────────────────────────────────────────
238 + var trip2Members = new[] { testUser, admin, diana };
239 + var trip2Expenses = new (string desc, decimal amount, Guid paidBy, DateTime date)[]
240 + {
241 + ("Hotel (2 nights)", 340.00m, testUser, now.AddDays(-28)),
242 + ("Heathrow Express", 75.00m, admin, now.AddDays(-28)),
243 + ("Conference dinner", 185.00m, testUser, now.AddDays(-27)),
244 + ("Uber rides", 48.00m, diana, now.AddDays(-27)),
245 + ("Team lunch", 92.00m, admin, now.AddDays(-26)),
246 + ("Coffee & snacks", 24.50m, diana, now.AddDays(-26)),
247 + };
248 + foreach (var (desc, amount, paidBy, date) in trip2Expenses)
249 + {
250 + var ex = new Expense
251 + {
252 + TripId = trip2.Id, PaidByUserId = paidBy, Amount = amount,
253 + Description = desc, ExpenseDate = date, CurrencyId = gbp,
254 + SplitMethod = ESplitMethod.EqualAll,
255 + };
256 + expenses.Expenses.Add(ex);
257 + AddEqualSplits(expenses, ex, trip2Members);
258 + }
259 +
260 + var plan2 = new SettlementPlan
261 + {
262 + TripId = trip2.Id, CreatedByUserId = testUser,
263 + TotalAmount = 158.50m, Status = ESettlementStatus.Completed,
264 + CompletedAt = now.AddDays(-20),
265 + };
266 + expenses.SettlementPlans.Add(plan2);
267 + expenses.SettlementPayments.AddRange(
268 + new SettlementPayment { SettlementPlanId = plan2.Id, FromUserId = diana, ToUserId = testUser, Amount = 130.50m, Status = EPaymentStatus.Confirmed, MarkedPaidAt = now.AddDays(-22), ConfirmedAt = now.AddDays(-20) },
269 + new SettlementPayment { SettlementPlanId = plan2.Id, FromUserId = diana, ToUserId = admin, Amount = 28.00m, Status = EPaymentStatus.Confirmed, MarkedPaidAt = now.AddDays(-21), ConfirmedAt = now.AddDays(-20) });
270 +
271 + // ──────────────────────────────────────────────────────────────
272 + // Trip 3 expenses + in-progress settlement
273 + // ──────────────────────────────────────────────────────────────
274 + var trip3Members = new[] { alice, bob, charlie, diana, admin };
275 + var trip3Expenses = new (string desc, decimal amount, Guid paidBy, DateTime date)[]
276 + {
277 + ("Cabin rental (3 nights)", 600.00m, alice, now.AddDays(-2)),
278 + ("BBQ supplies and meat", 95.00m, bob, now.AddDays(-1)),
279 + ("Firewood and charcoal", 25.00m, charlie, now.AddDays(-1)),
280 + ("Drinks and beverages", 78.00m, diana, now),
281 + ("Fishing gear rental", 40.00m, admin, now),
282 + ("Breakfast groceries", 42.00m, alice, now),
283 + ("Canoe rental (half day)", 60.00m, bob, now),
284 + };
285 + foreach (var (desc, amount, paidBy, date) in trip3Expenses)
286 + {
287 + var ex = new Expense
288 + {
289 + TripId = trip3.Id, PaidByUserId = paidBy, Amount = amount,
290 + Description = desc, ExpenseDate = date, CurrencyId = eur,
291 + SplitMethod = ESplitMethod.EqualAll,
292 + };
293 + expenses.Expenses.Add(ex);
294 + AddEqualSplits(expenses, ex, trip3Members);
295 + }
296 +
297 + var plan3 = new SettlementPlan
298 + {
299 + TripId = trip3.Id, CreatedByUserId = alice,
300 + TotalAmount = 350m, Status = ESettlementStatus.InProgress,
301 + };
302 + expenses.SettlementPlans.Add(plan3);
303 + expenses.SettlementPayments.AddRange(
304 + new SettlementPayment { SettlementPlanId = plan3.Id, FromUserId = charlie, ToUserId = alice, Amount = 145.00m, Status = EPaymentStatus.MarkedPaid, MarkedPaidAt = now.AddHours(-2) },
305 + new SettlementPayment { SettlementPlanId = plan3.Id, FromUserId = diana, ToUserId = alice, Amount = 110.00m, Status = EPaymentStatus.Pending },
306 + new SettlementPayment { SettlementPlanId = plan3.Id, FromUserId = admin, ToUserId = bob, Amount = 95.00m, Status = EPaymentStatus.Pending });
307 +
308 + // ──────────────────────────────────────────────────────────────
309 + // Trip 4 expenses (archived)
310 + // ──────────────────────────────────────────────────────────────
311 + var trip4Members = new[] { bob, alice, diana };
312 + var trip4Expenses = new (string desc, decimal amount, Guid paidBy, DateTime date)[]
313 + {
314 + ("Hotel in Midtown (6 nights)", 1800.00m, bob, new DateTime(2025, 12, 20, 12, 0, 0, DateTimeKind.Utc)),
315 + ("Broadway show tickets", 450.00m, alice, new DateTime(2025, 12, 21, 20, 0, 0, DateTimeKind.Utc)),
316 + ("Statue of Liberty ferry", 63.00m, diana, new DateTime(2025, 12, 22, 10, 0, 0, DateTimeKind.Utc)),
317 + ("Central Park bike rental", 75.00m, bob, new DateTime(2025, 12, 23, 14, 0, 0, DateTimeKind.Utc)),
318 + ("Dinner in Little Italy", 195.00m, alice, new DateTime(2025, 12, 23, 20, 0, 0, DateTimeKind.Utc)),
319 + ("Brooklyn Bridge walk snacks", 28.00m, diana, new DateTime(2025, 12, 24, 11, 0, 0, DateTimeKind.Utc)),
320 + ("MoMA tickets", 75.00m, bob, new DateTime(2025, 12, 25, 10, 0, 0, DateTimeKind.Utc)),
321 + ("Times Square shopping", 220.00m, alice, new DateTime(2025, 12, 26, 15, 0, 0, DateTimeKind.Utc)),
322 + ("JFK taxi", 65.00m, diana, new DateTime(2025, 12, 27, 8, 0, 0, DateTimeKind.Utc)),
323 + };
324 + foreach (var (desc, amount, paidBy, date) in trip4Expenses)
325 + {
326 + var ex = new Expense
327 + {
328 + TripId = trip4.Id, PaidByUserId = paidBy, Amount = amount,
329 + Description = desc, ExpenseDate = date, CurrencyId = usd,
330 + SplitMethod = ESplitMethod.EqualAll,
331 + };
332 + expenses.Expenses.Add(ex);
333 + AddEqualSplits(expenses, ex, trip4Members);
334 + }
335 +
336 + expenses.SaveChanges();
337 + }
338 +
339 + private static LangStr Lang(string en, string et)
340 + {
341 + var s = new LangStr(en, "en");
342 + s["et"] = et;
343 + return s;
344 + }
345 +
346 + private static void AddEqualSplits(ExpensesDbContext db, Expense expense, Guid[] memberIds)
347 + {
348 + var count = memberIds.Length;
349 + var baseAmt = Math.Floor(expense.Amount / count * 100m) / 100m;
350 + var remainderCents = (int)Math.Round((expense.Amount - baseAmt * count) * 100m);
351 +
352 + for (var i = 0; i < count; i++)
353 + {
354 + var splitAmt = baseAmt + (i < remainderCents ? 0.01m : 0m);
355 + db.ExpenseSplits.Add(new ExpenseSplit
356 + {
357 + ExpenseId = expense.Id,
358 + UserId = memberIds[i],
359 + Amount = splitAmt,
360 + });
361 + }
362 + }
363 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/ConfigureSwaggerOptions.cs +61 −0
@@ -0,0 +1,61 @@
1 +using Asp.Versioning.ApiExplorer;
2 +using Microsoft.Extensions.Options;
3 +using Microsoft.OpenApi;
4 +using Swashbuckle.AspNetCore.SwaggerGen;
5 +
6 +namespace SplitApp.WebApp.Hosting;
7 +
8 +public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
9 +{
10 + private readonly IApiVersionDescriptionProvider _descriptionProvider;
11 +
12 + public ConfigureSwaggerOptions(IApiVersionDescriptionProvider descriptionProvider)
13 + {
14 + _descriptionProvider = descriptionProvider;
15 + }
16 +
17 + public void Configure(SwaggerGenOptions options)
18 + {
19 + foreach (var description in _descriptionProvider.ApiVersionDescriptions)
20 + {
21 + options.SwaggerDoc(
22 + description.GroupName,
23 + new OpenApiInfo
24 + {
25 + Title = $"SplitApp API {description.ApiVersion}",
26 + Version = description.ApiVersion.ToString(),
27 + });
28 + }
29 +
30 + options.CustomSchemaIds(t => t.FullName);
31 +
32 + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
33 + {
34 + Description =
35 + "JWT Authorization header using the Bearer scheme.\r\n<br/>" +
36 + "Enter your token in the text box below.\r\n<br/>" +
37 + "You will get the bearer from the <i>account/login</i> or <i>account/register</i> endpoint.",
38 + Name = "Authorization",
39 + In = ParameterLocation.Header,
40 + Type = SecuritySchemeType.Http,
41 + Scheme = "Bearer",
42 + BearerFormat = "JWT",
43 + });
44 +
45 + options.DocumentFilter<BearerSecurityRequirementDocumentFilter>();
46 + }
47 +}
48 +
49 +public class BearerSecurityRequirementDocumentFilter : IDocumentFilter
50 +{
51 + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
52 + {
53 + swaggerDoc.Security = new List<OpenApiSecurityRequirement>
54 + {
55 + new()
56 + {
57 + [new OpenApiSecuritySchemeReference("Bearer", swaggerDoc)] = new List<string>(),
58 + },
59 + };
60 + }
61 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/CurrencyConverter.cs +30 −0
@@ -0,0 +1,30 @@
1 +namespace SplitApp.WebApp.Hosting.Helpers;
2 +
3 +public static class CurrencyConverter
4 +{
5 + // Rates: 1 unit of currency = X EUR (approximate, hardcoded)
6 + private static readonly Dictionary<string, decimal> RatesToEur = new()
7 + {
8 + { "EUR", 1.0m },
9 + { "USD", 0.92m },
10 + { "GBP", 1.16m },
11 + { "SEK", 0.087m },
12 + { "NOK", 0.086m },
13 + };
14 +
15 + /// <summary>
16 + /// Convert amount from one currency to another.
17 + /// Falls back to 1:1 if either currency code is unknown.
18 + /// </summary>
19 + public static decimal Convert(decimal amount, string fromCurrencyCode, string toCurrencyCode)
20 + {
21 + if (fromCurrencyCode == toCurrencyCode) return amount;
22 +
23 + if (!RatesToEur.TryGetValue(fromCurrencyCode, out var fromRate)) return amount;
24 + if (!RatesToEur.TryGetValue(toCurrencyCode, out var toRate)) return amount;
25 +
26 + // Convert: amount in "from" -> EUR -> "to"
27 + var amountInEur = amount * fromRate;
28 + return Math.Round(amountInEur / toRate, 2);
29 + }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/EnumHelper.cs +18 −0
@@ -0,0 +1,18 @@
1 +using System.Resources;
2 +
3 +namespace SplitApp.WebApp.Hosting.Helpers;
4 +
5 +/// <summary>Returns the enum's localized display name from App.Resources.Domain.Enums
6 +/// (keys are "{EnumTypeName}_{Value}", e.g. "ETripStatus_Active"). Falls back to the
7 +/// enum's ToString() if the resource lookup fails.</summary>
8 +public static class EnumHelper
9 +{
10 + private static readonly ResourceManager ResManager =
11 + new("App.Resources.Domain.Enums", typeof(App.Resources.Domain.Enums).Assembly);
12 +
13 + public static string GetDisplayName<TEnum>(TEnum value) where TEnum : struct, Enum
14 + {
15 + var key = $"{typeof(TEnum).Name}_{value}";
16 + return ResManager.GetString(key, Thread.CurrentThread.CurrentUICulture) ?? value.ToString();
17 + }
18 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/InvariantDecimalModelBinderProvider.cs +55 −0
@@ -0,0 +1,55 @@
1 +using System.Globalization;
2 +using Microsoft.AspNetCore.Mvc.ModelBinding;
3 +
4 +namespace SplitApp.WebApp.Hosting;
5 +
6 +/// <summary>
7 +/// Ensures decimal values from HTML number inputs (which always send dot-separated values)
8 +/// bind correctly regardless of the server's request culture (e.g. et-EE uses comma).
9 +/// </summary>
10 +public class InvariantDecimalModelBinderProvider : IModelBinderProvider
11 +{
12 + public IModelBinder? GetBinder(ModelBinderProviderContext context)
13 + {
14 + if (context.Metadata.ModelType == typeof(decimal) || context.Metadata.ModelType == typeof(decimal?))
15 + {
16 + return new InvariantDecimalModelBinder();
17 + }
18 +
19 + return null;
20 + }
21 +}
22 +
23 +public class InvariantDecimalModelBinder : IModelBinder
24 +{
25 + public Task BindModelAsync(ModelBindingContext bindingContext)
26 + {
27 + var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
28 + if (valueResult == ValueProviderResult.None)
29 + return Task.CompletedTask;
30 +
31 + var value = valueResult.FirstValue;
32 + if (string.IsNullOrWhiteSpace(value))
33 + {
34 + if (bindingContext.ModelType == typeof(decimal?))
35 + {
36 + bindingContext.Result = ModelBindingResult.Success(null);
37 + }
38 +
39 + return Task.CompletedTask;
40 + }
41 +
42 + value = value.Replace(',', '.');
43 +
44 + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
45 + {
46 + bindingContext.Result = ModelBindingResult.Success(result);
47 + }
48 + else
49 + {
50 + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, "Invalid number format.");
51 + }
52 +
53 + return Task.CompletedTask;
54 + }
55 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/PassthroughStringLocalizer.cs +28 −0
@@ -0,0 +1,28 @@
1 +using System.Globalization;
2 +using Microsoft.Extensions.Localization;
3 +
4 +namespace SplitApp.WebApp.Hosting;
5 +
6 +/// <summary>
7 +/// No-op localizer factory: every requested key is returned unchanged. Phase 2 used
8 +/// resource files; in modular monolith we simply pass the key through so all the
9 +/// `IStringLocalizer<App.Resources.Views.Shared>` references continue to compile and
10 +/// produce sensible English strings.
11 +/// </summary>
12 +public class PassthroughStringLocalizerFactory : IStringLocalizerFactory
13 +{
14 + public IStringLocalizer Create(Type resourceSource) => new PassthroughStringLocalizer();
15 + public IStringLocalizer Create(string baseName, string location) => new PassthroughStringLocalizer();
16 +}
17 +
18 +public class PassthroughStringLocalizer : IStringLocalizer
19 +{
20 + public LocalizedString this[string name] => new(name, name, resourceNotFound: false);
21 +
22 + public LocalizedString this[string name, params object[] arguments]
23 + => new(name, string.Format(CultureInfo.CurrentCulture, name, arguments), resourceNotFound: false);
24 +
25 + public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Array.Empty<LocalizedString>();
26 +}
27 +
28 +public class PassthroughStringLocalizer<T> : PassthroughStringLocalizer, IStringLocalizer<T> { }
added SplitApp.Modular/src/SplitApp.WebApp/Models/ErrorViewModel.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.WebApp.Models;
2 +
3 +public class ErrorViewModel
4 +{
5 + public string? RequestId { get; set; }
6 +
7 + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
8 +}
added SplitApp.Modular/src/SplitApp.WebApp/Program.cs +191 −0
@@ -0,0 +1,191 @@
1 +using System.Globalization;
2 +using Asp.Versioning;
3 +using Asp.Versioning.ApiExplorer;
4 +using Microsoft.AspNetCore.Localization;
5 +using Microsoft.AspNetCore.Mvc;
6 +using Microsoft.Extensions.Options;
7 +using SplitApp.Modules.Expenses.Infrastructure;
8 +using SplitApp.Modules.Trips.Infrastructure;
9 +using SplitApp.Modules.Users.Infrastructure;
10 +using SplitApp.WebApp.Hosting;
11 +using Swashbuckle.AspNetCore.SwaggerGen;
12 +
13 +var builder = WebApplication.CreateBuilder(args);
14 +
15 +// MVC + API + view localization (matches phase 2). ApplicationParts surface every module's
16 +// controllers without the host needing to know specifics.
17 +builder.Services
18 + .AddControllersWithViews(opts =>
19 + {
20 + opts.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider());
21 + })
22 + .AddApplicationPart(typeof(SplitApp.Modules.Users.Api.Controllers.AccountController).Assembly)
23 + .AddApplicationPart(typeof(SplitApp.Modules.Trips.Api.Controllers.TripsController).Assembly)
24 + .AddApplicationPart(typeof(SplitApp.Modules.Expenses.Api.Controllers.ExpensesController).Assembly)
25 + .AddViewLocalization()
26 + .AddDataAnnotationsLocalization(options =>
27 + {
28 + // All [Display]/[Required]/[MaxLength] etc. annotations resolve their labels +
29 + // error messages through Resources/Views/Shared.{resx,et.resx}. This restores
30 + // phase 2's translated labels (e.g. asp-for="Trip.Name" → "Nimi") without
31 + // needing per-entity resx files.
32 + options.DataAnnotationLocalizerProvider = (type, factory) =>
33 + factory.Create(typeof(App.Resources.Views.Shared));
34 + });
35 +
36 +builder.Services.AddRazorPages();
37 +
38 +// API versioning
39 +builder.Services.AddApiVersioning(options =>
40 +{
41 + options.ReportApiVersions = true;
42 + options.DefaultApiVersion = new ApiVersion(1, 0);
43 + options.AssumeDefaultVersionWhenUnspecified = true;
44 + options.ApiVersionReader = new UrlSegmentApiVersionReader();
45 +}).AddApiExplorer(options =>
46 +{
47 + options.GroupNameFormat = "'v'VVV";
48 + options.SubstituteApiVersionInUrl = true;
49 +});
50 +
51 +builder.Services.AddEndpointsApiExplorer();
52 +builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
53 +builder.Services.AddSwaggerGen();
54 +
55 +// Per-module DI registration. Composition root is the only place all three are visible.
56 +builder.Services.AddUsersModule(builder.Configuration);
57 +builder.Services.AddTripsModule(builder.Configuration);
58 +builder.Services.AddExpensesModule(builder.Configuration);
59 +
60 +// Composition-root facade: aggregates the three module DbContexts into a phase-2-style
61 +// IAppUnitOfWork so the lifted BLL services keep working unchanged.
62 +builder.Services.AddScoped<SplitApp.WebApp.Application.Contracts.IAppUnitOfWork,
63 + SplitApp.WebApp.Application.Persistence.AppUnitOfWork>();
64 +builder.Services.AddScoped<SplitApp.WebApp.Application.Persistence.CrossModuleNavigationLoader>();
65 +
66 +// Phase 2 BLL services (lifted into WebApp/Application). These use IAppUnitOfWork.
67 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ITripService, SplitApp.WebApp.Application.Services.TripService>();
68 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IExpenseService, SplitApp.WebApp.Application.Services.ExpenseService>();
69 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISettlementService, SplitApp.WebApp.Application.Services.SettlementService>();
70 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IInvitationService, SplitApp.WebApp.Application.Services.InvitationService>();
71 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IPollService, SplitApp.WebApp.Application.Services.PollService>();
72 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IBudgetCategoryService, SplitApp.WebApp.Application.Services.BudgetCategoryService>();
73 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IWishlistService, SplitApp.WebApp.Application.Services.WishlistService>();
74 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISplitPresetService, SplitApp.WebApp.Application.Services.SplitPresetService>();
75 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Identity.IIdentityService, SplitApp.WebApp.Application.Services.Identity.IdentityService>();
76 +
77 +// Admin BLL services
78 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IBudgetCategoryAdminService, SplitApp.WebApp.Application.Services.Admin.BudgetCategoryAdminService>();
79 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ICurrencyAdminService, SplitApp.WebApp.Application.Services.Admin.CurrencyAdminService>();
80 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripAdminService, SplitApp.WebApp.Application.Services.Admin.TripAdminService>();
81 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IExpenseAdminService, SplitApp.WebApp.Application.Services.Admin.ExpenseAdminService>();
82 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IPollAdminService, SplitApp.WebApp.Application.Services.Admin.PollAdminService>();
83 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IWishlistAdminService, SplitApp.WebApp.Application.Services.Admin.WishlistAdminService>();
84 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPlanAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPlanAdminService>();
85 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPaymentAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPaymentAdminService>();
86 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISplitPresetAdminService, SplitApp.WebApp.Application.Services.Admin.SplitPresetAdminService>();
87 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripParticipantAdminService, SplitApp.WebApp.Application.Services.Admin.TripParticipantAdminService>();
88 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IInvitationAdminService, SplitApp.WebApp.Application.Services.Admin.InvitationAdminService>();
89 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IAdminStatsService, SplitApp.WebApp.Application.Services.Admin.AdminStatsService>();
90 +
91 +// Localization — EN+ET. resx files in Resources/Views/Shared.{resx,et.resx} are
92 +// embedded with explicit LogicalName so they match the App.Resources.Views.Shared
93 +// marker type (declared in Resources/Shared.cs). ResourcesPath="" because the
94 +// LogicalName already targets the namespace directly.
95 +builder.Services.AddLocalization(options => options.ResourcesPath = "");
96 +
97 +var supportedCultures = (builder.Configuration.GetSection("SupportedCultures").Get<string[]>()
98 + ?? new[] { "en", "et" })
99 + .Select(c => new CultureInfo(c)).ToArray();
100 +builder.Services.Configure<RequestLocalizationOptions>(options =>
101 +{
102 + options.SupportedCultures = supportedCultures;
103 + options.SupportedUICultures = supportedCultures;
104 + options.DefaultRequestCulture = new RequestCulture("en", "en");
105 + options.SetDefaultCulture("en");
106 + options.RequestCultureProviders = new List<IRequestCultureProvider>
107 + {
108 + new QueryStringRequestCultureProvider(),
109 + new CookieRequestCultureProvider()
110 + };
111 +});
112 +
113 +builder.Services.AddAuthorization();
114 +
115 +// CORS — same wide-open policy as phase 2 so external SPA frontends (Vite/Vue) can call
116 +// /api/v1/* directly. WithExposedHeaders surfaces api-versioning headers to the JS client.
117 +builder.Services.AddCors(options =>
118 +{
119 + options.AddPolicy("CorsAllowAll", policy =>
120 + {
121 + policy
122 + .AllowAnyOrigin()
123 + .AllowAnyHeader()
124 + .AllowAnyMethod()
125 + .WithExposedHeaders("X-Version", "X-Version-Created-At");
126 + });
127 +});
128 +
129 +var app = builder.Build();
130 +
131 +// Per-module pipeline + migration hooks. Skipped in Testing so WebApplicationFactory
132 +// smoke tests can boot without a real database.
133 +if (!app.Environment.IsEnvironment("Testing"))
134 +{
135 + app.UseUsersModule();
136 + app.UseTripsModule();
137 + app.UseExpensesModule();
138 +
139 + // Cross-module example data — host-level concern, runs after all per-module
140 + // migrations + per-module seeds (currencies, roles, identity users) are in place.
141 + if (app.Configuration.GetValue<bool>("DataInitialization:SeedData"))
142 + {
143 + SplitApp.WebApp.Hosting.AppDataInit.SeedExampleData(app.Services);
144 + }
145 +}
146 +
147 +if (app.Environment.IsDevelopment())
148 +{
149 + app.UseDeveloperExceptionPage();
150 +}
151 +else
152 +{
153 + app.UseExceptionHandler("/Home/Error");
154 +}
155 +
156 +// Swagger is always on so the Docker container (Production env) still serves it.
157 +app.UseSwagger();
158 +app.UseSwaggerUI(options =>
159 +{
160 + var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
161 + foreach (var description in provider.ApiVersionDescriptions)
162 + {
163 + options.SwaggerEndpoint(
164 + $"/swagger/{description.GroupName}/swagger.json",
165 + description.GroupName.ToUpperInvariant());
166 + }
167 +});
168 +
169 +app.UseStaticFiles();
170 +app.UseHttpsRedirection();
171 +app.UseRouting();
172 +
173 +app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value);
174 +
175 +app.UseCors("CorsAllowAll");
176 +
177 +app.UseAuthentication();
178 +app.UseAuthorization();
179 +
180 +app.MapControllerRoute(
181 + name: "areas",
182 + pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
183 +app.MapControllerRoute(
184 + name: "default",
185 + pattern: "{controller=Home}/{action=Index}/{id?}");
186 +app.MapControllers();
187 +app.MapRazorPages();
188 +
189 +app.Run();
190 +
191 +public partial class Program;
added SplitApp.Modular/src/SplitApp.WebApp/Properties/launchSettings.json +23 −0
@@ -0,0 +1,23 @@
1 +{
2 + "$schema": "https://json.schemastore.org/launchsettings.json",
3 + "profiles": {
4 + "http": {
5 + "commandName": "Project",
6 + "dotnetRunMessages": true,
7 + "launchBrowser": true,
8 + "applicationUrl": "http://localhost:5297",
9 + "environmentVariables": {
10 + "ASPNETCORE_ENVIRONMENT": "Development"
11 + }
12 + },
13 + "https": {
14 + "commandName": "Project",
15 + "dotnetRunMessages": true,
16 + "launchBrowser": true,
17 + "applicationUrl": "https://localhost:7133;http://localhost:5297",
18 + "environmentVariables": {
19 + "ASPNETCORE_ENVIRONMENT": "Development"
20 + }
21 + }
22 + }
23 +}
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace App.Resources.Domain;
2 +
3 +/// <summary>
4 +/// Marker class for the Enums.{resx,et.resx} resource bundle. The .resx files are
5 +/// embedded with explicit LogicalName "App.Resources.Domain.Enums.resources" so the
6 +/// ResourceManager can find them regardless of the host project's default namespace.
7 +/// </summary>
8 +public class Enums { }
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.et.resx +70 −0
@@ -0,0 +1,70 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 + <data name="ETripStatus_Active" xml:space="preserve"><value>Aktiivne</value></data>
43 + <data name="ETripStatus_Settled" xml:space="preserve"><value>Arveldatud</value></data>
44 + <data name="ETripStatus_Archived" xml:space="preserve"><value>Arhiveeritud</value></data>
45 + <data name="ETripStatus_Finalizing" xml:space="preserve"><value>Arveldamisel</value></data>
46 + <data name="ESplitMethod_EqualAll" xml:space="preserve"><value>V&#245;rdselt (k&#245;ik)</value></data>
47 + <data name="ESplitMethod_EqualSubset" xml:space="preserve"><value>V&#245;rdselt (valik)</value></data>
48 + <data name="ESplitMethod_ExactAmounts" xml:space="preserve"><value>T&#228;psed summad</value></data>
49 + <data name="ESplitMethod_Percentages" xml:space="preserve"><value>Protsendid</value></data>
50 + <data name="EParticipantRole_Organizer" xml:space="preserve"><value>Korraldaja</value></data>
51 + <data name="EParticipantRole_Participant" xml:space="preserve"><value>Osaleja</value></data>
52 + <data name="EInvitationStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
53 + <data name="EInvitationStatus_Accepted" xml:space="preserve"><value>Vastu v&#245;etud</value></data>
54 + <data name="EInvitationStatus_Declined" xml:space="preserve"><value>Keeldutud</value></data>
55 + <data name="EInvitationStatus_Expired" xml:space="preserve"><value>Aegunud</value></data>
56 + <data name="EInvitationStatus_Revoked" xml:space="preserve"><value>T&#252;histatud</value></data>
57 + <data name="EPaymentStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
58 + <data name="EPaymentStatus_MarkedPaid" xml:space="preserve"><value>M&#228;rgitud makstuks</value></data>
59 + <data name="EPaymentStatus_Confirmed" xml:space="preserve"><value>Kinnitatud</value></data>
60 + <data name="ESettlementStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
61 + <data name="ESettlementStatus_InProgress" xml:space="preserve"><value>Pooleli</value></data>
62 + <data name="ESettlementStatus_Completed" xml:space="preserve"><value>L&#245;petatud</value></data>
63 + <data name="EWishlistCategory_Place" xml:space="preserve"><value>Koht</value></data>
64 + <data name="EWishlistCategory_Activity" xml:space="preserve"><value>Tegevus</value></data>
65 + <data name="EWishlistCategory_Restaurant" xml:space="preserve"><value>Restoran</value></data>
66 + <data name="EWishlistCategory_Other" xml:space="preserve"><value>Muu</value></data>
67 + <data name="EWishlistPriority_MustDo" xml:space="preserve"><value>Kohustuslik</value></data>
68 + <data name="EWishlistPriority_NiceToHave" xml:space="preserve"><value>Soovituslik</value></data>
69 + <data name="EWishlistPriority_Optional" xml:space="preserve"><value>Valikuline</value></data>
70 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.resx +70 −0
@@ -0,0 +1,70 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 + <data name="ETripStatus_Active" xml:space="preserve"><value>Active</value></data>
43 + <data name="ETripStatus_Settled" xml:space="preserve"><value>Settled</value></data>
44 + <data name="ETripStatus_Archived" xml:space="preserve"><value>Archived</value></data>
45 + <data name="ETripStatus_Finalizing" xml:space="preserve"><value>Finalizing</value></data>
46 + <data name="ESplitMethod_EqualAll" xml:space="preserve"><value>Equal (all)</value></data>
47 + <data name="ESplitMethod_EqualSubset" xml:space="preserve"><value>Equal (subset)</value></data>
48 + <data name="ESplitMethod_ExactAmounts" xml:space="preserve"><value>Exact amounts</value></data>
49 + <data name="ESplitMethod_Percentages" xml:space="preserve"><value>Percentages</value></data>
50 + <data name="EParticipantRole_Organizer" xml:space="preserve"><value>Organizer</value></data>
51 + <data name="EParticipantRole_Participant" xml:space="preserve"><value>Participant</value></data>
52 + <data name="EInvitationStatus_Pending" xml:space="preserve"><value>Pending</value></data>
53 + <data name="EInvitationStatus_Accepted" xml:space="preserve"><value>Accepted</value></data>
54 + <data name="EInvitationStatus_Declined" xml:space="preserve"><value>Declined</value></data>
55 + <data name="EInvitationStatus_Expired" xml:space="preserve"><value>Expired</value></data>
56 + <data name="EInvitationStatus_Revoked" xml:space="preserve"><value>Revoked</value></data>
57 + <data name="EPaymentStatus_Pending" xml:space="preserve"><value>Pending</value></data>
58 + <data name="EPaymentStatus_MarkedPaid" xml:space="preserve"><value>Marked Paid</value></data>
59 + <data name="EPaymentStatus_Confirmed" xml:space="preserve"><value>Confirmed</value></data>
60 + <data name="ESettlementStatus_Pending" xml:space="preserve"><value>Pending</value></data>
61 + <data name="ESettlementStatus_InProgress" xml:space="preserve"><value>In Progress</value></data>
62 + <data name="ESettlementStatus_Completed" xml:space="preserve"><value>Completed</value></data>
63 + <data name="EWishlistCategory_Place" xml:space="preserve"><value>Place</value></data>
64 + <data name="EWishlistCategory_Activity" xml:space="preserve"><value>Activity</value></data>
65 + <data name="EWishlistCategory_Restaurant" xml:space="preserve"><value>Restaurant</value></data>
66 + <data name="EWishlistCategory_Other" xml:space="preserve"><value>Other</value></data>
67 + <data name="EWishlistPriority_MustDo" xml:space="preserve"><value>Must Do</value></data>
68 + <data name="EWishlistPriority_NiceToHave" xml:space="preserve"><value>Nice to Have</value></data>
69 + <data name="EWishlistPriority_Optional" xml:space="preserve"><value>Optional</value></data>
70 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Shared.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace App.Resources.Views;
2 +
3 +/// <summary>
4 +/// Marker type retained for IStringLocalizer references in copied phase 2 controllers/views.
5 +/// In modular monolith we route all calls to a no-op localizer that returns the key unchanged.
6 +/// </summary>
7 +public class Shared { }
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.et.resx +513 −0
@@ -0,0 +1,513 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 +
43 + <!-- ========== EXISTING KEYS (preserved) ========== -->
44 + <data name="Save" xml:space="preserve"><value>Salvesta</value></data>
45 + <data name="Cancel" xml:space="preserve"><value>T&#252;hista</value></data>
46 + <data name="Delete" xml:space="preserve"><value>Kustuta</value></data>
47 + <data name="Edit" xml:space="preserve"><value>Muuda</value></data>
48 + <data name="Create" xml:space="preserve"><value>Loo</value></data>
49 + <data name="Back" xml:space="preserve"><value>Tagasi</value></data>
50 + <data name="Confirm" xml:space="preserve"><value>Kinnita</value></data>
51 + <data name="Search" xml:space="preserve"><value>Otsi</value></data>
52 + <data name="Actions" xml:space="preserve"><value>Tegevused</value></data>
53 + <data name="Loading" xml:space="preserve"><value>Laadimine...</value></data>
54 + <data name="NoData" xml:space="preserve"><value>Andmed puuduvad</value></data>
55 + <data name="LogIn" xml:space="preserve"><value>Logi sisse</value></data>
56 + <data name="LogOut" xml:space="preserve"><value>Logi v&#228;lja</value></data>
57 + <data name="Register" xml:space="preserve"><value>Registreeru</value></data>
58 + <data name="Trips" xml:space="preserve"><value>Reisid</value></data>
59 + <data name="Expenses" xml:space="preserve"><value>Kulud</value></data>
60 + <data name="Budget" xml:space="preserve"><value>Eelarve</value></data>
61 + <data name="Members" xml:space="preserve"><value>Liikmed</value></data>
62 + <data name="Wishlist" xml:space="preserve"><value>Soovinimekiri</value></data>
63 + <data name="Polls" xml:space="preserve"><value>K&#252;sitlused</value></data>
64 + <data name="Settlement" xml:space="preserve"><value>Arveldus</value></data>
65 + <data name="Categories" xml:space="preserve"><value>Kategooriad</value></data>
66 + <data name="Dashboard" xml:space="preserve"><value>T&#246;&#246;laud</value></data>
67 + <data name="AreYouSure" xml:space="preserve"><value>Kas oled kindel?</value></data>
68 + <data name="DeleteConfirm" xml:space="preserve"><value>Kas soovid kustutada?</value></data>
69 + <data name="Details" xml:space="preserve"><value>Detailid</value></data>
70 + <data name="Name" xml:space="preserve"><value>Nimi</value></data>
71 + <data name="Description" xml:space="preserve"><value>Kirjeldus</value></data>
72 + <data name="Amount" xml:space="preserve"><value>Summa</value></data>
73 + <data name="Date" xml:space="preserve"><value>Kuup&#228;ev</value></data>
74 + <data name="Status" xml:space="preserve"><value>Olek</value></data>
75 + <data name="Admin" xml:space="preserve"><value>Admin</value></data>
76 +
77 + <!-- ========== COMMON UI ========== -->
78 + <data name="Home" xml:space="preserve"><value>Avaleht</value></data>
79 + <data name="Hello" xml:space="preserve"><value>Tere</value></data>
80 + <data name="Welcome" xml:space="preserve"><value>Tere tulemast</value></data>
81 + <data name="Total" xml:space="preserve"><value>Kokku</value></data>
82 + <data name="Email" xml:space="preserve"><value>E-post</value></data>
83 + <data name="Joined" xml:space="preserve"><value>Liitunud</value></data>
84 + <data name="Role" xml:space="preserve"><value>Roll</value></data>
85 + <data name="Summary" xml:space="preserve"><value>Kokkuv&#245;te</value></data>
86 + <data name="Dates" xml:space="preserve"><value>Kuup&#228;evad</value></data>
87 + <data name="Currency" xml:space="preserve"><value>Valuuta</value></data>
88 + <data name="Currencies" xml:space="preserve"><value>Valuutad</value></data>
89 + <data name="Destination" xml:space="preserve"><value>Sihtkoht</value></data>
90 + <data name="Participants" xml:space="preserve"><value>Osalejad</value></data>
91 + <data name="Code" xml:space="preserve"><value>Kood</value></data>
92 + <data name="Symbol" xml:space="preserve"><value>S&#252;mbol</value></data>
93 + <data name="Select" xml:space="preserve"><value>Vali</value></data>
94 + <data name="Copy" xml:space="preserve"><value>Kopeeri</value></data>
95 + <data name="Split" xml:space="preserve"><value>Jaotus</value></data>
96 + <data name="Member" xml:space="preserve"><value>Liige</value></data>
97 + <data name="Title" xml:space="preserve"><value>Pealkiri</value></data>
98 + <data name="Question" xml:space="preserve"><value>K&#252;simus</value></data>
99 + <data name="Option" xml:space="preserve"><value>Valik</value></data>
100 + <data name="Options" xml:space="preserve"><value>Valikud</value></data>
101 + <data name="Priority" xml:space="preserve"><value>Prioriteet</value></data>
102 + <data name="Location" xml:space="preserve"><value>Asukoht</value></data>
103 + <data name="URL" xml:space="preserve"><value>URL</value></data>
104 + <data name="User" xml:space="preserve"><value>Kasutaja</value></data>
105 + <data name="Trip" xml:space="preserve"><value>Reis</value></data>
106 + <data name="Close" xml:space="preserve"><value>Sulge</value></data>
107 + <data name="Yes" xml:space="preserve"><value>Jah</value></data>
108 + <data name="No" xml:space="preserve"><value>Ei</value></data>
109 + <data name="Submit" xml:space="preserve"><value>Esita</value></data>
110 + <data name="Add" xml:space="preserve"><value>Lisa</value></data>
111 + <data name="Remove" xml:space="preserve"><value>Eemalda</value></data>
112 + <data name="Unknown" xml:space="preserve"><value>Tundmatu</value></data>
113 + <data name="or" xml:space="preserve"><value>v&#245;i</value></data>
114 + <data name="of" xml:space="preserve"><value>st</value></data>
115 + <data name="Category" xml:space="preserve"><value>Kategooria</value></data>
116 + <data name="Expires" xml:space="preserve"><value>Aegub</value></data>
117 + <data name="Nickname" xml:space="preserve"><value>H&#252;&#252;dnimi</value></data>
118 +
119 + <!-- ========== TRIP ========== -->
120 + <data name="My Trips" xml:space="preserve"><value>Minu reisid</value></data>
121 + <data name="Create Trip" xml:space="preserve"><value>Loo reis</value></data>
122 + <data name="Edit Trip" xml:space="preserve"><value>Muuda reisi</value></data>
123 + <data name="Delete Trip" xml:space="preserve"><value>Kustuta reis</value></data>
124 + <data name="Trip Information" xml:space="preserve"><value>Reisi info</value></data>
125 + <data name="Back to Trips" xml:space="preserve"><value>Tagasi reiside juurde</value></data>
126 + <data name="Back to Trip" xml:space="preserve"><value>Tagasi reisi juurde</value></data>
127 + <data name="No trips yet" xml:space="preserve"><value>Reisid puuduvad</value></data>
128 + <data name="Create your first trip to get started!" xml:space="preserve"><value>Loo oma esimene reis alustamiseks!</value></data>
129 + <data name="View Details" xml:space="preserve"><value>Vaata detaile</value></data>
130 + <data name="View All" xml:space="preserve"><value>Vaata k&#245;iki</value></data>
131 + <data name="Go to My Trips" xml:space="preserve"><value>Mine minu reiside juurde</value></data>
132 + <data name="Start Date" xml:space="preserve"><value>Alguskuup&#228;ev</value></data>
133 + <data name="End Date" xml:space="preserve"><value>L&#245;ppkuup&#228;ev</value></data>
134 + <data name="Default Currency" xml:space="preserve"><value>Vaikevaluuta</value></data>
135 + <data name="Select currency..." xml:space="preserve"><value>Vali valuuta...</value></data>
136 + <data name="Are you sure you want to delete this trip? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle reisi kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
137 +
138 + <!-- ========== EXPENSE ========== -->
139 + <data name="Add Expense" xml:space="preserve"><value>Lisa kulu</value></data>
140 + <data name="Edit Expense" xml:space="preserve"><value>Muuda kulu</value></data>
141 + <data name="Delete Expense" xml:space="preserve"><value>Kustuta kulu</value></data>
142 + <data name="Paid By" xml:space="preserve"><value>Maksis</value></data>
143 + <data name="Who Paid" xml:space="preserve"><value>Kes maksis</value></data>
144 + <data name="Split Method" xml:space="preserve"><value>Jagamismeetod</value></data>
145 + <data name="Select Participants" xml:space="preserve"><value>Vali osalejad</value></data>
146 + <data name="No category" xml:space="preserve"><value>Kategooriata</value></data>
147 + <data name="Trip default" xml:space="preserve"><value>Reisi vaikimisi</value></data>
148 + <data name="No expenses yet" xml:space="preserve"><value>Kulusid pole veel</value></data>
149 + <data name="Add your first expense to start tracking." xml:space="preserve"><value>Lisa oma esimene kulu j&#228;lgimise alustamiseks.</value></data>
150 + <data name="Recent Expenses" xml:space="preserve"><value>Hiljutised kulud</value></data>
151 + <data name="Expense" xml:space="preserve"><value>Kulu</value></data>
152 + <data name="Total Expenses" xml:space="preserve"><value>Kulud kokku</value></data>
153 + <data name="Are you sure you want to delete this expense? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle kulu kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
154 + <data name="Estimated Cost" xml:space="preserve"><value>Hinnanguline maksumus</value></data>
155 +
156 + <!-- ========== SPLIT METHODS ========== -->
157 + <data name="Equal (all)" xml:space="preserve"><value>V&#245;rdselt (k&#245;ik)</value></data>
158 + <data name="Equal (subset)" xml:space="preserve"><value>V&#245;rdselt (valik)</value></data>
159 + <data name="Exact amounts" xml:space="preserve"><value>T&#228;psed summad</value></data>
160 + <data name="Percentages" xml:space="preserve"><value>Protsendid</value></data>
161 +
162 + <!-- ========== MEMBERS ========== -->
163 + <data name="Current Members" xml:space="preserve"><value>Praegused liikmed</value></data>
164 + <data name="Pending Invitations" xml:space="preserve"><value>Ootel kutsed</value></data>
165 + <data name="Invite Member" xml:space="preserve"><value>Kutsu liige</value></data>
166 + <data name="Generate Invitation Link" xml:space="preserve"><value>Genereeri kutse link</value></data>
167 + <data name="Back to Members" xml:space="preserve"><value>Tagasi liikmete juurde</value></data>
168 + <data name="Invited by" xml:space="preserve"><value>Kutsuja</value></data>
169 + <data name="Are you sure you want to remove this member?" xml:space="preserve"><value>Kas soovid selle liikme eemaldada?</value></data>
170 +
171 + <!-- ========== SETTLEMENT ========== -->
172 + <data name="Balances" xml:space="preserve"><value>Saldod</value></data>
173 + <data name="Settlement Plan" xml:space="preserve"><value>Arveldusplaan</value></data>
174 + <data name="Total Paid" xml:space="preserve"><value>Kokku makstud</value></data>
175 + <data name="Total Owed" xml:space="preserve"><value>Kokku v&#245;lgnetav</value></data>
176 + <data name="Net Balance" xml:space="preserve"><value>Neto saldo</value></data>
177 + <data name="From" xml:space="preserve"><value>Kellelt</value></data>
178 + <data name="To" xml:space="preserve"><value>Kellele</value></data>
179 + <data name="Calculate Settlement" xml:space="preserve"><value>Arvuta arveldus</value></data>
180 + <data name="Mark Paid" xml:space="preserve"><value>M&#228;rgi makstuks</value></data>
181 + <data name="Confirm Receipt" xml:space="preserve"><value>Kinnita kviitung</value></data>
182 + <data name="No settlement plan generated yet. Click 'Calculate Settlement' to generate one." xml:space="preserve"><value>Arveldusplaani pole veel loodud. Kliki 'Arvuta arveldus' selle loomiseks.</value></data>
183 +
184 + <!-- ========== BUDGET ========== -->
185 + <data name="Total Planned" xml:space="preserve"><value>Kokku planeeritud</value></data>
186 + <data name="Total Spent" xml:space="preserve"><value>Kokku kulutatud</value></data>
187 + <data name="Remaining" xml:space="preserve"><value>J&#228;&#228;k</value></data>
188 + <data name="No budget categories yet" xml:space="preserve"><value>Eelarve kategooriad puuduvad</value></data>
189 + <data name="Add categories to track your trip budget." xml:space="preserve"><value>Lisa kategooriaid oma reisieelarve j&#228;lgimiseks.</value></data>
190 + <data name="Add Category" xml:space="preserve"><value>Lisa kategooria</value></data>
191 + <data name="Add Budget Category" xml:space="preserve"><value>Lisa eelarve kategooria</value></data>
192 + <data name="Edit Budget Category" xml:space="preserve"><value>Muuda eelarve kategooriat</value></data>
193 + <data name="Delete Budget Category" xml:space="preserve"><value>Kustuta eelarve kategooria</value></data>
194 + <data name="Category Name" xml:space="preserve"><value>Kategooria nimi</value></data>
195 + <data name="Icon Name" xml:space="preserve"><value>Ikooni nimi</value></data>
196 + <data name="Bootstrap Icons name (optional)" xml:space="preserve"><value>Bootstrap Icons nimi (valikuline)</value></data>
197 + <data name="Planned Amount" xml:space="preserve"><value>Planeeritud summa</value></data>
198 + <data name="Display Order" xml:space="preserve"><value>Kuvamisj&#228;rjekord</value></data>
199 + <data name="Are you sure you want to delete this budget category? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle eelarve kategooria kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
200 +
201 + <!-- ========== WISHLIST ========== -->
202 + <data name="Link" xml:space="preserve"><value>Link</value></data>
203 + <data name="Added by" xml:space="preserve"><value>Lisas</value></data>
204 + <data name="Vote" xml:space="preserve"><value>H&#228;&#228;leta</value></data>
205 + <data name="Voted" xml:space="preserve"><value>H&#228;&#228;letatud</value></data>
206 + <data name="Mark Done" xml:space="preserve"><value>M&#228;rgi tehtuks</value></data>
207 + <data name="Completed" xml:space="preserve"><value>Tehtud</value></data>
208 + <data name="Add Item" xml:space="preserve"><value>Lisa element</value></data>
209 + <data name="Add Wishlist Item" xml:space="preserve"><value>Lisa soovinimekirja element</value></data>
210 + <data name="No wishlist items yet" xml:space="preserve"><value>Soovinimekirja elemendid puuduvad</value></data>
211 + <data name="Add places, activities, or restaurants you want to visit." xml:space="preserve"><value>Lisa kohti, tegevusi v&#245;i restorane, mida soovid k&#252;lastada.</value></data>
212 +
213 + <!-- ========== POLLS ========== -->
214 + <data name="Create Poll" xml:space="preserve"><value>Loo k&#252;sitlus</value></data>
215 + <data name="Open" xml:space="preserve"><value>Avatud</value></data>
216 + <data name="Closed" xml:space="preserve"><value>Suletud</value></data>
217 + <data name="Created by" xml:space="preserve"><value>Looja</value></data>
218 + <data name="View Results" xml:space="preserve"><value>Vaata tulemusi</value></data>
219 + <data name="Close Poll" xml:space="preserve"><value>Sulge k&#252;sitlus</value></data>
220 + <data name="Back to Polls" xml:space="preserve"><value>Tagasi k&#252;sitluste juurde</value></data>
221 + <data name="Poll Results" xml:space="preserve"><value>K&#252;sitluse tulemused</value></data>
222 + <data name="No polls yet" xml:space="preserve"><value>K&#252;sitlused puuduvad</value></data>
223 + <data name="Create a poll to help your group make decisions." xml:space="preserve"><value>Loo k&#252;sitlus, et aidata grupil otsuseid teha.</value></data>
224 + <data name="votes" xml:space="preserve"><value>h&#228;&#228;lt</value></data>
225 + <data name="Multiple votes allowed" xml:space="preserve"><value>Mitu h&#228;&#228;lt lubatud</value></data>
226 + <data name="Allow multiple votes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
227 + <data name="Anonymous voting" xml:space="preserve"><value>Anonuumne h&#228;&#228;letamine</value></data>
228 + <data name="At least 2 options are required. Up to 5 options supported." xml:space="preserve"><value>Vajalik on v&#228;hemalt 2 valikut. Toetatud kuni 5 valikut.</value></data>
229 + <data name="total votes" xml:space="preserve"><value>h&#228;&#228;lt kokku</value></data>
230 + <data name="Your vote" xml:space="preserve"><value>Sinu h&#228;&#228;l</value></data>
231 + <data name="Winner" xml:space="preserve"><value>V&#245;itja</value></data>
232 + <data name="Unvote" xml:space="preserve"><value>T&#252;hista h&#228;&#228;l</value></data>
233 +
234 + <!-- ========== ADMIN ========== -->
235 + <data name="AdminDashboard" xml:space="preserve"><value>Administreerimislaud</value></data>
236 + <data name="ManageEntities" xml:space="preserve"><value>Halda andmeid</value></data>
237 + <data name="ManageTrips" xml:space="preserve"><value>Halda reise</value></data>
238 + <data name="ManageExpenses" xml:space="preserve"><value>Halda kulusid</value></data>
239 + <data name="ManageCategories" xml:space="preserve"><value>Halda kategooriaid</value></data>
240 + <data name="ManageCurrencies" xml:space="preserve"><value>Halda valuutasid</value></data>
241 + <data name="ManageSettlements" xml:space="preserve"><value>Halda arveldusi</value></data>
242 + <data name="ManageParticipants" xml:space="preserve"><value>Halda osalejaid</value></data>
243 + <data name="ManageWishlist" xml:space="preserve"><value>Halda soovinimekirja</value></data>
244 + <data name="ManagePolls" xml:space="preserve"><value>Halda k&#252;sitlusi</value></data>
245 + <data name="Users" xml:space="preserve"><value>Kasutajad</value></data>
246 + <data name="Invitations" xml:space="preserve"><value>Kutsed</value></data>
247 +
248 + <!-- ========== ADMIN ENTITY FIELD KEYS ========== -->
249 + <data name="StartDate" xml:space="preserve"><value>Alguskuup&#228;ev</value></data>
250 + <data name="EndDate" xml:space="preserve"><value>L&#245;ppkuup&#228;ev</value></data>
251 + <data name="CreatedBy" xml:space="preserve"><value>Looja</value></data>
252 + <data name="DefaultCurrency" xml:space="preserve"><value>Vaikevaluuta</value></data>
253 + <data name="ExpenseDate" xml:space="preserve"><value>Kulu kuup&#228;ev</value></data>
254 + <data name="PaidByUser" xml:space="preserve"><value>Maksis</value></data>
255 + <data name="SplitMethod" xml:space="preserve"><value>Jagamismeetod</value></data>
256 + <data name="BudgetCategory" xml:space="preserve"><value>Eelarve kategooria</value></data>
257 + <data name="BudgetCategories" xml:space="preserve"><value>Eelarve kategooriad</value></data>
258 + <data name="PlannedAmount" xml:space="preserve"><value>Planeeritud summa</value></data>
259 + <data name="DisplayOrder" xml:space="preserve"><value>Kuvamisj&#228;rjekord</value></data>
260 + <data name="IconName" xml:space="preserve"><value>Ikooni nimi</value></data>
261 + <data name="SettlementPlans" xml:space="preserve"><value>Arveldusplaanid</value></data>
262 + <data name="SettlementPlan" xml:space="preserve"><value>Arveldusplaan</value></data>
263 + <data name="TotalAmount" xml:space="preserve"><value>Kogusumma</value></data>
264 + <data name="CreatedByUser" xml:space="preserve"><value>Looja</value></data>
265 + <data name="CompletedAt" xml:space="preserve"><value>L&#245;petatud</value></data>
266 + <data name="TripParticipants" xml:space="preserve"><value>Reisi osalejad</value></data>
267 + <data name="TripParticipant" xml:space="preserve"><value>Reisi osaleja</value></data>
268 + <data name="JoinedAt" xml:space="preserve"><value>Liitumisaeg</value></data>
269 + <data name="LeftAt" xml:space="preserve"><value>Lahkumisaeg</value></data>
270 + <data name="IsActive" xml:space="preserve"><value>Aktiivne</value></data>
271 +
272 + <!-- ========== ENUM VALUES ========== -->
273 + <data name="Active" xml:space="preserve"><value>Aktiivne</value></data>
274 + <data name="Settled" xml:space="preserve"><value>Arveldatud</value></data>
275 + <data name="Archived" xml:space="preserve"><value>Arhiveeritud</value></data>
276 + <data name="Pending" xml:space="preserve"><value>Ootel</value></data>
277 + <data name="Accepted" xml:space="preserve"><value>Vastu v&#245;etud</value></data>
278 + <data name="Declined" xml:space="preserve"><value>Keeldutud</value></data>
279 + <data name="Expired" xml:space="preserve"><value>Aegunud</value></data>
280 + <data name="Revoked" xml:space="preserve"><value>T&#252;histatud</value></data>
281 + <data name="Organizer" xml:space="preserve"><value>Korraldaja</value></data>
282 + <data name="Participant" xml:space="preserve"><value>Osaleja</value></data>
283 + <data name="MustDo" xml:space="preserve"><value>Kohustuslik</value></data>
284 + <data name="NiceToHave" xml:space="preserve"><value>Soovituslik</value></data>
285 + <data name="Optional" xml:space="preserve"><value>Valikuline</value></data>
286 + <data name="Place" xml:space="preserve"><value>Koht</value></data>
287 + <data name="Activity" xml:space="preserve"><value>Tegevus</value></data>
288 + <data name="Restaurant" xml:space="preserve"><value>Restoran</value></data>
289 + <data name="Other" xml:space="preserve"><value>Muu</value></data>
290 + <data name="InProgress" xml:space="preserve"><value>Pooleli</value></data>
291 + <data name="MarkedPaid" xml:space="preserve"><value>M&#228;rgitud makstuks</value></data>
292 + <data name="Confirmed" xml:space="preserve"><value>Kinnitatud</value></data>
293 +
294 + <!-- ========== HOME PAGE ========== -->
295 + <data name="AppTagline" xml:space="preserve"><value>R&#252;hma reisikulude haldamine lihtsaks tehtud.</value></data>
296 + <data name="AppDescription" xml:space="preserve"><value>J&#228;lgi kulusid, jaga kulusid, halda eelarveid ja arvlda v&#245;lgu oma reisigrupiga.</value></data>
297 +
298 + <!-- ========== INVITATION ========== -->
299 + <data name="Accept Invitation" xml:space="preserve"><value>V&#245;ta kutse vastu</value></data>
300 + <data name="Invitation Generated" xml:space="preserve"><value>Kutse genereeritud</value></data>
301 + <data name="Invalid Invitation" xml:space="preserve"><value>Kehtetu kutse</value></data>
302 + <data name="You've Been Invited!" xml:space="preserve"><value>Sind on kutsutud!</value></data>
303 + <data name="By accepting, you will join this trip as a participant." xml:space="preserve"><value>Vastu v&#245;ttes liitud selle reisiga osalejana.</value></data>
304 + <data name="This invitation is no longer valid." xml:space="preserve"><value>See kutse ei ole enam kehtiv.</value></data>
305 + <data name="Invitation link has been generated successfully!" xml:space="preserve"><value>Kutse link on edukalt genereeritud!</value></data>
306 + <data name="Share this link with the person you want to invite:" xml:space="preserve"><value>Jaga seda linki isikuga, keda soovid kutsuda:</value></data>
307 + <data name="This link will expire in 7 days." xml:space="preserve"><value>See link aegub 7 p&#228;eva p&#228;rast.</value></data>
308 + <data name="The invitation link will expire after 7 days." xml:space="preserve"><value>Kutse link aegub 7 p&#228;eva p&#228;rast.</value></data>
309 + <data name="Generate an invitation link that you can share with someone to join this trip." xml:space="preserve"><value>Genereeri kutse link, mida saad jagada kellegagi, kes soovib reisiga liituda.</value></data>
310 +
311 + <!-- ========== ADDITIONAL KEYS ========== -->
312 + <data name="AddedBy" xml:space="preserve"><value>Lisas</value></data>
313 + <data name="AllowMultipleVotes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
314 + <data name="Back to List" xml:space="preserve"><value>Tagasi nimekirja</value></data>
315 + <data name="Budget Categories" xml:space="preserve"><value>Eelarve kategooriad</value></data>
316 + <data name="ClosedAt" xml:space="preserve"><value>Suletud</value></data>
317 + <data name="CreatePoll" xml:space="preserve"><value>Loo k&#252;sitlus</value></data>
318 + <data name="DeletePoll" xml:space="preserve"><value>Kustuta k&#252;sitlus</value></data>
319 + <data name="EditPoll" xml:space="preserve"><value>Muuda k&#252;sitlust</value></data>
320 + <data name="IsAnonymous" xml:space="preserve"><value>Anonuumne</value></data>
321 + <data name="Manage Categories" xml:space="preserve"><value>Halda kategooriaid</value></data>
322 + <data name="Manage Currencies" xml:space="preserve"><value>Halda valuutasid</value></data>
323 + <data name="Manage Entities" xml:space="preserve"><value>Halda andmeid</value></data>
324 + <data name="Manage Expenses" xml:space="preserve"><value>Halda kulusid</value></data>
325 + <data name="Manage Participants" xml:space="preserve"><value>Halda osalejaid</value></data>
326 + <data name="Manage Polls" xml:space="preserve"><value>Halda k&#252;sitlusi</value></data>
327 + <data name="Manage Settlements" xml:space="preserve"><value>Halda arveldusi</value></data>
328 + <data name="Manage Trips" xml:space="preserve"><value>Halda reise</value></data>
329 + <data name="Manage Wishlist" xml:space="preserve"><value>Halda soovinimekirja</value></data>
330 + <data name="PollDetails" xml:space="preserve"><value>K&#252;sitluse detailid</value></data>
331 + <data name="Settlements" xml:space="preserve"><value>Arveldused</value></data>
332 + <data name="Text" xml:space="preserve"><value>Tekst</value></data>
333 + <data name="Toggle navigation" xml:space="preserve"><value>L&#252;lita navigatsioon</value></data>
334 + <data name="Wishlist Items" xml:space="preserve"><value>Soovinimekirja elemendid</value></data>
335 + <data name="WishlistItem" xml:space="preserve"><value>Soovinimekirja element</value></data>
336 + <data name="WishlistItemDetails" xml:space="preserve"><value>Soovinimekirja elemendi detailid</value></data>
337 +
338 + <!-- ========== NEW KEYS ========== -->
339 + <data name="Add expenses as you go. Choose how to split — we handle the math." xml:space="preserve"><value>Lisa kulusid jooksvalt. Vali jagamisviis &#x2014; meie teeme matemaatika.</value></data>
340 + <data name="Admin Panel" xml:space="preserve"><value>Administreerimispaneel</value></data>
341 + <data name="All settled up!" xml:space="preserve"><value>K&#245;ik arveldatud!</value></data>
342 + <data name="An error occurred while processing your request." xml:space="preserve"><value>Teie p&#228;ringu t&#246;&#246;tlemisel tekkis viga.</value></data>
343 + <data name="Anonymous" xml:space="preserve"><value>Anon&#252;&#252;mne</value></data>
344 + <data name="At least 2 required" xml:space="preserve"><value>V&#228;hemalt 2 n&#245;utud</value></data>
345 + <data name="Back to App" xml:space="preserve"><value>Tagasi rakendusse</value></data>
346 + <data name="Back to Home" xml:space="preserve"><value>Tagasi avalehele</value></data>
347 + <data name="Budget Progress" xml:space="preserve"><value>Eelarve edenemine</value></data>
348 + <data name="Budget Used" xml:space="preserve"><value>Eelarve kasutatud</value></data>
349 + <data name="Closed at" xml:space="preserve"><value>Suletud</value></data>
350 + <data name="Create Free Account" xml:space="preserve"><value>Loo tasuta konto</value></data>
351 + <data name="Create a Trip" xml:space="preserve"><value>Loo reis</value></data>
352 + <data name="Create polls, build wishlists, and vote together to make group planning effortless." xml:space="preserve"><value>Loo k&#252;sitlusi, koosta soovinimekirju ja h&#228;&#228;leta koos, et grupiplaneerimine oleks vaevatu.</value></data>
353 + <data name="Done" xml:space="preserve"><value>Tehtud</value></data>
354 + <data name="Entities" xml:space="preserve"><value>Andmed</value></data>
355 + <data name="Entity Management" xml:space="preserve"><value>Andmete haldus</value></data>
356 + <data name="Error" xml:space="preserve"><value>Viga</value></data>
357 + <data name="Everything you need for group travel" xml:space="preserve"><value>K&#245;ik, mida vajad grupireisiks</value></data>
358 + <data name="From splitting dinner bills to planning activities, SplitApp handles it all." xml:space="preserve"><value>&#213;htus&#246;&#246;giarve jagamisest tegevuste planeerimiseni &#x2014; SplitApp teeb k&#245;ik &#228;ra.</value></data>
359 + <data name="Get Started" xml:space="preserve"><value>Alusta</value></data>
360 + <data name="Group Decisions" xml:space="preserve"><value>Grupiotsused</value></data>
361 + <data name="Help your group make decisions together" xml:space="preserve"><value>Aita grupil koos otsuseid teha</value></data>
362 + <data name="How it works" xml:space="preserve"><value>Kuidas see t&#246;&#246;tab</value></data>
363 + <data name="Icon" xml:space="preserve"><value>Ikoon</value></data>
364 + <data name="Invite" xml:space="preserve"><value>Kutsu</value></data>
365 + <data name="Join thousands of travelers who split smarter." xml:space="preserve"><value>Liitu tuhandete reisijatega, kes jagavad targemalt.</value></data>
366 + <data name="Learn More" xml:space="preserve"><value>Loe lisaks</value></data>
367 + <data name="Link Generated!" xml:space="preserve"><value>Link genereeritud!</value></data>
368 + <data name="Log in" xml:space="preserve"><value>Logi sisse</value></data>
369 + <data name="Manage Wishlist Items" xml:space="preserve"><value>Halda soovinimekirja elemente</value></data>
370 + <data name="Multi-Vote" xml:space="preserve"><value>Mitu h&#228;&#228;lt</value></data>
371 + <data name="Multiple votes" xml:space="preserve"><value>Mitu h&#228;&#228;lt</value></data>
372 + <data name="New Category" xml:space="preserve"><value>Uus kategooria</value></data>
373 + <data name="New Currency" xml:space="preserve"><value>Uus valuuta</value></data>
374 + <data name="New Expense" xml:space="preserve"><value>Uus kulu</value></data>
375 + <data name="New Trip" xml:space="preserve"><value>Uus reis</value></data>
376 + <data name="No description" xml:space="preserve"><value>Kirjeldus puudub</value></data>
377 + <data name="No payments needed — everyone is even." xml:space="preserve"><value>Makseid pole vaja &#x2014; k&#245;ik on tasas.</value></data>
378 + <data name="No users yet" xml:space="preserve"><value>Kasutajaid pole veel</value></data>
379 + <data name="Not friendships." xml:space="preserve"><value>Mitte s&#245;prussuhteid.</value></data>
380 + <data name="Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification." xml:space="preserve"><value>Optimeeritud arveldus arvutab vajalike maksete miinimumi. Kinnita kahepoolse kinnitusega.</value></data>
381 + <data name="Over budget!" xml:space="preserve"><value>Eelarve &#252;letatud!</value></data>
382 + <data name="Overall Progress" xml:space="preserve"><value>&#220;ldine edenemine</value></data>
383 + <data name="Overview" xml:space="preserve"><value>&#220;levaade</value></data>
384 + <data name="Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place." xml:space="preserve"><value>Planeeri reise, j&#228;lgi kulusid, jaga kulusid ja arvlda v&#245;lgu oma reisigrupiga &#x2014; k&#245;ik &#252;hes kohas.</value></data>
385 + <data name="Planned" xml:space="preserve"><value>Planeeritud</value></data>
386 + <data name="Poll" xml:space="preserve"><value>K&#252;sitlus</value></data>
387 + <data name="Privacy Policy" xml:space="preserve"><value>Privaatsuspoliitika</value></data>
388 + <data name="Profile" xml:space="preserve"><value>Profiil</value></data>
389 + <data name="Quick Links" xml:space="preserve"><value>Kiirlingid</value></data>
390 + <data name="Ready to plan your next trip?" xml:space="preserve"><value>Valmis planeerima oma j&#228;rgmist reisi?</value></data>
391 + <data name="Recalculate" xml:space="preserve"><value>Arvuta uuesti</value></data>
392 + <data name="Recalculate after new expenses" xml:space="preserve"><value>Arvuta uuesti p&#228;rast uusi kulusid</value></data>
393 + <data name="Recent Trips" xml:space="preserve"><value>Hiljutised reisid</value></data>
394 + <data name="Recent Users" xml:space="preserve"><value>Hiljutised kasutajad</value></data>
395 + <data name="Request ID" xml:space="preserve"><value>P&#228;ringu ID</value></data>
396 + <data name="Search..." xml:space="preserve"><value>Otsi...</value></data>
397 + <data name="See who owes whom and settle with minimal payments." xml:space="preserve"><value>Vaata, kes kellele v&#245;lgneb, ja arvlda minimaalsete maksetega.</value></data>
398 + <data name="Set up your next adventure" xml:space="preserve"><value>Seadista oma j&#228;rgmine seiklus</value></data>
399 + <data name="Set up your trip and invite friends with a simple shareable link." xml:space="preserve"><value>Seadista oma reis ja kutsu s&#245;brad lihtsa jagatava lingiga.</value></data>
400 + <data name="Settle Up" xml:space="preserve"><value>Arvlda</value></data>
401 + <data name="Share what you want to experience" xml:space="preserve"><value>Jaga, mida soovid kogeda</value></data>
402 + <data name="Sign up" xml:space="preserve"><value>Registreeru</value></data>
403 + <data name="Something went wrong" xml:space="preserve"><value>Midagi l&#228;ks valesti</value></data>
404 + <data name="Split Any Way" xml:space="preserve"><value>Jaga kulusid</value></data>
405 + <data name="Split equally, by exact amounts, or percentages. Save presets for recurring groups." xml:space="preserve"><value>Jaga v&#245;rdselt, t&#228;psete summade v&#245;i protsentidega. Salvesta eelseaded korduvate gruppide jaoks.</value></data>
406 + <data name="Split expenses." xml:space="preserve"><value>Jaga kulusid.</value></data>
407 + <data name="Start tracking your group expenses." xml:space="preserve"><value>Alusta oma grupi kulude j&#228;lgimist.</value></data>
408 + <data name="System Administration" xml:space="preserve"><value>S&#252;steemihaldus</value></data>
409 + <data name="Total Trips" xml:space="preserve"><value>Reise kokku</value></data>
410 + <data name="Track &amp; Split" xml:space="preserve"><value>J&#228;lgi ja jaga</value></data>
411 + <data name="Track a group expense" xml:space="preserve"><value>J&#228;lgi grupi kulu</value></data>
412 + <data name="Use this page to detail your site's privacy policy." xml:space="preserve"><value>Kasuta seda lehte oma saidi privaatsuspoliitika kirjeldamiseks.</value></data>
413 + <data name="View Link" xml:space="preserve"><value>Vaata linki</value></data>
414 + <data name="What should the group decide?" xml:space="preserve"><value>Mida peaks grupp otsustama?</value></data>
415 + <data name="What's the plan?" xml:space="preserve"><value>Mis on plaan?</value></data>
416 + <data name="Why do you want to go here?" xml:space="preserve"><value>Miks soovid sinna minna?</value></data>
417 + <data name="Wishlist Item" xml:space="preserve"><value>Soovinimekirja element</value></data>
418 + <data name="Your Balance" xml:space="preserve"><value>Sinu saldo</value></data>
419 + <data name="e.g. Group dinner, taxi, museum tickets..." xml:space="preserve"><value>nt Grupi&#245;htus&#246;&#246;k, takso, muuseumi piletid...</value></data>
420 + <data name="e.g. Old Town, City Center" xml:space="preserve"><value>nt Vanalinn, Kesklinn</value></data>
421 + <data name="e.g. Sagrada Familia, Local tapas bar" xml:space="preserve"><value>nt Sagrada Familia, Kohalik tapas baar</value></data>
422 + <data name="in progress" xml:space="preserve"><value>pooleli</value></data>
423 + <data name="member(s)" xml:space="preserve"><value>liige/liikmed</value></data>
424 + <data name="payment(s) marked paid, awaiting confirmation" xml:space="preserve"><value>makse/maksed m&#228;rgitud makstuks, ootab kinnitust</value></data>
425 + <data name="pending invitation(s)" xml:space="preserve"><value>ootel kutse/kutsed</value></data>
426 + <data name="settlement payment(s) awaiting action" xml:space="preserve"><value>arvelduse makse/maksed ootavad tegevust</value></data>
427 + <data name="trip(s)" xml:space="preserve"><value>reis(i)</value></data>
428 +
429 + <data name="All" xml:space="preserve"><value>Kõik</value></data>
430 +
431 + <!-- ========== SPLIT PRESETS ========== -->
432 + <data name="Apply Preset" xml:space="preserve"><value>Rakenda mall</value></data>
433 + <data name="Select a preset..." xml:space="preserve"><value>Vali mall...</value></data>
434 + <data name="Apply" xml:space="preserve"><value>Rakenda</value></data>
435 + <data name="Save current split as preset" xml:space="preserve"><value>Salvesta praegune jaotus mallina</value></data>
436 + <data name="Preset name, e.g. Hotel group" xml:space="preserve"><value>Malli nimi, nt Hotelli grupp</value></data>
437 +
438 + <!-- ========== WISHLIST EDIT/DELETE ========== -->
439 + <data name="Edit Wishlist Item" xml:space="preserve"><value>Muuda soovinimekirja elementi</value></data>
440 + <data name="Delete Wishlist Item" xml:space="preserve"><value>Kustuta soovinimekirja element</value></data>
441 +
442 + <!-- ========== ADMIN USER MANAGEMENT ========== -->
443 + <data name="User Management" xml:space="preserve"><value>Kasutajate haldus</value></data>
444 + <data name="Edit Roles" xml:space="preserve"><value>Muuda rolle</value></data>
445 + <data name="System" xml:space="preserve"><value>S&#252;steem</value></data>
446 + <data name="More" xml:space="preserve"><value>Veel</value></data>
447 + <data name="Payments" xml:space="preserve"><value>Maksed</value></data>
448 + <data name="Split Presets" xml:space="preserve"><value>Jaotuse mallid</value></data>
449 + <data name="Revoke" xml:space="preserve"><value>T&#252;hista</value></data>
450 +
451 + <!-- ADMIN REBUILD ADDITIONS -->
452 + <data name="Core" xml:space="preserve"><value>Põhi</value></data>
453 + <data name="Back to site" xml:space="preserve"><value>Tagasi saidile</value></data>
454 + <data name="System overview and real-time statistics" xml:space="preserve"><value>Süsteemi ülevaade ja reaalajas statistika</value></data>
455 + <data name="payments awaiting action" xml:space="preserve"><value>makset ootavad tegevust</value></data>
456 + <data name="payments awaiting confirmation" xml:space="preserve"><value>makset ootavad kinnitust</value></data>
457 + <data name="Total Users" xml:space="preserve"><value>Kasutajaid kokku</value></data>
458 + <data name="Total amount" xml:space="preserve"><value>Kogusumma</value></data>
459 + <data name="Settlement plans" xml:space="preserve"><value>Arvelduste plaanid</value></data>
460 + <data name="Pending payments" xml:space="preserve"><value>Ootel maksed</value></data>
461 + <data name="Trip status breakdown" xml:space="preserve"><value>Reiside staatuse jaotus</value></data>
462 + <data name="Settlement status" xml:space="preserve"><value>Arvelduse staatus</value></data>
463 + <data name="Top active trips" xml:space="preserve"><value>Populaarseimad aktiivsed reisid</value></data>
464 + <data name="Biggest expenses" xml:space="preserve"><value>Suurimad kulud</value></data>
465 + <data name="No active trips yet" xml:space="preserve"><value>Aktiivseid reise veel pole</value></data>
466 + <data name="by" xml:space="preserve"><value>tegija</value></data>
467 + <data name="User activity" xml:space="preserve"><value>Kasutajate aktiivsus</value></data>
468 + <data name="Active 7d" xml:space="preserve"><value>Aktiivne 7p</value></data>
469 + <data name="Active 30d" xml:space="preserve"><value>Aktiivne 30p</value></data>
470 + <data name="Most active users" xml:space="preserve"><value>Kõige aktiivsemad kasutajad</value></data>
471 + <data name="No activity yet" xml:space="preserve"><value>Aktiivsust veel pole</value></data>
472 + <data name="Recent activity" xml:space="preserve"><value>Hiljutine aktiivsus</value></data>
473 + <data name="events" xml:space="preserve"><value>sündmust</value></data>
474 + <data name="No recent activity" xml:space="preserve"><value>Hiljutist aktiivsust pole</value></data>
475 + <data name="Quick actions" xml:space="preserve"><value>Kiirtegevused</value></data>
476 + <data name="Trip &quot;{0}&quot; created" xml:space="preserve"><value>Reis „{0}" loodi</value></data>
477 + <data name="Expense {0:0.00} added to &quot;{1}&quot;" xml:space="preserve"><value>Kulu {0:0.00} lisatud reisile „{1}"</value></data>
478 + <data name="Settlement plan for &quot;{0}&quot; ({1})" xml:space="preserve"><value>Arveldusplaan reisile „{0}" ({1})</value></data>
479 + <data name="Trip details" xml:space="preserve"><value>Reisi andmed</value></data>
480 + <data name="Expense details" xml:space="preserve"><value>Kulu andmed</value></data>
481 + <data name="Budget Category details" xml:space="preserve"><value>Eelarvekategooria andmed</value></data>
482 + <data name="New budget category" xml:space="preserve"><value>Uus eelarvekategooria</value></data>
483 + <data name="Currency details" xml:space="preserve"><value>Valuuta andmed</value></data>
484 + <data name="Edit currency" xml:space="preserve"><value>Muuda valuutat</value></data>
485 + <data name="Delete currency" xml:space="preserve"><value>Kustuta valuuta</value></data>
486 + <data name="Poll details" xml:space="preserve"><value>Hääletuse andmed</value></data>
487 + <data name="New poll" xml:space="preserve"><value>Uus hääletus</value></data>
488 + <data name="Edit poll" xml:space="preserve"><value>Muuda hääletust</value></data>
489 + <data name="Delete poll" xml:space="preserve"><value>Kustuta hääletus</value></data>
490 + <data name="Wishlist item details" xml:space="preserve"><value>Soovinimekirja andmed</value></data>
491 + <data name="New wishlist item" xml:space="preserve"><value>Uus soov</value></data>
492 + <data name="Invitation details" xml:space="preserve"><value>Kutse andmed</value></data>
493 + <data name="Settlement plan details" xml:space="preserve"><value>Arveldusplaani andmed</value></data>
494 + <data name="New settlement plan" xml:space="preserve"><value>Uus arveldusplaan</value></data>
495 + <data name="Edit settlement plan" xml:space="preserve"><value>Muuda arveldusplaani</value></data>
496 + <data name="Delete settlement plan" xml:space="preserve"><value>Kustuta arveldusplaan</value></data>
497 + <data name="Settlement payment details" xml:space="preserve"><value>Arveldusmakse andmed</value></data>
498 + <data name="Split preset details" xml:space="preserve"><value>Jaotuse eelseadistuse andmed</value></data>
499 + <data name="Trip participant details" xml:space="preserve"><value>Reisi osaleja andmed</value></data>
500 + <data name="New trip participant" xml:space="preserve"><value>Uus reisi osaleja</value></data>
501 + <data name="Edit trip participant" xml:space="preserve"><value>Muuda reisi osalejat</value></data>
502 + <data name="Delete trip participant" xml:space="preserve"><value>Kustuta reisi osaleja</value></data>
503 +
504 + <!-- ========== DEFAULT DATA ANNOTATION VALIDATION MESSAGES ========== -->
505 + <!-- These match the literal default error strings ASP.NET passes to the
506 + DataAnnotationLocalizerProvider as keys when ErrorMessage is not set. -->
507 + <data name="The {0} field is required." xml:space="preserve"><value>Väli {0} on kohustuslik.</value></data>
508 + <data name="The field {0} must be a string with a maximum length of {1}." xml:space="preserve"><value>Väli {0} ei tohi ületada {1} tähemärki.</value></data>
509 + <data name="The field {0} must be a string or array type with a maximum length of '{1}'." xml:space="preserve"><value>Väli {0} ei tohi ületada {1} tähemärki.</value></data>
510 + <data name="The field {0} must be between {1} and {2}." xml:space="preserve"><value>Väli {0} peab olema vahemikus {1} kuni {2}.</value></data>
511 + <data name="The field {0} must be a string with a minimum length of {2} and a maximum length of {1}." xml:space="preserve"><value>Väli {0} peab olema {2} kuni {1} tähemärki.</value></data>
512 + <data name="The {0} field is not a valid e-mail address." xml:space="preserve"><value>Väli {0} ei ole kehtiv e-posti aadress.</value></data>
513 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.resx +514 −0
@@ -0,0 +1,514 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 +
43 + <!-- ========== EXISTING KEYS (preserved) ========== -->
44 + <data name="Save" xml:space="preserve"><value>Save</value></data>
45 + <data name="Cancel" xml:space="preserve"><value>Cancel</value></data>
46 + <data name="Delete" xml:space="preserve"><value>Delete</value></data>
47 + <data name="Edit" xml:space="preserve"><value>Edit</value></data>
48 + <data name="Create" xml:space="preserve"><value>Create</value></data>
49 + <data name="Back" xml:space="preserve"><value>Back</value></data>
50 + <data name="Confirm" xml:space="preserve"><value>Confirm</value></data>
51 + <data name="Search" xml:space="preserve"><value>Search</value></data>
52 + <data name="Actions" xml:space="preserve"><value>Actions</value></data>
53 + <data name="Loading" xml:space="preserve"><value>Loading...</value></data>
54 + <data name="NoData" xml:space="preserve"><value>No data available</value></data>
55 + <data name="LogIn" xml:space="preserve"><value>Log In</value></data>
56 + <data name="LogOut" xml:space="preserve"><value>Log Out</value></data>
57 + <data name="Register" xml:space="preserve"><value>Register</value></data>
58 + <data name="Trips" xml:space="preserve"><value>Trips</value></data>
59 + <data name="Expenses" xml:space="preserve"><value>Expenses</value></data>
60 + <data name="Budget" xml:space="preserve"><value>Budget</value></data>
61 + <data name="Members" xml:space="preserve"><value>Members</value></data>
62 + <data name="Wishlist" xml:space="preserve"><value>Wishlist</value></data>
63 + <data name="Polls" xml:space="preserve"><value>Polls</value></data>
64 + <data name="Settlement" xml:space="preserve"><value>Settlement</value></data>
65 + <data name="Categories" xml:space="preserve"><value>Categories</value></data>
66 + <data name="Dashboard" xml:space="preserve"><value>Dashboard</value></data>
67 + <data name="AreYouSure" xml:space="preserve"><value>Are you sure?</value></data>
68 + <data name="DeleteConfirm" xml:space="preserve"><value>Do you want to delete this?</value></data>
69 + <data name="Details" xml:space="preserve"><value>Details</value></data>
70 + <data name="Name" xml:space="preserve"><value>Name</value></data>
71 + <data name="Description" xml:space="preserve"><value>Description</value></data>
72 + <data name="Amount" xml:space="preserve"><value>Amount</value></data>
73 + <data name="Date" xml:space="preserve"><value>Date</value></data>
74 + <data name="Status" xml:space="preserve"><value>Status</value></data>
75 + <data name="Admin" xml:space="preserve"><value>Admin</value></data>
76 +
77 + <!-- ========== COMMON UI ========== -->
78 + <data name="Home" xml:space="preserve"><value>Home</value></data>
79 + <data name="Hello" xml:space="preserve"><value>Hello</value></data>
80 + <data name="Welcome" xml:space="preserve"><value>Welcome</value></data>
81 + <data name="Total" xml:space="preserve"><value>Total</value></data>
82 + <data name="Email" xml:space="preserve"><value>Email</value></data>
83 + <data name="Joined" xml:space="preserve"><value>Joined</value></data>
84 + <data name="Role" xml:space="preserve"><value>Role</value></data>
85 + <data name="Summary" xml:space="preserve"><value>Summary</value></data>
86 + <data name="Dates" xml:space="preserve"><value>Dates</value></data>
87 + <data name="Currency" xml:space="preserve"><value>Currency</value></data>
88 + <data name="Currencies" xml:space="preserve"><value>Currencies</value></data>
89 + <data name="Destination" xml:space="preserve"><value>Destination</value></data>
90 + <data name="Participants" xml:space="preserve"><value>Participants</value></data>
91 + <data name="Code" xml:space="preserve"><value>Code</value></data>
92 + <data name="Symbol" xml:space="preserve"><value>Symbol</value></data>
93 + <data name="Select" xml:space="preserve"><value>Select</value></data>
94 + <data name="Copy" xml:space="preserve"><value>Copy</value></data>
95 + <data name="Split" xml:space="preserve"><value>Split</value></data>
96 + <data name="Member" xml:space="preserve"><value>Member</value></data>
97 + <data name="Title" xml:space="preserve"><value>Title</value></data>
98 + <data name="Question" xml:space="preserve"><value>Question</value></data>
99 + <data name="Option" xml:space="preserve"><value>Option</value></data>
100 + <data name="Options" xml:space="preserve"><value>Options</value></data>
101 + <data name="Priority" xml:space="preserve"><value>Priority</value></data>
102 + <data name="Location" xml:space="preserve"><value>Location</value></data>
103 + <data name="URL" xml:space="preserve"><value>URL</value></data>
104 + <data name="User" xml:space="preserve"><value>User</value></data>
105 + <data name="Trip" xml:space="preserve"><value>Trip</value></data>
106 + <data name="Close" xml:space="preserve"><value>Close</value></data>
107 + <data name="Yes" xml:space="preserve"><value>Yes</value></data>
108 + <data name="No" xml:space="preserve"><value>No</value></data>
109 + <data name="Submit" xml:space="preserve"><value>Submit</value></data>
110 + <data name="Add" xml:space="preserve"><value>Add</value></data>
111 + <data name="Remove" xml:space="preserve"><value>Remove</value></data>
112 + <data name="Unknown" xml:space="preserve"><value>Unknown</value></data>
113 + <data name="or" xml:space="preserve"><value>or</value></data>
114 + <data name="of" xml:space="preserve"><value>of</value></data>
115 + <data name="Category" xml:space="preserve"><value>Category</value></data>
116 + <data name="Expires" xml:space="preserve"><value>Expires</value></data>
117 + <data name="Nickname" xml:space="preserve"><value>Nickname</value></data>
118 +
119 + <!-- ========== TRIP ========== -->
120 + <data name="My Trips" xml:space="preserve"><value>My Trips</value></data>
121 + <data name="Create Trip" xml:space="preserve"><value>Create Trip</value></data>
122 + <data name="Edit Trip" xml:space="preserve"><value>Edit Trip</value></data>
123 + <data name="Delete Trip" xml:space="preserve"><value>Delete Trip</value></data>
124 + <data name="Trip Information" xml:space="preserve"><value>Trip Information</value></data>
125 + <data name="Back to Trips" xml:space="preserve"><value>Back to Trips</value></data>
126 + <data name="Back to Trip" xml:space="preserve"><value>Back to Trip</value></data>
127 + <data name="No trips yet" xml:space="preserve"><value>No trips yet</value></data>
128 + <data name="Create your first trip to get started!" xml:space="preserve"><value>Create your first trip to get started!</value></data>
129 + <data name="View Details" xml:space="preserve"><value>View Details</value></data>
130 + <data name="View All" xml:space="preserve"><value>View All</value></data>
131 + <data name="Go to My Trips" xml:space="preserve"><value>Go to My Trips</value></data>
132 + <data name="Start Date" xml:space="preserve"><value>Start Date</value></data>
133 + <data name="End Date" xml:space="preserve"><value>End Date</value></data>
134 + <data name="Default Currency" xml:space="preserve"><value>Default Currency</value></data>
135 + <data name="Select currency..." xml:space="preserve"><value>Select currency...</value></data>
136 + <data name="Are you sure you want to delete this trip? This action cannot be undone." xml:space="preserve"><value>Are you sure you want to delete this trip? This action cannot be undone.</value></data>
137 +
138 + <!-- ========== EXPENSE ========== -->
139 + <data name="Add Expense" xml:space="preserve"><value>Add Expense</value></data>
140 + <data name="Edit Expense" xml:space="preserve"><value>Edit Expense</value></data>
141 + <data name="Delete Expense" xml:space="preserve"><value>Delete Expense</value></data>
142 + <data name="Paid By" xml:space="preserve"><value>Paid By</value></data>
143 + <data name="Who Paid" xml:space="preserve"><value>Who Paid</value></data>
144 + <data name="Split Method" xml:space="preserve"><value>Split Method</value></data>
145 + <data name="Select Participants" xml:space="preserve"><value>Select Participants</value></data>
146 + <data name="No category" xml:space="preserve"><value>No category</value></data>
147 + <data name="Trip default" xml:space="preserve"><value>Trip default</value></data>
148 + <data name="No expenses yet" xml:space="preserve"><value>No expenses yet</value></data>
149 + <data name="Add your first expense to start tracking." xml:space="preserve"><value>Add your first expense to start tracking.</value></data>
150 + <data name="Recent Expenses" xml:space="preserve"><value>Recent Expenses</value></data>
151 + <data name="Expense" xml:space="preserve"><value>Expense</value></data>
152 + <data name="Total Expenses" xml:space="preserve"><value>Total Expenses</value></data>
153 + <data name="Are you sure you want to delete this expense? This action cannot be undone." xml:space="preserve"><value>Are you sure you want to delete this expense? This action cannot be undone.</value></data>
154 + <data name="Estimated Cost" xml:space="preserve"><value>Estimated Cost</value></data>
155 +
156 + <!-- ========== SPLIT METHODS ========== -->
157 + <data name="Equal (all)" xml:space="preserve"><value>Equal (all)</value></data>
158 + <data name="Equal (subset)" xml:space="preserve"><value>Equal (subset)</value></data>
159 + <data name="Exact amounts" xml:space="preserve"><value>Exact amounts</value></data>
160 + <data name="Percentages" xml:space="preserve"><value>Percentages</value></data>
161 +
162 + <!-- ========== MEMBERS ========== -->
163 + <data name="Current Members" xml:space="preserve"><value>Current Members</value></data>
164 + <data name="Pending Invitations" xml:space="preserve"><value>Pending Invitations</value></data>
165 + <data name="Invite Member" xml:space="preserve"><value>Invite Member</value></data>
166 + <data name="Generate Invitation Link" xml:space="preserve"><value>Generate Invitation Link</value></data>
167 + <data name="Back to Members" xml:space="preserve"><value>Back to Members</value></data>
168 + <data name="Invited by" xml:space="preserve"><value>Invited by</value></data>
169 + <data name="Are you sure you want to remove this member?" xml:space="preserve"><value>Are you sure you want to remove this member?</value></data>
170 +
171 + <!-- ========== SETTLEMENT ========== -->
172 + <data name="Balances" xml:space="preserve"><value>Balances</value></data>
173 + <data name="Settlement Plan" xml:space="preserve"><value>Settlement Plan</value></data>
174 + <data name="Total Paid" xml:space="preserve"><value>Total Paid</value></data>
175 + <data name="Total Owed" xml:space="preserve"><value>Total Owed</value></data>
176 + <data name="Net Balance" xml:space="preserve"><value>Net Balance</value></data>
177 + <data name="From" xml:space="preserve"><value>From</value></data>
178 + <data name="To" xml:space="preserve"><value>To</value></data>
179 + <data name="Calculate Settlement" xml:space="preserve"><value>Calculate Settlement</value></data>
180 + <data name="Mark Paid" xml:space="preserve"><value>Mark Paid</value></data>
181 + <data name="Confirm Receipt" xml:space="preserve"><value>Confirm Receipt</value></data>
182 + <data name="No settlement plan generated yet. Click 'Calculate Settlement' to generate one." xml:space="preserve"><value>No settlement plan generated yet. Click 'Calculate Settlement' to generate one.</value></data>
183 +
184 + <!-- ========== BUDGET ========== -->
185 + <data name="Total Planned" xml:space="preserve"><value>Total Planned</value></data>
186 + <data name="Total Spent" xml:space="preserve"><value>Total Spent</value></data>
187 + <data name="Remaining" xml:space="preserve"><value>Remaining</value></data>
188 + <data name="No budget categories yet" xml:space="preserve"><value>No budget categories yet</value></data>
189 + <data name="Add categories to track your trip budget." xml:space="preserve"><value>Add categories to track your trip budget.</value></data>
190 + <data name="Add Category" xml:space="preserve"><value>Add Category</value></data>
191 + <data name="Add Budget Category" xml:space="preserve"><value>Add Budget Category</value></data>
192 + <data name="Edit Budget Category" xml:space="preserve"><value>Edit Budget Category</value></data>
193 + <data name="Delete Budget Category" xml:space="preserve"><value>Delete Budget Category</value></data>
194 + <data name="Category Name" xml:space="preserve"><value>Category Name</value></data>
195 + <data name="Icon Name" xml:space="preserve"><value>Icon Name</value></data>
196 + <data name="Bootstrap Icons name (optional)" xml:space="preserve"><value>Bootstrap Icons name (optional)</value></data>
197 + <data name="Planned Amount" xml:space="preserve"><value>Planned Amount</value></data>
198 + <data name="Display Order" xml:space="preserve"><value>Display Order</value></data>
199 + <data name="Are you sure you want to delete this budget category? This action cannot be undone." xml:space="preserve"><value>Are you sure you want to delete this budget category? This action cannot be undone.</value></data>
200 +
201 + <!-- ========== WISHLIST ========== -->
202 + <data name="Link" xml:space="preserve"><value>Link</value></data>
203 + <data name="Added by" xml:space="preserve"><value>Added by</value></data>
204 + <data name="Vote" xml:space="preserve"><value>Vote</value></data>
205 + <data name="Voted" xml:space="preserve"><value>Voted</value></data>
206 + <data name="Mark Done" xml:space="preserve"><value>Mark Done</value></data>
207 + <data name="Completed" xml:space="preserve"><value>Completed</value></data>
208 + <data name="Add Item" xml:space="preserve"><value>Add Item</value></data>
209 + <data name="Add Wishlist Item" xml:space="preserve"><value>Add Wishlist Item</value></data>
210 + <data name="No wishlist items yet" xml:space="preserve"><value>No wishlist items yet</value></data>
211 + <data name="Add places, activities, or restaurants you want to visit." xml:space="preserve"><value>Add places, activities, or restaurants you want to visit.</value></data>
212 +
213 + <!-- ========== POLLS ========== -->
214 + <data name="Create Poll" xml:space="preserve"><value>Create Poll</value></data>
215 + <data name="Open" xml:space="preserve"><value>Open</value></data>
216 + <data name="Closed" xml:space="preserve"><value>Closed</value></data>
217 + <data name="Created by" xml:space="preserve"><value>Created by</value></data>
218 + <data name="View Results" xml:space="preserve"><value>View Results</value></data>
219 + <data name="Close Poll" xml:space="preserve"><value>Close Poll</value></data>
220 + <data name="Back to Polls" xml:space="preserve"><value>Back to Polls</value></data>
221 + <data name="Poll Results" xml:space="preserve"><value>Poll Results</value></data>
222 + <data name="No polls yet" xml:space="preserve"><value>No polls yet</value></data>
223 + <data name="Create a poll to help your group make decisions." xml:space="preserve"><value>Create a poll to help your group make decisions.</value></data>
224 + <data name="votes" xml:space="preserve"><value>votes</value></data>
225 + <data name="Multiple votes allowed" xml:space="preserve"><value>Multiple votes allowed</value></data>
226 + <data name="Allow multiple votes" xml:space="preserve"><value>Allow multiple votes</value></data>
227 + <data name="Anonymous voting" xml:space="preserve"><value>Anonymous voting</value></data>
228 + <data name="At least 2 options are required. Up to 5 options supported." xml:space="preserve"><value>At least 2 options are required. Up to 5 options supported.</value></data>
229 + <data name="total votes" xml:space="preserve"><value>total votes</value></data>
230 + <data name="Your vote" xml:space="preserve"><value>Your vote</value></data>
231 + <data name="Winner" xml:space="preserve"><value>Winner</value></data>
232 + <data name="Unvote" xml:space="preserve"><value>Unvote</value></data>
233 +
234 + <!-- ========== ADMIN ========== -->
235 + <data name="AdminDashboard" xml:space="preserve"><value>Admin Dashboard</value></data>
236 + <data name="ManageEntities" xml:space="preserve"><value>Manage Entities</value></data>
237 + <data name="ManageTrips" xml:space="preserve"><value>Manage Trips</value></data>
238 + <data name="ManageExpenses" xml:space="preserve"><value>Manage Expenses</value></data>
239 + <data name="ManageCategories" xml:space="preserve"><value>Manage Categories</value></data>
240 + <data name="ManageCurrencies" xml:space="preserve"><value>Manage Currencies</value></data>
241 + <data name="ManageSettlements" xml:space="preserve"><value>Manage Settlements</value></data>
242 + <data name="ManageParticipants" xml:space="preserve"><value>Manage Participants</value></data>
243 + <data name="ManageWishlist" xml:space="preserve"><value>Manage Wishlist</value></data>
244 + <data name="ManagePolls" xml:space="preserve"><value>Manage Polls</value></data>
245 + <data name="Users" xml:space="preserve"><value>Users</value></data>
246 + <data name="Invitations" xml:space="preserve"><value>Invitations</value></data>
247 +
248 + <!-- ========== ADMIN ENTITY FIELD KEYS ========== -->
249 + <data name="StartDate" xml:space="preserve"><value>Start Date</value></data>
250 + <data name="EndDate" xml:space="preserve"><value>End Date</value></data>
251 + <data name="CreatedBy" xml:space="preserve"><value>Created By</value></data>
252 + <data name="DefaultCurrency" xml:space="preserve"><value>Default Currency</value></data>
253 + <data name="ExpenseDate" xml:space="preserve"><value>Expense Date</value></data>
254 + <data name="PaidByUser" xml:space="preserve"><value>Paid By</value></data>
255 + <data name="SplitMethod" xml:space="preserve"><value>Split Method</value></data>
256 + <data name="BudgetCategory" xml:space="preserve"><value>Budget Category</value></data>
257 + <data name="BudgetCategories" xml:space="preserve"><value>Budget Categories</value></data>
258 + <data name="PlannedAmount" xml:space="preserve"><value>Planned Amount</value></data>
259 + <data name="DisplayOrder" xml:space="preserve"><value>Display Order</value></data>
260 + <data name="IconName" xml:space="preserve"><value>Icon Name</value></data>
261 + <data name="SettlementPlans" xml:space="preserve"><value>Settlement Plans</value></data>
262 + <data name="SettlementPlan" xml:space="preserve"><value>Settlement Plan</value></data>
263 + <data name="TotalAmount" xml:space="preserve"><value>Total Amount</value></data>
264 + <data name="CreatedByUser" xml:space="preserve"><value>Created By</value></data>
265 + <data name="CompletedAt" xml:space="preserve"><value>Completed At</value></data>
266 + <data name="TripParticipants" xml:space="preserve"><value>Trip Participants</value></data>
267 + <data name="TripParticipant" xml:space="preserve"><value>Trip Participant</value></data>
268 + <data name="JoinedAt" xml:space="preserve"><value>Joined At</value></data>
269 + <data name="LeftAt" xml:space="preserve"><value>Left At</value></data>
270 + <data name="IsActive" xml:space="preserve"><value>Is Active</value></data>
271 +
272 + <!-- ========== ENUM VALUES ========== -->
273 + <data name="Active" xml:space="preserve"><value>Active</value></data>
274 + <data name="Settled" xml:space="preserve"><value>Settled</value></data>
275 + <data name="Archived" xml:space="preserve"><value>Archived</value></data>
276 + <data name="Pending" xml:space="preserve"><value>Pending</value></data>
277 + <data name="Accepted" xml:space="preserve"><value>Accepted</value></data>
278 + <data name="Declined" xml:space="preserve"><value>Declined</value></data>
279 + <data name="Expired" xml:space="preserve"><value>Expired</value></data>
280 + <data name="Revoked" xml:space="preserve"><value>Revoked</value></data>
281 + <data name="Organizer" xml:space="preserve"><value>Organizer</value></data>
282 + <data name="Participant" xml:space="preserve"><value>Participant</value></data>
283 + <data name="MustDo" xml:space="preserve"><value>Must Do</value></data>
284 + <data name="NiceToHave" xml:space="preserve"><value>Nice to Have</value></data>
285 + <data name="Optional" xml:space="preserve"><value>Optional</value></data>
286 + <data name="Place" xml:space="preserve"><value>Place</value></data>
287 + <data name="Activity" xml:space="preserve"><value>Activity</value></data>
288 + <data name="Restaurant" xml:space="preserve"><value>Restaurant</value></data>
289 + <data name="Other" xml:space="preserve"><value>Other</value></data>
290 + <data name="InProgress" xml:space="preserve"><value>In Progress</value></data>
291 + <data name="MarkedPaid" xml:space="preserve"><value>Marked Paid</value></data>
292 + <data name="Confirmed" xml:space="preserve"><value>Confirmed</value></data>
293 +
294 + <!-- ========== HOME PAGE ========== -->
295 + <data name="AppTagline" xml:space="preserve"><value>Group travel expense management made easy.</value></data>
296 + <data name="AppDescription" xml:space="preserve"><value>Track expenses, split costs, manage budgets, and settle debts with your travel group.</value></data>
297 +
298 + <!-- ========== INVITATION ========== -->
299 + <data name="Accept Invitation" xml:space="preserve"><value>Accept Invitation</value></data>
300 + <data name="Invitation Generated" xml:space="preserve"><value>Invitation Generated</value></data>
301 + <data name="Invalid Invitation" xml:space="preserve"><value>Invalid Invitation</value></data>
302 + <data name="You've Been Invited!" xml:space="preserve"><value>You've Been Invited!</value></data>
303 + <data name="By accepting, you will join this trip as a participant." xml:space="preserve"><value>By accepting, you will join this trip as a participant.</value></data>
304 + <data name="This invitation is no longer valid." xml:space="preserve"><value>This invitation is no longer valid.</value></data>
305 + <data name="Invitation link has been generated successfully!" xml:space="preserve"><value>Invitation link has been generated successfully!</value></data>
306 + <data name="Share this link with the person you want to invite:" xml:space="preserve"><value>Share this link with the person you want to invite:</value></data>
307 + <data name="This link will expire in 7 days." xml:space="preserve"><value>This link will expire in 7 days.</value></data>
308 + <data name="The invitation link will expire after 7 days." xml:space="preserve"><value>The invitation link will expire after 7 days.</value></data>
309 + <data name="Generate an invitation link that you can share with someone to join this trip." xml:space="preserve"><value>Generate an invitation link that you can share with someone to join this trip.</value></data>
310 +
311 + <!-- ========== ADDITIONAL KEYS ========== -->
312 + <data name="AddedBy" xml:space="preserve"><value>Added by</value></data>
313 + <data name="AllowMultipleVotes" xml:space="preserve"><value>Allow multiple votes</value></data>
314 + <data name="Back to List" xml:space="preserve"><value>Back to List</value></data>
315 + <data name="Budget Categories" xml:space="preserve"><value>Budget Categories</value></data>
316 + <data name="ClosedAt" xml:space="preserve"><value>Closed at</value></data>
317 + <data name="CreatePoll" xml:space="preserve"><value>Create Poll</value></data>
318 + <data name="DeletePoll" xml:space="preserve"><value>Delete Poll</value></data>
319 + <data name="EditPoll" xml:space="preserve"><value>Edit Poll</value></data>
320 + <data name="IsAnonymous" xml:space="preserve"><value>Anonymous</value></data>
321 + <data name="Manage Categories" xml:space="preserve"><value>Manage Categories</value></data>
322 + <data name="Manage Currencies" xml:space="preserve"><value>Manage Currencies</value></data>
323 + <data name="Manage Entities" xml:space="preserve"><value>Manage Entities</value></data>
324 + <data name="Manage Expenses" xml:space="preserve"><value>Manage Expenses</value></data>
325 + <data name="Manage Participants" xml:space="preserve"><value>Manage Participants</value></data>
326 + <data name="Manage Polls" xml:space="preserve"><value>Manage Polls</value></data>
327 + <data name="Manage Settlements" xml:space="preserve"><value>Manage Settlements</value></data>
328 + <data name="Manage Trips" xml:space="preserve"><value>Manage Trips</value></data>
329 + <data name="Manage Wishlist" xml:space="preserve"><value>Manage Wishlist</value></data>
330 + <data name="PollDetails" xml:space="preserve"><value>Poll Details</value></data>
331 + <data name="Settlements" xml:space="preserve"><value>Settlements</value></data>
332 + <data name="Text" xml:space="preserve"><value>Text</value></data>
333 + <data name="Toggle navigation" xml:space="preserve"><value>Toggle navigation</value></data>
334 + <data name="Wishlist Items" xml:space="preserve"><value>Wishlist Items</value></data>
335 + <data name="WishlistItem" xml:space="preserve"><value>Wishlist Item</value></data>
336 + <data name="WishlistItemDetails" xml:space="preserve"><value>Wishlist Item Details</value></data>
337 +
338 + <!-- ========== NEW KEYS ========== -->
339 + <data name="Add expenses as you go. Choose how to split — we handle the math." xml:space="preserve"><value>Add expenses as you go. Choose how to split — we handle the math.</value></data>
340 + <data name="Admin Panel" xml:space="preserve"><value>Admin Panel</value></data>
341 + <data name="All settled up!" xml:space="preserve"><value>All settled up!</value></data>
342 + <data name="An error occurred while processing your request." xml:space="preserve"><value>An error occurred while processing your request.</value></data>
343 + <data name="Anonymous" xml:space="preserve"><value>Anonymous</value></data>
344 + <data name="At least 2 required" xml:space="preserve"><value>At least 2 required</value></data>
345 + <data name="Back to App" xml:space="preserve"><value>Back to App</value></data>
346 + <data name="Back to Home" xml:space="preserve"><value>Back to Home</value></data>
347 + <data name="Budget Progress" xml:space="preserve"><value>Budget Progress</value></data>
348 + <data name="Budget Used" xml:space="preserve"><value>Budget Used</value></data>
349 + <data name="Closed at" xml:space="preserve"><value>Closed at</value></data>
350 + <data name="Create Free Account" xml:space="preserve"><value>Create Free Account</value></data>
351 + <data name="Create a Trip" xml:space="preserve"><value>Create a Trip</value></data>
352 + <data name="Create polls, build wishlists, and vote together to make group planning effortless." xml:space="preserve"><value>Create polls, build wishlists, and vote together to make group planning effortless.</value></data>
353 + <data name="Done" xml:space="preserve"><value>Done</value></data>
354 + <data name="Entities" xml:space="preserve"><value>Entities</value></data>
355 + <data name="Entity Management" xml:space="preserve"><value>Entity Management</value></data>
356 + <data name="Error" xml:space="preserve"><value>Error</value></data>
357 + <data name="Everything you need for group travel" xml:space="preserve"><value>Everything you need for group travel</value></data>
358 + <data name="From splitting dinner bills to planning activities, SplitApp handles it all." xml:space="preserve"><value>From splitting dinner bills to planning activities, SplitApp handles it all.</value></data>
359 + <data name="Get Started" xml:space="preserve"><value>Get Started</value></data>
360 + <data name="Group Decisions" xml:space="preserve"><value>Group Decisions</value></data>
361 + <data name="Help your group make decisions together" xml:space="preserve"><value>Help your group make decisions together</value></data>
362 + <data name="How it works" xml:space="preserve"><value>How it works</value></data>
363 + <data name="Icon" xml:space="preserve"><value>Icon</value></data>
364 + <data name="Invite" xml:space="preserve"><value>Invite</value></data>
365 + <data name="Join thousands of travelers who split smarter." xml:space="preserve"><value>Join thousands of travelers who split smarter.</value></data>
366 + <data name="Learn More" xml:space="preserve"><value>Learn More</value></data>
367 + <data name="Link Generated!" xml:space="preserve"><value>Link Generated!</value></data>
368 + <data name="Log in" xml:space="preserve"><value>Log in</value></data>
369 + <data name="Manage Wishlist Items" xml:space="preserve"><value>Manage Wishlist Items</value></data>
370 + <data name="Multi-Vote" xml:space="preserve"><value>Multi-Vote</value></data>
371 + <data name="Multiple votes" xml:space="preserve"><value>Multiple votes</value></data>
372 + <data name="New Category" xml:space="preserve"><value>New Category</value></data>
373 + <data name="New Currency" xml:space="preserve"><value>New Currency</value></data>
374 + <data name="New Expense" xml:space="preserve"><value>New Expense</value></data>
375 + <data name="New Trip" xml:space="preserve"><value>New Trip</value></data>
376 + <data name="No description" xml:space="preserve"><value>No description</value></data>
377 + <data name="No payments needed — everyone is even." xml:space="preserve"><value>No payments needed — everyone is even.</value></data>
378 + <data name="No users yet" xml:space="preserve"><value>No users yet</value></data>
379 + <data name="Not friendships." xml:space="preserve"><value>Not friendships.</value></data>
380 + <data name="Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification." xml:space="preserve"><value>Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification.</value></data>
381 + <data name="Over budget!" xml:space="preserve"><value>Over budget!</value></data>
382 + <data name="Overall Progress" xml:space="preserve"><value>Overall Progress</value></data>
383 + <data name="Overview" xml:space="preserve"><value>Overview</value></data>
384 + <data name="Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place." xml:space="preserve"><value>Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place.</value></data>
385 + <data name="Planned" xml:space="preserve"><value>Planned</value></data>
386 + <data name="Poll" xml:space="preserve"><value>Poll</value></data>
387 + <data name="Privacy Policy" xml:space="preserve"><value>Privacy Policy</value></data>
388 + <data name="Profile" xml:space="preserve"><value>Profile</value></data>
389 + <data name="Quick Links" xml:space="preserve"><value>Quick Links</value></data>
390 + <data name="Ready to plan your next trip?" xml:space="preserve"><value>Ready to plan your next trip?</value></data>
391 + <data name="Recalculate" xml:space="preserve"><value>Recalculate</value></data>
392 + <data name="Recalculate after new expenses" xml:space="preserve"><value>Recalculate after new expenses</value></data>
393 + <data name="Recent Trips" xml:space="preserve"><value>Recent Trips</value></data>
394 + <data name="Recent Users" xml:space="preserve"><value>Recent Users</value></data>
395 + <data name="Request ID" xml:space="preserve"><value>Request ID</value></data>
396 + <data name="Search..." xml:space="preserve"><value>Search...</value></data>
397 + <data name="See who owes whom and settle with minimal payments." xml:space="preserve"><value>See who owes whom and settle with minimal payments.</value></data>
398 + <data name="Set up your next adventure" xml:space="preserve"><value>Set up your next adventure</value></data>
399 + <data name="Set up your trip and invite friends with a simple shareable link." xml:space="preserve"><value>Set up your trip and invite friends with a simple shareable link.</value></data>
400 + <data name="Settle Up" xml:space="preserve"><value>Settle Up</value></data>
401 + <data name="Share what you want to experience" xml:space="preserve"><value>Share what you want to experience</value></data>
402 + <data name="Sign up" xml:space="preserve"><value>Sign up</value></data>
403 + <data name="Something went wrong" xml:space="preserve"><value>Something went wrong</value></data>
404 + <data name="Split Any Way" xml:space="preserve"><value>Split Any Way</value></data>
405 + <data name="Split equally, by exact amounts, or percentages. Save presets for recurring groups." xml:space="preserve"><value>Split equally, by exact amounts, or percentages. Save presets for recurring groups.</value></data>
406 + <data name="Split expenses." xml:space="preserve"><value>Split expenses.</value></data>
407 + <data name="Start tracking your group expenses." xml:space="preserve"><value>Start tracking your group expenses.</value></data>
408 + <data name="System Administration" xml:space="preserve"><value>System Administration</value></data>
409 + <data name="Total Trips" xml:space="preserve"><value>Total Trips</value></data>
410 + <data name="Track &amp; Split" xml:space="preserve"><value>Track &amp; Split</value></data>
411 + <data name="Track a group expense" xml:space="preserve"><value>Track a group expense</value></data>
412 + <data name="Use this page to detail your site's privacy policy." xml:space="preserve"><value>Use this page to detail your site's privacy policy.</value></data>
413 + <data name="View Link" xml:space="preserve"><value>View Link</value></data>
414 + <data name="What should the group decide?" xml:space="preserve"><value>What should the group decide?</value></data>
415 + <data name="What's the plan?" xml:space="preserve"><value>What's the plan?</value></data>
416 + <data name="Why do you want to go here?" xml:space="preserve"><value>Why do you want to go here?</value></data>
417 + <data name="Wishlist Item" xml:space="preserve"><value>Wishlist Item</value></data>
418 + <data name="Your Balance" xml:space="preserve"><value>Your Balance</value></data>
419 + <data name="e.g. Group dinner, taxi, museum tickets..." xml:space="preserve"><value>e.g. Group dinner, taxi, museum tickets...</value></data>
420 + <data name="e.g. Old Town, City Center" xml:space="preserve"><value>e.g. Old Town, City Center</value></data>
421 + <data name="e.g. Sagrada Familia, Local tapas bar" xml:space="preserve"><value>e.g. Sagrada Familia, Local tapas bar</value></data>
422 + <data name="in progress" xml:space="preserve"><value>in progress</value></data>
423 + <data name="member(s)" xml:space="preserve"><value>member(s)</value></data>
424 + <data name="payment(s) marked paid, awaiting confirmation" xml:space="preserve"><value>payment(s) marked paid, awaiting confirmation</value></data>
425 + <data name="pending invitation(s)" xml:space="preserve"><value>pending invitation(s)</value></data>
426 + <data name="settlement payment(s) awaiting action" xml:space="preserve"><value>settlement payment(s) awaiting action</value></data>
427 + <data name="trip(s)" xml:space="preserve"><value>trip(s)</value></data>
428 +
429 + <data name="All" xml:space="preserve"><value>All</value></data>
430 +
431 + <!-- ========== SPLIT PRESETS ========== -->
432 + <data name="Apply Preset" xml:space="preserve"><value>Apply Preset</value></data>
433 + <data name="Select a preset..." xml:space="preserve"><value>Select a preset...</value></data>
434 + <data name="Apply" xml:space="preserve"><value>Apply</value></data>
435 + <data name="Save current split as preset" xml:space="preserve"><value>Save current split as preset</value></data>
436 + <data name="Preset name, e.g. Hotel group" xml:space="preserve"><value>Preset name, e.g. Hotel group</value></data>
437 +
438 + <!-- ========== WISHLIST EDIT/DELETE ========== -->
439 + <data name="Edit Wishlist Item" xml:space="preserve"><value>Edit Wishlist Item</value></data>
440 + <data name="Delete Wishlist Item" xml:space="preserve"><value>Delete Wishlist Item</value></data>
441 +
442 + <!-- ========== ADMIN USER MANAGEMENT ========== -->
443 + <data name="User Management" xml:space="preserve"><value>User Management</value></data>
444 + <data name="Edit Roles" xml:space="preserve"><value>Edit Roles</value></data>
445 + <data name="System" xml:space="preserve"><value>System</value></data>
446 + <data name="More" xml:space="preserve"><value>More</value></data>
447 + <data name="Payments" xml:space="preserve"><value>Payments</value></data>
448 + <data name="Split Presets" xml:space="preserve"><value>Split Presets</value></data>
449 + <data name="Revoke" xml:space="preserve"><value>Revoke</value></data>
450 +
451 + <!-- ADMIN REBUILD ADDITIONS -->
452 + <data name="Core" xml:space="preserve"><value>Core</value></data>
453 + <data name="Back to site" xml:space="preserve"><value>Back to site</value></data>
454 + <data name="System overview and real-time statistics" xml:space="preserve"><value>System overview and real-time statistics</value></data>
455 + <data name="payments awaiting action" xml:space="preserve"><value>payments awaiting action</value></data>
456 + <data name="payments awaiting confirmation" xml:space="preserve"><value>payments awaiting confirmation</value></data>
457 + <data name="Total Users" xml:space="preserve"><value>Total Users</value></data>
458 + <data name="Total amount" xml:space="preserve"><value>Total amount</value></data>
459 + <data name="Settlement plans" xml:space="preserve"><value>Settlement plans</value></data>
460 + <data name="Pending payments" xml:space="preserve"><value>Pending payments</value></data>
461 + <data name="Trip status breakdown" xml:space="preserve"><value>Trip status breakdown</value></data>
462 + <data name="Settlement status" xml:space="preserve"><value>Settlement status</value></data>
463 + <data name="Top active trips" xml:space="preserve"><value>Top active trips</value></data>
464 + <data name="Biggest expenses" xml:space="preserve"><value>Biggest expenses</value></data>
465 + <data name="No active trips yet" xml:space="preserve"><value>No active trips yet</value></data>
466 + <data name="by" xml:space="preserve"><value>by</value></data>
467 + <data name="User activity" xml:space="preserve"><value>User activity</value></data>
468 + <data name="Active 7d" xml:space="preserve"><value>Active 7d</value></data>
469 + <data name="Active 30d" xml:space="preserve"><value>Active 30d</value></data>
470 + <data name="Most active users" xml:space="preserve"><value>Most active users</value></data>
471 + <data name="No activity yet" xml:space="preserve"><value>No activity yet</value></data>
472 + <data name="Recent activity" xml:space="preserve"><value>Recent activity</value></data>
473 + <data name="events" xml:space="preserve"><value>events</value></data>
474 + <data name="No recent activity" xml:space="preserve"><value>No recent activity</value></data>
475 + <data name="Quick actions" xml:space="preserve"><value>Quick actions</value></data>
476 + <data name="Trip &quot;{0}&quot; created" xml:space="preserve"><value>Trip &quot;{0}&quot; created</value></data>
477 + <data name="Expense {0:0.00} added to &quot;{1}&quot;" xml:space="preserve"><value>Expense {0:0.00} added to &quot;{1}&quot;</value></data>
478 + <data name="Settlement plan for &quot;{0}&quot; ({1})" xml:space="preserve"><value>Settlement plan for &quot;{0}&quot; ({1})</value></data>
479 + <data name="Trip details" xml:space="preserve"><value>Trip details</value></data>
480 + <data name="Expense details" xml:space="preserve"><value>Expense details</value></data>
481 + <data name="Budget Category details" xml:space="preserve"><value>Budget Category details</value></data>
482 + <data name="New budget category" xml:space="preserve"><value>New budget category</value></data>
483 + <data name="Currency details" xml:space="preserve"><value>Currency details</value></data>
484 + <data name="Edit currency" xml:space="preserve"><value>Edit currency</value></data>
485 + <data name="Delete currency" xml:space="preserve"><value>Delete currency</value></data>
486 + <data name="Poll details" xml:space="preserve"><value>Poll details</value></data>
487 + <data name="New poll" xml:space="preserve"><value>New poll</value></data>
488 + <data name="Edit poll" xml:space="preserve"><value>Edit poll</value></data>
489 + <data name="Delete poll" xml:space="preserve"><value>Delete poll</value></data>
490 + <data name="Wishlist item details" xml:space="preserve"><value>Wishlist item details</value></data>
491 + <data name="New wishlist item" xml:space="preserve"><value>New wishlist item</value></data>
492 + <data name="Invitation details" xml:space="preserve"><value>Invitation details</value></data>
493 + <data name="Settlement plan details" xml:space="preserve"><value>Settlement plan details</value></data>
494 + <data name="New settlement plan" xml:space="preserve"><value>New settlement plan</value></data>
495 + <data name="Edit settlement plan" xml:space="preserve"><value>Edit settlement plan</value></data>
496 + <data name="Delete settlement plan" xml:space="preserve"><value>Delete settlement plan</value></data>
497 + <data name="Settlement payment details" xml:space="preserve"><value>Settlement payment details</value></data>
498 + <data name="Split preset details" xml:space="preserve"><value>Split preset details</value></data>
499 + <data name="Trip participant details" xml:space="preserve"><value>Trip participant details</value></data>
500 + <data name="New trip participant" xml:space="preserve"><value>New trip participant</value></data>
501 + <data name="Edit trip participant" xml:space="preserve"><value>Edit trip participant</value></data>
502 + <data name="Delete trip participant" xml:space="preserve"><value>Delete trip participant</value></data>
503 +
504 + <!-- ========== DEFAULT DATA ANNOTATION VALIDATION MESSAGES ========== -->
505 + <!-- Identity values for English; the et.resx pair holds Estonian translations.
506 + These are the literal default strings ASP.NET passes to the
507 + DataAnnotationLocalizerProvider as keys when ErrorMessage is not set. -->
508 + <data name="The {0} field is required." xml:space="preserve"><value>The {0} field is required.</value></data>
509 + <data name="The field {0} must be a string with a maximum length of {1}." xml:space="preserve"><value>The field {0} must be a string with a maximum length of {1}.</value></data>
510 + <data name="The field {0} must be a string or array type with a maximum length of '{1}'." xml:space="preserve"><value>The field {0} must be a string or array type with a maximum length of '{1}'.</value></data>
511 + <data name="The field {0} must be between {1} and {2}." xml:space="preserve"><value>The field {0} must be between {1} and {2}.</value></data>
512 + <data name="The field {0} must be a string with a minimum length of {2} and a maximum length of {1}." xml:space="preserve"><value>The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.</value></data>
513 + <data name="The {0} field is not a valid e-mail address." xml:space="preserve"><value>The {0} field is not a valid e-mail address.</value></data>
514 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/SplitApp.WebApp.csproj +45 −0
@@ -0,0 +1,45 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Web">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + <ProjectReference Include="..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
6 + <ProjectReference Include="..\Modules\Users\SplitApp.Modules.Users.Api\SplitApp.Modules.Users.Api.csproj" />
7 + <ProjectReference Include="..\Modules\Users\SplitApp.Modules.Users.Infrastructure\SplitApp.Modules.Users.Infrastructure.csproj" />
8 + <ProjectReference Include="..\Modules\Trips\SplitApp.Modules.Trips.Api\SplitApp.Modules.Trips.Api.csproj" />
9 + <ProjectReference Include="..\Modules\Trips\SplitApp.Modules.Trips.Infrastructure\SplitApp.Modules.Trips.Infrastructure.csproj" />
10 + <ProjectReference Include="..\Modules\Expenses\SplitApp.Modules.Expenses.Api\SplitApp.Modules.Expenses.Api.csproj" />
11 + <ProjectReference Include="..\Modules\Expenses\SplitApp.Modules.Expenses.Infrastructure\SplitApp.Modules.Expenses.Infrastructure.csproj" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
16 + <PackageReference Include="MediatR" Version="12.4.1" />
17 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
18 + <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.5" />
19 + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
20 + <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" />
21 + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
22 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
23 + <PrivateAssets>all</PrivateAssets>
24 + </PackageReference>
25 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
26 + <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
27 + </ItemGroup>
28 +
29 + <PropertyGroup>
30 + <TargetFramework>net10.0</TargetFramework>
31 + <Nullable>enable</Nullable>
32 + <ImplicitUsings>enable</ImplicitUsings>
33 + </PropertyGroup>
34 +
35 + <ItemGroup>
36 + <!-- Force LogicalName so the resource manifest matches the marker type
37 + App.Resources.Views.Shared (in Resources/Shared.cs) regardless of
38 + what physical folder we keep the resx files in. -->
39 + <EmbeddedResource Update="Resources\Views\Shared.resx" LogicalName="App.Resources.Views.Shared.resources" />
40 + <EmbeddedResource Update="Resources\Views\Shared.et.resx" LogicalName="App.Resources.Views.Shared.et.resources" />
41 + <EmbeddedResource Update="Resources\Domain\Enums.resx" LogicalName="App.Resources.Domain.Enums.resources" />
42 + <EmbeddedResource Update="Resources\Domain\Enums.et.resx" LogicalName="App.Resources.Domain.Enums.et.resources" />
43 + </ItemGroup>
44 +
45 +</Project>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/CreateCategory.cshtml +71 −0
@@ -0,0 +1,71 @@
1 +@model SplitApp.WebApp.Application.DTO.BudgetCategoryBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Add Budget Category"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-success); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-wallet2"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Budget Category"]</h1>
17 + </div>
18 +
19 + <form asp-action="CreateCategory" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="TripId" />
22 +
23 + <div class="mb-3">
24 + <label for="name" class="form-label">@Localizer["Category Name"]</label>
25 + <input type="text" id="name" name="name" class="form-control" placeholder="e.g. Food, Transport, Activities" required />
26 + <span asp-validation-for="Name" class="text-danger"></span>
27 + </div>
28 +
29 + <div class="mb-3">
30 + <label class="form-label">@Localizer["Icon"]</label>
31 + <input type="hidden" asp-for="IconName" id="iconNameInput" />
32 + <div class="sa-icon-picker">
33 + @{
34 + var icons = new[] { "airplane", "house", "cart", "cup-hot", "film", "music-note-beamed", "cash-stack", "ticket-perforated", "bus-front", "fuel-pump", "gift", "heart-fill", "camera-fill", "egg-fried", "bed", "bicycle", "shop", "palette", "binoculars", "lightning" };
35 + }
36 + @foreach (var icon in icons)
37 + {
38 + <div class="sa-icon-option @(Model.IconName == icon ? "active" : "")" data-icon="@icon" title="@icon">
39 + <i class="bi bi-@icon"></i>
40 + </div>
41 + }
42 + </div>
43 + </div>
44 +
45 + <div class="mb-3">
46 + <label asp-for="PlannedAmount" class="form-label">@Localizer["Planned Amount"]</label>
47 + <input asp-for="PlannedAmount" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
48 + <span asp-validation-for="PlannedAmount" class="text-danger"></span>
49 + </div>
50 +
51 + <div class="mb-4">
52 + <label asp-for="DisplayOrder" class="form-label">@Localizer["Display Order"]</label>
53 + <input asp-for="DisplayOrder" type="number" class="form-control" />
54 + <span asp-validation-for="DisplayOrder" class="text-danger"></span>
55 + </div>
56 +
57 + <div class="d-flex gap-3">
58 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
59 + <i class="bi bi-check-lg"></i> @Localizer["Create"]
60 + </button>
61 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
62 + </div>
63 + </form>
64 + </div>
65 + </div>
66 + </div>
67 +</div>
68 +
69 +@section Scripts {
70 + <partial name="_ValidationScriptsPartial" />
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
@@ -0,0 +1,51 @@
1 +@model SplitApp.WebApp.Application.DTO.BudgetCategoryBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Budget Category"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-confirm-card sa-card-accent sa-card-accent-danger">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center">
12 + <div class="sa-confirm-icon sa-confirm-icon-danger">
13 + <i class="bi bi-exclamation-triangle-fill"></i>
14 + </div>
15 + <h2 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Localizer["Delete Budget Category"]</h2>
16 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
17 + @Localizer["Are you sure you want to delete this budget category? This action cannot be undone."]
18 + </p>
19 + </div>
20 +
21 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
22 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
23 + <div class="d-flex align-items-center gap-3">
24 + <div class="sa-expense-icon sa-category-default">
25 + @if (!string.IsNullOrEmpty(Model.IconName))
26 + { <i class="bi bi-@Model.IconName"></i> }
27 + else
28 + { <i class="bi bi-tag"></i> }
29 + </div>
30 + <div>
31 + <strong>@Model.Name</strong>
32 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">
33 + @Localizer["Planned"]: @(Model.PlannedAmount?.ToString("N2") ?? "-")
34 + </div>
35 + </div>
36 + </div>
37 + </div>
38 + </div>
39 +
40 + <form asp-action="DeleteCategory">
41 + <input type="hidden" asp-for="Id" />
42 + <div class="d-flex gap-3">
43 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
44 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
45 + </button>
46 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 + </div>
51 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/EditCategory.cshtml +72 −0
@@ -0,0 +1,72 @@
1 +@model SplitApp.WebApp.Application.DTO.BudgetCategoryBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Budget Category"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-secondary); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-pencil-square"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Budget Category"]</h1>
17 + </div>
18 +
19 + <form asp-action="EditCategory" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <div class="mb-3">
25 + <label for="name" class="form-label">@Localizer["Category Name"]</label>
26 + <input type="text" id="name" name="name" class="form-control" value="@Model.Name.ToString()" required />
27 + <span asp-validation-for="Name" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="mb-3">
31 + <label class="form-label">@Localizer["Icon"]</label>
32 + <input type="hidden" asp-for="IconName" id="iconNameInput" />
33 + <div class="sa-icon-picker">
34 + @{
35 + var icons = new[] { "airplane", "house", "cart", "cup-hot", "film", "music-note-beamed", "cash-stack", "ticket-perforated", "bus-front", "fuel-pump", "gift", "heart-fill", "camera-fill", "egg-fried", "bed", "bicycle", "shop", "palette", "binoculars", "lightning" };
36 + }
37 + @foreach (var icon in icons)
38 + {
39 + <div class="sa-icon-option @(Model.IconName == icon ? "active" : "")" data-icon="@icon" title="@icon">
40 + <i class="bi bi-@icon"></i>
41 + </div>
42 + }
43 + </div>
44 + </div>
45 +
46 + <div class="mb-3">
47 + <label asp-for="PlannedAmount" class="form-label">@Localizer["Planned Amount"]</label>
48 + <input asp-for="PlannedAmount" type="number" step="0.01" min="0" class="form-control" />
49 + <span asp-validation-for="PlannedAmount" class="text-danger"></span>
50 + </div>
51 +
52 + <div class="mb-4">
53 + <label asp-for="DisplayOrder" class="form-label">@Localizer["Display Order"]</label>
54 + <input asp-for="DisplayOrder" type="number" class="form-control" />
55 + <span asp-validation-for="DisplayOrder" class="text-danger"></span>
56 + </div>
57 +
58 + <div class="d-flex gap-3">
59 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
60 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
61 + </button>
62 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
63 + </div>
64 + </form>
65 + </div>
66 + </div>
67 + </div>
68 +</div>
69 +
70 +@section Scripts {
71 + <partial name="_ValidationScriptsPartial" />
72 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/Index.cshtml +155 −0
@@ -0,0 +1,155 @@
1 +@model List<SplitApp.WebApp.Controllers.BudgetCategoryViewModel>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Budget"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 + var totalPlanned = (decimal)ViewData["TotalPlanned"]!;
8 + var totalSpent = (decimal)ViewData["TotalSpent"]!;
9 + var remaining = totalPlanned - totalSpent;
10 + var overallPct = totalPlanned > 0 ? (int)(totalSpent * 100 / totalPlanned) : 0;
11 + var isOrganizer = (bool)ViewData["IsOrganizer"]!;
12 +}
13 +
14 +<!-- Page Header -->
15 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, #22c55e 0%, #1a9e8f 100%);">
16 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
17 + <div>
18 + <h1 style="margin-bottom: 4px;">@Localizer["Budget"]</h1>
19 + <p class="text-muted mb-0">@tripName</p>
20 + </div>
21 + <div class="d-flex gap-2">
22 + @if (isOrganizer)
23 + {
24 + <a asp-action="CreateCategory" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
25 + <i class="bi bi-plus-lg"></i> @Localizer["Add Category"]
26 + </a>
27 + }
28 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
29 + <i class="bi bi-arrow-left"></i>
30 + </a>
31 + </div>
32 + </div>
33 +</div>
34 +
35 +<!-- Summary Cards -->
36 +<div class="row g-3 mb-4">
37 + <div class="col-md-4">
38 + <div class="sa-card-static">
39 + <div class="sa-stat">
40 + <div class="sa-stat-icon" style="color: var(--sa-info);"><i class="bi bi-bullseye"></i></div>
41 + <div class="sa-stat-value">@totalPlanned.ToString("N2")</div>
42 + <div class="sa-stat-label">@Localizer["Total Planned"]</div>
43 + </div>
44 + </div>
45 + </div>
46 + <div class="col-md-4">
47 + <div class="sa-card-static">
48 + <div class="sa-stat">
49 + <div class="sa-stat-icon" style="color: @(totalSpent > totalPlanned && totalPlanned > 0 ? "var(--sa-danger)" : "var(--sa-accent)");"><i class="bi bi-credit-card"></i></div>
50 + <div class="sa-stat-value" style="color: @(totalSpent > totalPlanned && totalPlanned > 0 ? "var(--sa-danger)" : "inherit");">@totalSpent.ToString("N2")</div>
51 + <div class="sa-stat-label">@Localizer["Total Spent"]</div>
52 + </div>
53 + </div>
54 + </div>
55 + <div class="col-md-4">
56 + <div class="sa-card-static">
57 + <div class="sa-stat">
58 + <div class="sa-stat-icon" style="color: @(remaining >= 0 ? "var(--sa-success)" : "var(--sa-danger)");"><i class="bi bi-piggy-bank"></i></div>
59 + <div class="sa-stat-value" style="color: @(remaining >= 0 ? "var(--sa-success)" : "var(--sa-danger)");">@remaining.ToString("N2")</div>
60 + <div class="sa-stat-label">@Localizer["Remaining"]</div>
61 + </div>
62 + </div>
63 + </div>
64 +</div>
65 +
66 +<!-- Overall progress -->
67 +@if (totalPlanned > 0)
68 +{
69 + <div class="sa-card-static mb-4">
70 + <div class="sa-card-body">
71 + <div class="d-flex justify-content-between mb-2">
72 + <span style="font-weight: 600; font-size: 0.9rem;">@Localizer["Overall Progress"]</span>
73 + <span class="@(overallPct > 100 ? "text-danger fw-bold" : "sa-text-muted")" style="font-size: 0.85rem;">@overallPct%</span>
74 + </div>
75 + @if (overallPct > 100)
76 + {
77 + <small style="color: var(--sa-danger); font-weight: 600;">@Localizer["Over budget!"]</small>
78 + }
79 + @{
80 + var barClass = overallPct <= 60 ? "sa-progress-bar-success" : overallPct <= 85 ? "sa-progress-bar-warning" : "sa-progress-bar-danger";
81 + }
82 + <div class="sa-progress sa-progress-lg">
83 + <div class="sa-progress-bar @barClass" data-width="@Math.Min(overallPct, 100)%" style="width: 0%;"></div>
84 + </div>
85 + </div>
86 + </div>
87 +}
88 +
89 +@if (!Model.Any())
90 +{
91 + <div class="sa-empty">
92 + <div class="sa-empty-icon"><i class="bi bi-wallet2"></i></div>
93 + <div class="sa-empty-title">@Localizer["No budget categories yet"]</div>
94 + <p class="sa-empty-text">@Localizer["Add categories to track your trip budget."]</p>
95 + <a asp-action="CreateCategory" asp-route-tripId="@tripId" class="sa-btn sa-btn-secondary sa-btn-pill">
96 + <i class="bi bi-plus-lg"></i> @Localizer["Add Category"]
97 + </a>
98 + </div>
99 +}
100 +else
101 +{
102 + <div class="d-flex flex-column gap-3">
103 + @foreach (var category in Model)
104 + {
105 + var catPct = category.ProgressPercentage;
106 + var catBarClass = catPct <= 60 ? "sa-progress-bar-success" : catPct <= 85 ? "sa-progress-bar-warning" : "sa-progress-bar-danger";
107 +
108 + <div class="sa-card-static">
109 + <div class="sa-card-body">
110 + <div class="d-flex justify-content-between align-items-center mb-3">
111 + <div class="d-flex align-items-center gap-3">
112 + <div class="sa-expense-icon sa-category-default">
113 + @if (!string.IsNullOrEmpty(category.IconName))
114 + {
115 + <i class="bi bi-@category.IconName"></i>
116 + }
117 + else
118 + {
119 + <i class="bi bi-tag"></i>
120 + }
121 + </div>
122 + <div>
123 + <div style="font-weight: 700;">@category.Name</div>
124 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">
125 + @category.SpentAmount.ToString("N2") / @category.PlannedAmount.ToString("N2")
126 + </div>
127 + </div>
128 + </div>
129 + @if (isOrganizer)
130 + {
131 + <div class="d-flex gap-2">
132 + <a asp-action="EditCategory" asp-route-id="@category.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
133 + <i class="bi bi-pencil"></i>
134 + </a>
135 + <a asp-action="DeleteCategory" asp-route-id="@category.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
136 + <i class="bi bi-trash3"></i>
137 + </a>
138 + </div>
139 + }
140 + </div>
141 + <div class="d-flex justify-content-between mb-1">
142 + <small class="sa-text-muted">@catPct%</small>
143 + @if (catPct > 100)
144 + {
145 + <small style="color: var(--sa-danger); font-weight: 600;">@Localizer["Over budget!"]</small>
146 + }
147 + </div>
148 + <div class="sa-progress">
149 + <div class="sa-progress-bar @catBarClass" data-width="@Math.Min(catPct, 100)%"></div>
150 + </div>
151 + </div>
152 + </div>
153 + }
154 + </div>
155 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Create.cshtml +365 −0
@@ -0,0 +1,365 @@
1 +@model SplitApp.WebApp.Application.DTO.ExpenseBllDto
2 +
3 +
4 +@{
5 + ViewData["Title"] = Localizer["Add Expense"];
6 + var tripId = (Guid)ViewData["TripId"]!;
7 + var participants = (List<SplitApp.WebApp.Application.DTO.TripParticipantBllDto>)ViewData["Participants"]!;
8 + var presets = (List<SplitApp.WebApp.Application.DTO.SplitPresetBllDto>)ViewData["SplitPresets"]!;
9 +}
10 +
11 +<div class="row justify-content-center">
12 + <div class="col-md-8 col-lg-6">
13 + <div class="sa-card-static sa-card-accent sa-card-accent-primary">
14 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
15 + <div class="text-center mb-4">
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Expense"]</h1>
17 + <p class="sa-text-muted">@Localizer["Track a group expense"]</p>
18 + </div>
19 +
20 + <form asp-action="Create" data-sa-loading>
21 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <!-- Amount (prominent) -->
25 + <div class="text-center mb-4">
26 + <label asp-for="Amount" class="form-label" style="text-align: center; display: block;">@Localizer["Amount"]</label>
27 + <input asp-for="Amount" type="number" step="0.01" min="0.01"
28 + class="form-control sa-amount-input" id="expenseAmount"
29 + placeholder="0.00" />
30 + <span asp-validation-for="Amount" class="text-danger"></span>
31 + </div>
32 +
33 + <div class="row mb-3">
34 + <div class="col-md-6 mb-3 mb-md-0">
35 + <label asp-for="PaidByUserId" class="form-label">@Localizer["Who Paid"]</label>
36 + <select asp-for="PaidByUserId" asp-items="@((SelectList)ViewData["PaidByUserId"]!)" class="form-select"></select>
37 + <span asp-validation-for="PaidByUserId" class="text-danger"></span>
38 + </div>
39 + <div class="col-md-6">
40 + <label asp-for="ExpenseDate" class="form-label">@Localizer["Date"]</label>
41 + <input asp-for="ExpenseDate" type="date" class="form-control" />
42 + </div>
43 + </div>
44 +
45 + <div class="mb-3">
46 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
47 + <input asp-for="Description" class="form-control" placeholder="@Localizer["e.g. Group dinner, taxi, museum tickets..."]" />
48 + <span asp-validation-for="Description" class="text-danger"></span>
49 + </div>
50 +
51 + <div class="row mb-3">
52 + <div class="col-md-6 mb-3 mb-md-0">
53 + <label asp-for="BudgetCategoryId" class="form-label">@Localizer["Category"]</label>
54 + <select asp-for="BudgetCategoryId" asp-items="@((SelectList)ViewData["BudgetCategoryId"]!)" class="form-select">
55 + <option value="">@Localizer["No category"]</option>
56 + </select>
57 + </div>
58 + <div class="col-md-6">
59 + <label asp-for="CurrencyId" class="form-label">@Localizer["Currency"]</label>
60 + <select asp-for="CurrencyId" asp-items="@((SelectList)ViewData["CurrencyId"]!)" class="form-select">
61 + <option value="">@Localizer["Trip default"]</option>
62 + </select>
63 + </div>
64 + </div>
65 +
66 + <!-- Split Method -->
67 + <div class="mb-3">
68 + <label class="form-label">@Localizer["Split Method"]</label>
69 + <div class="sa-split-methods">
70 + @foreach (var method in Enum.GetValues<ESplitMethod>())
71 + {
72 + var icon = method switch
73 + {
74 + ESplitMethod.EqualAll => "bi-pie-chart-fill",
75 + ESplitMethod.EqualSubset => "bi-pie-chart",
76 + ESplitMethod.ExactAmounts => "bi-hash",
77 + ESplitMethod.Percentages => "bi-percent",
78 + _ => "bi-circle"
79 + };
80 + var label = method switch
81 + {
82 + ESplitMethod.EqualAll => Localizer["Equal (all)"].Value,
83 + ESplitMethod.EqualSubset => Localizer["Equal (subset)"].Value,
84 + ESplitMethod.ExactAmounts => Localizer["Exact amounts"].Value,
85 + ESplitMethod.Percentages => Localizer["Percentages"].Value,
86 + _ => method.ToString()
87 + };
88 + <label class="sa-split-option">
89 + <input type="radio" name="SplitMethod" value="@((int)method)"
90 + @(method == ESplitMethod.EqualAll ? "checked" : "") />
91 + <div class="sa-split-option-label">
92 + <i class="bi @icon sa-split-option-icon"></i>
93 + <span class="sa-split-option-text">@label</span>
94 + </div>
95 + </label>
96 + }
97 + </div>
98 + </div>
99 +
100 + <!-- Split Presets -->
101 + @if (presets.Any())
102 + {
103 + <div id="presetSection" class="mb-3" style="display:none;">
104 + <label class="form-label">@Localizer["Apply Preset"]</label>
105 + <div class="d-flex gap-2">
106 + <select id="presetSelect" class="form-select">
107 + <option value="">@Localizer["Select a preset..."]</option>
108 + @foreach (var preset in presets)
109 + {
110 + <option value="@preset.Id"
111 + data-method="@((int)preset.SplitMethod)"
112 + data-members="@string.Join(",", preset.Members?.Select(m => m.UserId.ToString()) ?? Array.Empty<string>())"
113 + data-percentages="@string.Join(",", preset.Members?.Select(m => m.Percentage?.ToString("0.##") ?? "") ?? Array.Empty<string>())">
114 + @preset.Name (@preset.SplitMethod)
115 + </option>
116 + }
117 + </select>
118 + <button type="button" id="applyPresetBtn" class="sa-btn sa-btn-ghost sa-btn-sm" style="white-space:nowrap;">
119 + <i class="bi bi-lightning"></i> @Localizer["Apply"]
120 + </button>
121 + </div>
122 + </div>
123 + }
124 +
125 + <!-- Participant Selection -->
126 + <div id="participantSection" class="mb-3" style="display:none;">
127 + <label class="form-label">@Localizer["Select Participants"]</label>
128 + <div class="sa-card-static">
129 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
130 + @for (var i = 0; i < participants.Count; i++)
131 + {
132 + var p = participants[i];
133 + var name = $"{p.User!.FirstName} {p.User.LastName}";
134 + var initial = $"{p.User.FirstName?[0]}{p.User.LastName?[0]}";
135 + <div class="d-flex align-items-center gap-3 mb-2 participant-row">
136 + <input type="checkbox" class="form-check-input participant-check"
137 + name="selectedParticipants" value="@p.UserId"
138 + data-index="@i" checked style="flex-shrink:0;" />
139 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((i % 8) + 1)" style="border:none; font-size: 0.65rem;">@initial</span>
140 + <span style="font-weight: 500; flex: 1;">@name</span>
141 + <div class="split-amount-col" style="display:none; width: 100px;">
142 + <input type="number" step="0.01" min="0" class="form-control form-control-sm split-amount"
143 + name="splitAmounts" placeholder="0.00" />
144 + </div>
145 + <div class="split-pct-col" style="display:none; width: 90px;">
146 + <div class="input-group input-group-sm">
147 + <input type="number" step="0.01" min="0" max="100"
148 + class="form-control split-pct" name="splitPercentages" placeholder="0" />
149 + <span class="input-group-text">%</span>
150 + </div>
151 + </div>
152 + <span class="sa-badge sa-badge-neutral split-preview" style="min-width: 60px; text-align: center;"></span>
153 + </div>
154 + }
155 +
156 + <!-- Remaining indicator for exact amounts -->
157 + <div id="splitRemainingRow" style="display:none;" class="d-flex justify-content-end mt-2 pt-2" style="border-top: 1px solid var(--sa-gray-100);">
158 + <span class="sa-text-muted" style="font-size: 0.85rem;">@Localizer["Remaining"]: </span>
159 + <span id="splitRemaining" class="sa-amount ms-2" style="font-size: 0.85rem;">0.00</span>
160 + </div>
161 + <!-- Total indicator for percentages -->
162 + <div id="splitPctTotalRow" style="display:none;" class="d-flex justify-content-end mt-2 pt-2">
163 + <span class="sa-text-muted" style="font-size: 0.85rem;">@Localizer["Total"]: </span>
164 + <span id="splitPctTotal" class="sa-amount ms-2" style="font-size: 0.85rem;">0.0%</span>
165 + </div>
166 + </div>
167 + </div>
168 + </div>
169 +
170 + <div class="d-flex gap-3 mt-4">
171 + <button type="submit" class="sa-btn sa-btn-primary flex-grow-1">
172 + <i class="bi bi-check-lg"></i> @Localizer["Add Expense"]
173 + </button>
174 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
175 + </div>
176 + </form>
177 +
178 + <!-- Save as Preset (collapsible) -->
179 + <div id="savePresetSection" class="mt-3 pt-3" style="display:none; border-top: 1px solid var(--sa-gray-200);">
180 + <form asp-action="SavePreset" method="post">
181 + <input type="hidden" name="tripId" value="@tripId" />
182 + <input type="hidden" name="splitMethod" id="presetSplitMethod" value="0" />
183 + <div id="presetParticipantInputs"></div>
184 + <div id="presetPercentageInputs"></div>
185 + <div class="d-flex gap-2 align-items-end">
186 + <div class="flex-grow-1">
187 + <label class="form-label" style="font-size: 0.85rem;">@Localizer["Save current split as preset"]</label>
188 + <input type="text" name="presetName" class="form-control form-control-sm" placeholder="@Localizer["Preset name, e.g. Hotel group"]" required />
189 + </div>
190 + <button type="submit" class="sa-btn sa-btn-ghost sa-btn-sm">
191 + <i class="bi bi-bookmark-plus"></i> @Localizer["Save"]
192 + </button>
193 + </div>
194 + </form>
195 + </div>
196 + </div>
197 + </div>
198 + </div>
199 +</div>
200 +
201 +@section Scripts {
202 + <partial name="_ValidationScriptsPartial" />
203 + <script>
204 + // Preset: apply selected preset to participant checkboxes
205 + document.getElementById('applyPresetBtn')?.addEventListener('click', function() {
206 + const select = document.getElementById('presetSelect');
207 + const option = select?.selectedOptions[0];
208 + if (!option || !option.value) return;
209 +
210 + const method = option.dataset.method;
211 + const memberIds = option.dataset.members.split(',').filter(m => m);
212 + const percentages = option.dataset.percentages.split(',').filter(p => p);
213 +
214 + // Set split method
215 + const radio = document.querySelector(`input[name="SplitMethod"][value="${method}"]`);
216 + if (radio) { radio.checked = true; onSplitMethodChange(); }
217 +
218 + // Uncheck all, then check only preset members
219 + document.querySelectorAll('.participant-check').forEach(c => c.checked = false);
220 + memberIds.forEach((id, idx) => {
221 + const check = document.querySelector(`.participant-check[value="${id}"]`);
222 + if (check) {
223 + check.checked = true;
224 + // Set percentage if available
225 + if (method === '3' && percentages[idx]) {
226 + const row = check.closest('.participant-row');
227 + const pctInput = row?.querySelector('.split-pct');
228 + if (pctInput) pctInput.value = percentages[idx];
229 + }
230 + }
231 + });
232 + updatePreview();
233 + });
234 +
235 + // Save Preset: sync form hidden fields before submit
236 + function updateSavePresetForm() {
237 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value || '0';
238 + document.getElementById('presetSplitMethod').value = method;
239 +
240 + const container = document.getElementById('presetParticipantInputs');
241 + const pctContainer = document.getElementById('presetPercentageInputs');
242 + container.innerHTML = '';
243 + pctContainer.innerHTML = '';
244 +
245 + const checks = document.querySelectorAll('.participant-check:checked');
246 + checks.forEach(c => {
247 + container.innerHTML += `<input type="hidden" name="selectedParticipants" value="${c.value}" />`;
248 + const row = c.closest('.participant-row');
249 + const pct = row?.querySelector('.split-pct')?.value || '';
250 + pctContainer.innerHTML += `<input type="hidden" name="splitPercentages" value="${pct}" />`;
251 + });
252 + }
253 +
254 + // Show/hide preset sections based on split method
255 + function updatePresetVisibility() {
256 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value;
257 + const presetSection = document.getElementById('presetSection');
258 + const saveSection = document.getElementById('savePresetSection');
259 + if (presetSection) presetSection.style.display = method !== '0' ? 'block' : 'none';
260 + if (saveSection) saveSection.style.display = method !== '0' ? 'block' : 'none';
261 + updateSavePresetForm();
262 + }
263 +
264 + document.querySelector('#savePresetSection form')?.addEventListener('submit', updateSavePresetForm);
265 +
266 + function onSplitMethodChange() {
267 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value;
268 + const section = document.getElementById('participantSection');
269 + const amountCols = document.querySelectorAll('.split-amount-col');
270 + const pctCols = document.querySelectorAll('.split-pct-col');
271 + const checks = document.querySelectorAll('.participant-check');
272 + const remainingRow = document.getElementById('splitRemainingRow');
273 + const pctTotalRow = document.getElementById('splitPctTotalRow');
274 +
275 + if (method === '0') {
276 + section.style.display = 'none';
277 + } else {
278 + section.style.display = 'block';
279 + }
280 +
281 + amountCols.forEach(c => c.style.display = method === '2' ? 'block' : 'none');
282 + pctCols.forEach(c => c.style.display = method === '3' ? 'block' : 'none');
283 + if (remainingRow) remainingRow.style.display = method === '2' ? 'flex' : 'none';
284 + if (pctTotalRow) pctTotalRow.style.display = method === '3' ? 'flex' : 'none';
285 +
286 + if (method === '2' || method === '3') {
287 + checks.forEach(c => c.checked = true);
288 + }
289 +
290 + updatePreview();
291 + updatePresetVisibility();
292 + }
293 +
294 + function updatePreview() {
295 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value;
296 + const amount = parseFloat(document.getElementById('expenseAmount')?.value) || 0;
297 + const checks = document.querySelectorAll('.participant-check:checked');
298 + const allChecks = document.querySelectorAll('.participant-check');
299 + const previews = document.querySelectorAll('.split-preview');
300 +
301 + previews.forEach(p => { p.textContent = ''; p.className = 'sa-badge sa-badge-neutral split-preview'; });
302 +
303 + if (method === '0') {
304 + if (allChecks.length > 0 && amount > 0) {
305 + const each = (amount / allChecks.length).toFixed(2);
306 + allChecks.forEach(c => {
307 + const idx = c.dataset.index;
308 + if (previews[idx]) {
309 + previews[idx].textContent = '€' + each;
310 + previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
311 + }
312 + });
313 + }
314 + return;
315 + }
316 +
317 + if (method === '1') {
318 + if (checks.length > 0 && amount > 0) {
319 + const each = (amount / checks.length).toFixed(2);
320 + checks.forEach(c => {
321 + const idx = c.dataset.index;
322 + if (previews[idx]) {
323 + previews[idx].textContent = '€' + each;
324 + previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
325 + }
326 + });
327 + }
328 + }
329 +
330 + if (method === '2') {
331 + const amountInputs = document.querySelectorAll('.split-amount');
332 + let total = 0;
333 + amountInputs.forEach(input => { total += parseFloat(input.value) || 0; });
334 + const remaining = amount - total;
335 + const indicator = document.getElementById('splitRemaining');
336 + if (indicator) {
337 + indicator.textContent = remaining.toFixed(2);
338 + indicator.className = Math.abs(remaining) < 0.01
339 + ? 'sa-amount sa-amount-positive ms-2'
340 + : 'sa-amount sa-amount-negative ms-2';
341 + }
342 + }
343 +
344 + if (method === '3') {
345 + const pctInputs = document.querySelectorAll('.split-pct');
346 + let totalPct = 0;
347 + pctInputs.forEach(input => { totalPct += parseFloat(input.value) || 0; });
348 + const indicator = document.getElementById('splitPctTotal');
349 + if (indicator) {
350 + indicator.textContent = totalPct.toFixed(1) + '%';
351 + indicator.className = Math.abs(totalPct - 100) < 0.1
352 + ? 'sa-amount sa-amount-positive ms-2'
353 + : 'sa-amount sa-amount-negative ms-2';
354 + }
355 + }
356 + }
357 +
358 + document.querySelectorAll('input[name="SplitMethod"]').forEach(r => r.addEventListener('change', onSplitMethodChange));
359 + document.getElementById('expenseAmount')?.addEventListener('input', updatePreview);
360 + document.querySelectorAll('.participant-check').forEach(c => c.addEventListener('change', updatePreview));
361 + document.querySelectorAll('.split-amount, .split-pct').forEach(input => input.addEventListener('input', updatePreview));
362 +
363 + onSplitMethodChange();
364 + </script>
365 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Delete.cshtml +51 −0
@@ -0,0 +1,51 @@
1 +@model SplitApp.WebApp.Application.DTO.ExpenseBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Expense"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-confirm-card sa-card-accent sa-card-accent-danger">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center">
12 + <div class="sa-confirm-icon sa-confirm-icon-danger">
13 + <i class="bi bi-exclamation-triangle-fill"></i>
14 + </div>
15 + <h2 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Localizer["Delete Expense"]</h2>
16 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
17 + @Localizer["Are you sure you want to delete this expense? This action cannot be undone."]
18 + </p>
19 + </div>
20 +
21 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
22 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
23 + <div class="d-flex justify-content-between align-items-start mb-2">
24 + <strong>@(Model.Description ?? "-")</strong>
25 + <span class="sa-amount" style="font-size: 1.1rem;">@Model.Amount.ToString("N2")
26 + @if (Model.Currency != null) { <small class="sa-text-muted">@Model.Currency.Code</small> }
27 + </span>
28 + </div>
29 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">
30 + <i class="bi bi-calendar-event me-1"></i> @Model.ExpenseDate.ToString("MMM dd, yyyy")
31 + &middot; @Localizer["Paid by"] @(Model.PaidByUser != null ? $"{Model.PaidByUser.FirstName} {Model.PaidByUser.LastName}" : "?")
32 + @if (Model.BudgetCategory != null)
33 + {
34 + <span>&middot; @Model.BudgetCategory.Name</span>
35 + }
36 + </div>
37 + </div>
38 + </div>
39 +
40 + <form asp-action="Delete">
41 + <input type="hidden" asp-for="Id" />
42 + <div class="d-flex gap-3">
43 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
44 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
45 + </button>
46 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 + </div>
51 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Edit.cshtml +84 −0
@@ -0,0 +1,84 @@
1 +@model SplitApp.WebApp.Application.DTO.ExpenseBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Expense"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-primary">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-primary); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-pencil-square"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Expense"]</h1>
17 + </div>
18 +
19 + <form asp-action="Edit" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <!-- Amount -->
25 + <div class="text-center mb-4">
26 + <label asp-for="Amount" class="form-label" style="text-align: center; display: block;">@Localizer["Amount"]</label>
27 + <input asp-for="Amount" type="number" step="0.01" min="0.01"
28 + class="form-control sa-amount-input" placeholder="0.00" />
29 + <span asp-validation-for="Amount" class="text-danger"></span>
30 + </div>
31 +
32 + <div class="row mb-3">
33 + <div class="col-md-6 mb-3 mb-md-0">
34 + <label asp-for="PaidByUserId" class="form-label">@Localizer["Who Paid"]</label>
35 + <select asp-for="PaidByUserId" asp-items="@((SelectList)ViewData["PaidByUserId"]!)" class="form-select"></select>
36 + <span asp-validation-for="PaidByUserId" class="text-danger"></span>
37 + </div>
38 + <div class="col-md-6">
39 + <label asp-for="ExpenseDate" class="form-label">@Localizer["Date"]</label>
40 + <input asp-for="ExpenseDate" type="date" class="form-control" />
41 + </div>
42 + </div>
43 +
44 + <div class="mb-3">
45 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
46 + <input asp-for="Description" class="form-control" />
47 + <span asp-validation-for="Description" class="text-danger"></span>
48 + </div>
49 +
50 + <div class="row mb-3">
51 + <div class="col-md-6 mb-3 mb-md-0">
52 + <label asp-for="BudgetCategoryId" class="form-label">@Localizer["Category"]</label>
53 + <select asp-for="BudgetCategoryId" asp-items="@((SelectList)ViewData["BudgetCategoryId"]!)" class="form-select">
54 + <option value="">@Localizer["No category"]</option>
55 + </select>
56 + </div>
57 + <div class="col-md-6">
58 + <label asp-for="CurrencyId" class="form-label">@Localizer["Currency"]</label>
59 + <select asp-for="CurrencyId" asp-items="@((SelectList)ViewData["CurrencyId"]!)" class="form-select">
60 + <option value="">@Localizer["Trip default"]</option>
61 + </select>
62 + </div>
63 + </div>
64 +
65 + <div class="mb-4">
66 + <label asp-for="SplitMethod" class="form-label">@Localizer["Split Method"]</label>
67 + <select asp-for="SplitMethod" asp-items="@((SelectList)ViewData["SplitMethods"]!)" class="form-select"></select>
68 + </div>
69 +
70 + <div class="d-flex gap-3">
71 + <button type="submit" class="sa-btn sa-btn-primary flex-grow-1">
72 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
73 + </button>
74 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
75 + </div>
76 + </form>
77 + </div>
78 + </div>
79 + </div>
80 +</div>
81 +
82 +@section Scripts {
83 + <partial name="_ValidationScriptsPartial" />
84 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Index.cshtml +111 −0
@@ -0,0 +1,111 @@
1 +@model SplitApp.WebApp.Controllers.ExpensesIndexViewModel
2 +@using SplitApp.WebApp.Hosting.Helpers
3 +
4 +@{
5 + ViewData["Title"] = Localizer["Expenses"];
6 +}
7 +
8 +<!-- Page Header -->
9 +<div class="sa-gradient-header sa-gradient-header-coral">
10 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
11 + <div>
12 + <h1 style="margin-bottom: 4px;">@Localizer["Expenses"]</h1>
13 + <p class="text-muted mb-0">@Model.TripName</p>
14 + </div>
15 + <div class="d-flex gap-2">
16 + @if (Model.TripStatus == "Active")
17 + {
18 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
19 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
20 + </a>
21 + }
22 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
23 + <i class="bi bi-arrow-left"></i>
24 + </a>
25 + </div>
26 + </div>
27 + @if (Model.Expenses.Any())
28 + {
29 + <div class="mt-3" style="font-size: 1.8rem; font-weight: 800; letter-spacing: -0.02em;">
30 + @Model.Expenses.Sum(e => CurrencyConverter.Convert(e.Amount, e.Currency?.Code ?? Model.DefaultCurrencyCode, Model.DefaultCurrencyCode)).ToString("N2")
31 + <span style="font-size: 0.9rem; opacity: 0.7; font-weight: 500; margin-left: 4px;">@Model.CurrencySymbol @Localizer["total"]</span>
32 + </div>
33 + }
34 +</div>
35 +
36 +@if (!Model.Expenses.Any())
37 +{
38 + <div class="sa-empty">
39 + <div class="sa-empty-icon"><i class="bi bi-receipt"></i></div>
40 + <div class="sa-empty-title">@Localizer["No expenses yet"]</div>
41 + <p class="sa-empty-text">@Localizer["Add your first expense to start tracking."]</p>
42 + @if (Model.TripStatus == "Active")
43 + {
44 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-btn sa-btn-primary sa-btn-pill">
45 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
46 + </a>
47 + }
48 + </div>
49 +}
50 +else
51 +{
52 + <div class="sa-card-static">
53 + @foreach (var expense in Model.Expenses)
54 + {
55 + var isDifferentCurrency = expense.Currency != null && expense.Currency.Code != Model.DefaultCurrencyCode;
56 + <div class="sa-expense-item">
57 + <div class="sa-expense-icon @(expense.BudgetCategory != null ? "sa-category-default" : "sa-category-default")">
58 + <i class="bi bi-receipt"></i>
59 + </div>
60 + <div class="sa-expense-details">
61 + <div class="sa-expense-desc">@(expense.Description ?? Localizer["No description"].Value)</div>
62 + <div class="sa-expense-meta">
63 + <span>@(expense.PaidByUser != null ? $"{expense.PaidByUser.FirstName} {expense.PaidByUser.LastName}" : "?")</span>
64 + &middot; @expense.ExpenseDate.ToString("MMM dd, yyyy")
65 + @if (expense.BudgetCategory != null)
66 + {
67 + <span>&middot;</span>
68 + <span class="sa-badge sa-badge-secondary" style="font-size: 0.65rem; padding: 1px 6px;">@expense.BudgetCategory.Name</span>
69 + }
70 + </div>
71 + </div>
72 + <div class="d-flex align-items-center gap-3">
73 + <div class="text-end">
74 + @if (isDifferentCurrency)
75 + {
76 + <div class="sa-expense-amount">@(CurrencyConverter.Convert(expense.Amount, expense.Currency!.Code, Model.DefaultCurrencyCode).ToString("N2"))</div>
77 + <div style="font-size: 0.7rem; color: var(--sa-gray-400);">@expense.Amount.ToString("N2") @expense.Currency.Code</div>
78 + }
79 + else
80 + {
81 + <div class="sa-expense-amount">@expense.Amount.ToString("N2")</div>
82 + @if (expense.Currency != null)
83 + {
84 + <div style="font-size: 0.75rem; color: var(--sa-gray-400);">@expense.Currency.Code</div>
85 + }
86 + }
87 + </div>
88 + @if (Model.TripStatus == "Active")
89 + {
90 + <div class="sa-expense-actions">
91 + <a asp-action="Edit" asp-route-id="@expense.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
92 + <i class="bi bi-pencil"></i>
93 + </a>
94 + <a asp-action="Delete" asp-route-id="@expense.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
95 + <i class="bi bi-trash3"></i>
96 + </a>
97 + </div>
98 + }
99 + </div>
100 + </div>
101 + }
102 + </div>
103 +}
104 +
105 +@if (Model.TripStatus == "Active")
106 +{
107 + <!-- Mobile FAB -->
108 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-fab sa-hide-desktop" title="@Localizer["Add Expense"]">
109 + <i class="bi bi-plus-lg"></i>
110 + </a>
111 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Index.cshtml +116 −0
@@ -0,0 +1,116 @@
1 +@{
2 + ViewData["Title"] = Localizer["Home"].Value;
3 +}
4 +
5 +<!-- Hero Section -->
6 +<div class="sa-hero">
7 + <h1>@Localizer["Split expenses."] <br />@Localizer["Not friendships."] ✈️</h1>
8 + <p>@Localizer["Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place."]</p>
9 + <div class="sa-hero-btns">
10 + @if (!User.Identity!.IsAuthenticated)
11 + {
12 + <a asp-area="Identity" asp-page="/Account/Register" class="sa-hero-btn-white">
13 + <i class="bi bi-rocket-takeoff"></i> @Localizer["Get Started"]
14 + </a>
15 + <a href="#features" class="sa-hero-btn-outline">
16 + @Localizer["Learn More"] <i class="bi bi-arrow-down"></i>
17 + </a>
18 + }
19 + else
20 + {
21 + <a asp-controller="Trips" asp-action="Index" class="sa-hero-btn-white">
22 + <i class="bi bi-luggage"></i> @Localizer["My Trips"]
23 + </a>
24 + }
25 + </div>
26 +</div>
27 +
28 +<!-- Features Section -->
29 +<section id="features" style="padding: var(--sa-space-12) 0;">
30 + <div class="text-center mb-5">
31 + <h2 style="font-size: 1.75rem;">@Localizer["Everything you need for group travel"]</h2>
32 + <p class="sa-text-muted" style="max-width: 500px; margin: var(--sa-space-3) auto 0;">
33 + @Localizer["From splitting dinner bills to planning activities, SplitApp handles it all."]
34 + </p>
35 + </div>
36 +
37 + <div class="row g-4">
38 + <div class="col-md-4 sa-animate-on-scroll">
39 + <div class="sa-card-static">
40 + <div class="sa-feature-card">
41 + <div class="sa-feature-icon sa-nav-icon-expenses">
42 + <i class="bi bi-receipt-cutoff"></i>
43 + </div>
44 + <h3 class="sa-feature-title">@Localizer["Split Any Way"]</h3>
45 + <p class="sa-feature-text">
46 + @Localizer["Split equally, by exact amounts, or percentages. Save presets for recurring groups."]
47 + </p>
48 + </div>
49 + </div>
50 + </div>
51 + <div class="col-md-4 sa-animate-on-scroll sa-stagger-2">
52 + <div class="sa-card-static">
53 + <div class="sa-feature-card">
54 + <div class="sa-feature-icon sa-nav-icon-polls">
55 + <i class="bi bi-bar-chart-fill"></i>
56 + </div>
57 + <h3 class="sa-feature-title">@Localizer["Group Decisions"]</h3>
58 + <p class="sa-feature-text">
59 + @Localizer["Create polls, build wishlists, and vote together to make group planning effortless."]
60 + </p>
61 + </div>
62 + </div>
63 + </div>
64 + <div class="col-md-4 sa-animate-on-scroll sa-stagger-4">
65 + <div class="sa-card-static">
66 + <div class="sa-feature-card">
67 + <div class="sa-feature-icon sa-nav-icon-settlement">
68 + <i class="bi bi-check2-circle"></i>
69 + </div>
70 + <h3 class="sa-feature-title">@Localizer["Settle Up"]</h3>
71 + <p class="sa-feature-text">
72 + @Localizer["Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification."]
73 + </p>
74 + </div>
75 + </div>
76 + </div>
77 + </div>
78 +</section>
79 +
80 +<!-- How It Works -->
81 +<section style="padding: var(--sa-space-12) 0 var(--sa-space-16);">
82 + <div class="text-center mb-5">
83 + <h2 style="font-size: 1.75rem;">@Localizer["How it works"]</h2>
84 + </div>
85 + <div class="sa-steps">
86 + <div class="sa-step sa-animate-on-scroll">
87 + <div class="sa-step-number">1</div>
88 + <h3 class="sa-step-title">@Localizer["Create a Trip"]</h3>
89 + <p class="sa-step-text">@Localizer["Set up your trip and invite friends with a simple shareable link."]</p>
90 + </div>
91 + <div class="sa-step sa-animate-on-scroll sa-stagger-2">
92 + <div class="sa-step-number">2</div>
93 + <h3 class="sa-step-title">@Localizer["Track & Split"]</h3>
94 + <p class="sa-step-text">@Localizer["Add expenses as you go. Choose how to split — we handle the math."]</p>
95 + </div>
96 + <div class="sa-step sa-animate-on-scroll sa-stagger-4">
97 + <div class="sa-step-number">3</div>
98 + <h3 class="sa-step-title">@Localizer["Settle Up"]</h3>
99 + <p class="sa-step-text">@Localizer["See who owes whom and settle with minimal payments."]</p>
100 + </div>
101 + </div>
102 +</section>
103 +
104 +<!-- Bottom CTA -->
105 +@if (!User.Identity!.IsAuthenticated)
106 +{
107 + <section class="text-center" style="padding: var(--sa-space-10) 0 var(--sa-space-6);">
108 + <div class="sa-card-static" style="background: var(--sa-secondary-gradient); color: #fff; padding: var(--sa-space-10) var(--sa-space-6); border-radius: var(--sa-radius-lg);">
109 + <h2 style="color: #fff; margin-bottom: var(--sa-space-3);">@Localizer["Ready to plan your next trip?"]</h2>
110 + <p style="opacity: 0.9; margin-bottom: var(--sa-space-6);">@Localizer["Join thousands of travelers who split smarter."]</p>
111 + <a asp-area="Identity" asp-page="/Account/Register" class="sa-hero-btn-white">
112 + <i class="bi bi-person-plus"></i> @Localizer["Create Free Account"]
113 + </a>
114 + </div>
115 + </section>
116 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Privacy.cshtml +6 −0
@@ -0,0 +1,6 @@
1 +@{
2 + ViewData["Title"] = Localizer["Privacy Policy"].Value;
3 +}
4 +<h1>@ViewData["Title"]</h1>
5 +
6 +<p>@Localizer["Use this page to detail your site's privacy policy."]</p>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
@@ -0,0 +1,60 @@
1 +@model SplitApp.WebApp.Application.DTO.TripInvitationBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Accept Invitation"];
5 + var token = (string)ViewData["Token"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-invite-card sa-animate-scale-in">
10 + <div style="height: 6px; background: var(--sa-secondary-gradient);"></div>
11 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
12 + <div class="sa-invite-icon" style="color: var(--sa-secondary);">
13 + <i class="bi bi-envelope-paper-heart-fill"></i>
14 + </div>
15 +
16 + <h2 style="margin-bottom: var(--sa-space-4);">@Localizer["You've Been Invited!"]</h2>
17 +
18 + @if (Model.Trip != null)
19 + {
20 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
21 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
22 + <h4 style="margin-bottom: var(--sa-space-2); font-weight: 700;">@Model.Trip.Name</h4>
23 + @if (!string.IsNullOrEmpty(Model.Trip.Destination))
24 + {
25 + <div style="color: var(--sa-gray-600); font-size: 0.9rem; margin-bottom: 4px;">
26 + <i class="bi bi-geo-alt-fill me-1" style="color: var(--sa-primary);"></i> @Model.Trip.Destination
27 + </div>
28 + }
29 + @if (Model.Trip.StartDate.HasValue || Model.Trip.EndDate.HasValue)
30 + {
31 + <div style="color: var(--sa-gray-500); font-size: 0.85rem;">
32 + <i class="bi bi-calendar-event me-1"></i>
33 + @(Model.Trip.StartDate?.ToString("MMM dd, yyyy") ?? "?") — @(Model.Trip.EndDate?.ToString("MMM dd, yyyy") ?? "?")
34 + </div>
35 + }
36 + </div>
37 + </div>
38 + }
39 +
40 + @if (Model.InvitedByUser != null)
41 + {
42 + <p style="color: var(--sa-gray-600); font-size: 0.9rem; margin-bottom: var(--sa-space-4);">
43 + <i class="bi bi-person-fill me-1"></i>
44 + @Localizer["Invited by"] @Model.InvitedByUser.FirstName @Model.InvitedByUser.LastName
45 + </p>
46 + }
47 +
48 + <form asp-action="AcceptInvitation" asp-route-token="@token" method="post">
49 + <input type="hidden" name="_" value="1" />
50 + <button type="submit" class="sa-btn sa-btn-success sa-btn-lg" style="width: 100%;">
51 + <i class="bi bi-check-circle"></i> @Localizer["Accept Invitation"]
52 + </button>
53 + </form>
54 +
55 + <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-4);">
56 + @Localizer["By accepting, you will join this trip as a participant."]
57 + </p>
58 + </div>
59 + </div>
60 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Index.cshtml +125 −0
@@ -0,0 +1,125 @@
1 +@model List<SplitApp.WebApp.Application.DTO.TripParticipantBllDto>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Members"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 + var currentUserId = (Guid)ViewData["CurrentUserId"]!;
8 + var isOrganizer = (bool)ViewData["IsOrganizer"]!;
9 + var pendingInvitations = ViewData["PendingInvitations"] as List<SplitApp.WebApp.Application.DTO.TripInvitationBllDto> ?? new();
10 +}
11 +
12 +<!-- Page Header -->
13 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);">
14 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
15 + <div>
16 + <h1 style="margin-bottom: 4px;">@Localizer["Members"]</h1>
17 + <p class="text-muted mb-0">@tripName &middot; @Model.Count @Localizer["member(s)"]</p>
18 + </div>
19 + <div class="d-flex gap-2">
20 + @if (isOrganizer)
21 + {
22 + <a asp-action="Invite" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
23 + <i class="bi bi-person-plus-fill"></i> @Localizer["Invite"]
24 + </a>
25 + }
26 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
27 + <i class="bi bi-arrow-left"></i>
28 + </a>
29 + </div>
30 + </div>
31 +</div>
32 +
33 +<!-- Members List -->
34 +<div class="sa-card-static mb-4">
35 + <div class="sa-card-header">
36 + <i class="bi bi-people-fill me-2"></i>@Localizer["Current Members"]
37 + </div>
38 + <div>
39 + @{ var pIdx = 0; }
40 + @foreach (var participant in Model)
41 + {
42 + var name = participant.User != null ? $"{participant.User.FirstName} {participant.User.LastName}" : Localizer["Unknown"].Value;
43 + var initial = participant.User != null ? $"{participant.User.FirstName?[0]}{participant.User.LastName?[0]}" : "?";
44 + var email = participant.User?.Email ?? "-";
45 +
46 + <div class="d-flex align-items-center gap-3 px-4 py-3" style="border-bottom: 1px solid var(--sa-gray-100);">
47 + <span class="sa-avatar sa-avatar-@((pIdx % 8) + 1)" style="border: none;">@initial</span>
48 + <div style="flex: 1; min-width: 0;">
49 + <div style="font-weight: 600;">
50 + @name
51 + @if (!string.IsNullOrEmpty(participant.Nickname))
52 + {
53 + <small class="sa-text-muted">(@participant.Nickname)</small>
54 + }
55 + </div>
56 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">@email</div>
57 + </div>
58 + <div class="d-flex align-items-center gap-2">
59 + @if (participant.Role == EParticipantRole.Organizer)
60 + {
61 + <span class="sa-badge sa-badge-accent">
62 + <i class="bi bi-star-fill" style="font-size: 0.6rem;"></i> @Localizer["Organizer"]
63 + </span>
64 + }
65 + else
66 + {
67 + <span class="sa-badge sa-badge-neutral">@Localizer["Participant"]</span>
68 + }
69 + <span class="sa-text-muted sa-hide-mobile" style="font-size: 0.8rem;">
70 + @participant.JoinedAt.ToString("MMM dd, yyyy")
71 + </span>
72 + @if (isOrganizer && participant.UserId != currentUserId && participant.Role != EParticipantRole.Organizer)
73 + {
74 + <form asp-action="Remove" method="post" style="display:inline"
75 + onsubmit="return confirm('@Localizer["Are you sure you want to remove this member?"]');">
76 + <input type="hidden" name="tripId" value="@tripId" />
77 + <input type="hidden" name="participantId" value="@participant.Id" />
78 + <button type="submit" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Remove"]" style="color: var(--sa-danger);">
79 + <i class="bi bi-x-lg"></i>
80 + </button>
81 + </form>
82 + }
83 + </div>
84 + </div>
85 + pIdx++;
86 + }
87 + </div>
88 +</div>
89 +
90 +<!-- Pending Invitations -->
91 +@if (isOrganizer && pendingInvitations.Any())
92 +{
93 + <div class="sa-card-static">
94 + <div class="sa-card-header">
95 + <i class="bi bi-hourglass-split me-2"></i>@Localizer["Pending Invitations"]
96 + </div>
97 + <div>
98 + @foreach (var inv in pendingInvitations)
99 + {
100 + <div class="d-flex align-items-center gap-3 px-4 py-3" style="border-bottom: 1px solid var(--sa-gray-100);">
101 + <div class="sa-expense-icon" style="background: var(--sa-info-light); color: var(--sa-info);">
102 + <i class="bi bi-envelope-paper"></i>
103 + </div>
104 + <div style="flex: 1;">
105 + <div style="font-weight: 500; font-size: 0.9rem;">
106 + @Localizer["Invited by"] @(inv.InvitedByUser != null ? $"{inv.InvitedByUser.FirstName} {inv.InvitedByUser.LastName}" : "?")
107 + </div>
108 + <div style="font-size: 0.8rem; color: var(--sa-gray-500);">
109 + @Localizer["Expires"] @inv.ExpiresAt.ToString("MMM dd, yyyy HH:mm")
110 + </div>
111 + </div>
112 + <span class="sa-badge sa-badge-info">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(inv.Status)</span>
113 + <form asp-action="RevokeInvitation" method="post" style="display:inline;">
114 + <input type="hidden" name="id" value="@inv.Id" />
115 + <input type="hidden" name="tripId" value="@tripId" />
116 + <button type="submit" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Revoke"]" style="color: var(--sa-danger);"
117 + onclick="return confirm('@Localizer["AreYouSure"]');">
118 + <i class="bi bi-x-circle"></i>
119 + </button>
120 + </form>
121 + </div>
122 + }
123 + </div>
124 + </div>
125 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
@@ -0,0 +1,24 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Invalid Invitation"];
4 + var error = ViewData["Error"] as string;
5 +}
6 +
7 +<div class="sa-invite-page">
8 + <div class="sa-card-static sa-invite-card sa-card-accent sa-card-accent-danger">
9 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
10 + <div class="sa-confirm-icon sa-confirm-icon-danger" style="margin: 0 auto var(--sa-space-4);">
11 + <i class="bi bi-x-lg"></i>
12 + </div>
13 +
14 + <h2 style="margin-bottom: var(--sa-space-3);">@Localizer["Invalid Invitation"]</h2>
15 + <p style="color: var(--sa-gray-600); margin-bottom: var(--sa-space-6);">
16 + @(error ?? Localizer["This invitation is no longer valid."].Value)
17 + </p>
18 +
19 + <a asp-controller="Trips" asp-action="Index" class="sa-btn sa-btn-primary sa-btn-pill">
20 + <i class="bi bi-luggage me-1"></i> @Localizer["Go to My Trips"]
21 + </a>
22 + </div>
23 + </div>
24 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Invite.cshtml +34 −0
@@ -0,0 +1,34 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Invite Member"];
4 + var tripId = (Guid)ViewData["TripId"]!;
5 + var tripName = (string)ViewData["TripName"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-invite-card">
10 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
11 + <div class="sa-invite-icon" style="color: var(--sa-secondary);">
12 + <i class="bi bi-send-fill"></i>
13 + </div>
14 + <h2 style="margin-bottom: var(--sa-space-2);">@Localizer["Invite Member"]</h2>
15 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-2);">@tripName</p>
16 + <p style="color: var(--sa-gray-600); font-size: 0.938rem; margin-bottom: var(--sa-space-6); line-height: 1.6;">
17 + @Localizer["Generate an invitation link that you can share with someone to join this trip."]
18 + <br /><small class="sa-text-muted">@Localizer["The invitation link will expire after 7 days."]</small>
19 + </p>
20 +
21 + <form asp-action="Invite" asp-route-tripId="@tripId">
22 + <input type="hidden" name="_" value="1" />
23 + <div class="d-flex flex-column gap-3">
24 + <button type="submit" class="sa-btn sa-btn-secondary sa-btn-lg" style="width: 100%;">
25 + <i class="bi bi-link-45deg"></i> @Localizer["Generate Invitation Link"]
26 + </button>
27 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost" style="width: 100%;">
28 + @Localizer["Cancel"]
29 + </a>
30 + </div>
31 + </form>
32 + </div>
33 + </div>
34 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InviteGenerated.cshtml +40 −0
@@ -0,0 +1,40 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Invitation Generated"];
4 + var tripId = (Guid)ViewData["TripId"]!;
5 + var tripName = (string)ViewData["TripName"]!;
6 + var inviteUrl = (string)ViewData["InviteUrl"]!;
7 +}
8 +
9 +<div class="sa-invite-page">
10 + <div class="sa-card-static sa-invite-card">
11 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
12 + <div class="sa-animate-bounce-in" style="margin-bottom: var(--sa-space-4);">
13 + <div class="sa-confirm-icon sa-confirm-icon-success" style="margin: 0 auto;">
14 + <i class="bi bi-check-lg"></i>
15 + </div>
16 + </div>
17 +
18 + <h2 style="margin-bottom: var(--sa-space-2);">@Localizer["Link Generated!"]</h2>
19 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
20 + @Localizer["Share this link with the person you want to invite:"]
21 + </p>
22 +
23 + <div class="sa-copy-group mb-3">
24 + <input type="text" class="form-control sa-copy-input" id="inviteLink" value="@inviteUrl" readonly />
25 + <button class="sa-btn sa-btn-secondary sa-copy-btn" type="button"
26 + onclick="SplitApp.copyToClipboard(document.getElementById('inviteLink').value, this)">
27 + <i class="bi bi-clipboard"></i> @Localizer["Copy"]
28 + </button>
29 + </div>
30 +
31 + <p style="font-size: 0.85rem; color: var(--sa-gray-500); margin-bottom: var(--sa-space-6);">
32 + <i class="bi bi-clock me-1"></i> @Localizer["This link will expire in 7 days."]
33 + </p>
34 +
35 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost" style="width: 100%;">
36 + <i class="bi bi-arrow-left me-1"></i> @Localizer["Back to Members"]
37 + </a>
38 + </div>
39 + </div>
40 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Create.cshtml +83 −0
@@ -0,0 +1,83 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Create Poll"];
4 + var tripId = (Guid)ViewData["TripId"]!;
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-8 col-lg-6">
9 + <div class="sa-card-static sa-card-accent" style="border-top-color: #6366f1;">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center mb-4">
12 + <div style="font-size: 2rem; color: #6366f1; margin-bottom: var(--sa-space-2);">
13 + <i class="bi bi-bar-chart-fill"></i>
14 + </div>
15 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Create Poll"]</h1>
16 + <p class="sa-text-muted">@Localizer["Help your group make decisions together"]</p>
17 + </div>
18 +
19 + <form asp-action="Create" asp-route-tripId="@tripId" method="post" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 +
22 + <div class="mb-4">
23 + <label for="question" class="form-label">@Localizer["Question"]</label>
24 + <input type="text" id="question" name="question" class="form-control"
25 + required maxlength="500" placeholder="@Localizer["What should the group decide?"]"
26 + style="font-size: 1.1rem; font-weight: 600;" />
27 + </div>
28 +
29 + <div class="d-flex gap-4 mb-4" style="padding: var(--sa-space-4); background: var(--sa-gray-50); border-radius: var(--sa-radius-md);">
30 + <div class="form-check form-switch">
31 + <input type="checkbox" id="allowMultipleVotes" name="allowMultipleVotes" value="true" class="form-check-input" />
32 + <label for="allowMultipleVotes" class="form-check-label" style="font-size: 0.9rem;">
33 + <i class="bi bi-check2-all me-1"></i>@Localizer["Allow multiple votes"]
34 + </label>
35 + </div>
36 + <div class="form-check form-switch">
37 + <input type="checkbox" id="isAnonymous" name="isAnonymous" value="true" class="form-check-input" />
38 + <label for="isAnonymous" class="form-check-label" style="font-size: 0.9rem;">
39 + <i class="bi bi-incognito me-1"></i>@Localizer["Anonymous voting"]
40 + </label>
41 + </div>
42 + </div>
43 +
44 + <div class="mb-3">
45 + <div class="d-flex justify-content-between align-items-center mb-2">
46 + <label class="form-label mb-0">@Localizer["Options"]</label>
47 + <small class="sa-text-muted">@Localizer["At least 2 required"]</small>
48 + </div>
49 +
50 + <div id="pollOptionsContainer" class="d-flex flex-column gap-2">
51 + <div class="poll-option-row d-flex gap-2">
52 + <input type="text" name="option1" class="form-control" placeholder="@Localizer["Option"] 1 *" required maxlength="300" />
53 + </div>
54 + <div class="poll-option-row d-flex gap-2">
55 + <input type="text" name="option2" class="form-control" placeholder="@Localizer["Option"] 2 *" required maxlength="300" />
56 + </div>
57 + <div class="poll-option-row d-flex gap-2">
58 + <input type="text" name="option3" class="form-control" placeholder="@Localizer["Option"] 3" maxlength="300" />
59 + </div>
60 + <div class="poll-option-row d-flex gap-2">
61 + <input type="text" name="option4" class="form-control" placeholder="@Localizer["Option"] 4" maxlength="300" />
62 + </div>
63 + <div class="poll-option-row d-flex gap-2">
64 + <input type="text" name="option5" class="form-control" placeholder="@Localizer["Option"] 5" maxlength="300" />
65 + </div>
66 + </div>
67 + </div>
68 +
69 + <div class="d-flex gap-3 mt-4">
70 + <button type="submit" class="sa-btn flex-grow-1" style="background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff;">
71 + <i class="bi bi-check-lg"></i> @Localizer["Create Poll"]
72 + </button>
73 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
74 + </div>
75 + </form>
76 + </div>
77 + </div>
78 + </div>
79 +</div>
80 +
81 +@section Scripts {
82 + <partial name="_ValidationScriptsPartial" />
83 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Details.cshtml +126 −0
@@ -0,0 +1,126 @@
1 +@model SplitApp.WebApp.Application.DTO.TripPollBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Poll Results"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var userId = (Guid)ViewData["UserId"]!;
7 + var isCreator = (bool)ViewData["IsCreator"]!;
8 + var isClosed = Model.ClosedAt != null;
9 + var totalVotes = Model.Options?.Sum(o => o.VoteCount) ?? 0;
10 + var maxVotes = Model.Options?.Max(o => o.VoteCount) ?? 0;
11 +}
12 +
13 +<div class="row justify-content-center">
14 + <div class="col-lg-8">
15 + <!-- Header -->
16 + <div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-3">
17 + <div>
18 + <h1 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Model.Question</h1>
19 + <div class="d-flex align-items-center gap-3" style="font-size: 0.9rem; color: var(--sa-gray-500);">
20 + @if (isClosed)
21 + {
22 + <span class="d-flex align-items-center gap-1">
23 + <span class="sa-status-dot sa-status-dot-pending"></span> @Localizer["Closed"]
24 + </span>
25 + }
26 + else
27 + {
28 + <span class="d-flex align-items-center gap-1">
29 + <span class="sa-status-dot sa-status-dot-active sa-status-dot-pulse"></span> @Localizer["Open"]
30 + </span>
31 + }
32 + <span>@Localizer["Created by"] @(Model.CreatedByUser != null ? $"{Model.CreatedByUser.FirstName} {Model.CreatedByUser.LastName}" : "?")</span>
33 + <span>@totalVotes @Localizer["total votes"]</span>
34 + @if (Model.AllowMultipleVotes)
35 + {
36 + <span><i class="bi bi-check2-all me-1"></i>@Localizer["Multiple votes allowed"]</span>
37 + }
38 + </div>
39 + </div>
40 + <div class="d-flex gap-2">
41 + @if (isCreator && !isClosed)
42 + {
43 + <form asp-action="Close" asp-route-id="@Model.Id" method="post" style="display:inline">
44 + <button type="submit" class="sa-btn sa-btn-sm" style="background: var(--sa-warning); color: #fff;">
45 + <i class="bi bi-lock-fill"></i> @Localizer["Close Poll"]
46 + </button>
47 + </form>
48 + }
49 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost sa-btn-sm">
50 + <i class="bi bi-arrow-left"></i> @Localizer["Back"]
51 + </a>
52 + </div>
53 + </div>
54 +
55 + <!-- Options -->
56 + @if (Model.Options != null)
57 + {
58 + <div class="d-flex flex-column gap-3">
59 + @foreach (var option in Model.Options.OrderBy(o => o.DisplayOrder))
60 + {
61 + var voteCount = option.VoteCount;
62 + var percentage = totalVotes > 0 ? (int)(voteCount * 100.0 / totalVotes) : 0;
63 + var userVoted = option.VoterUserIds.Contains(userId);
64 + var isWinning = voteCount == maxVotes && maxVotes > 0;
65 +
66 + <div class="sa-poll-option @(userVoted ? "sa-poll-option-voted" : "") @(isWinning && isClosed ? "sa-poll-option-winner" : "")">
67 + <div class="d-flex justify-content-between align-items-center mb-3">
68 + <div class="d-flex align-items-center gap-2">
69 + <strong style="font-size: 1rem;">@option.Text</strong>
70 + @if (userVoted)
71 + {
72 + <span class="sa-badge sa-badge-solid-secondary" style="font-size: 0.65rem;">
73 + <i class="bi bi-check2"></i> @Localizer["Your vote"]
74 + </span>
75 + }
76 + @if (isWinning && isClosed)
77 + {
78 + <span class="sa-badge sa-badge-accent" style="font-size: 0.65rem;">
79 + <i class="bi bi-trophy-fill"></i> @Localizer["Winner"]
80 + </span>
81 + }
82 + </div>
83 + <div class="d-flex align-items-center gap-3">
84 + <span style="font-size: 0.9rem; color: var(--sa-gray-500);">
85 + @voteCount @Localizer["votes"] (@percentage%)
86 + </span>
87 + @if (!isClosed)
88 + {
89 + <form asp-action="Vote" method="post" style="display:inline">
90 + <input type="hidden" name="pollId" value="@Model.Id" />
91 + <input type="hidden" name="optionId" value="@option.Id" />
92 + <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill @(userVoted ? "sa-btn-secondary" : "sa-btn-ghost")">
93 + @(userVoted ? Localizer["Unvote"] : Localizer["Vote"])
94 + </button>
95 + </form>
96 + }
97 + </div>
98 + </div>
99 +
100 + @{
101 + var barClass = isWinning && isClosed ? "sa-progress-bar-success"
102 + : userVoted ? "sa-progress-bar"
103 + : "sa-progress-bar-primary";
104 + }
105 + <div class="sa-progress">
106 + <div class="sa-progress-bar @barClass" data-width="@percentage%"></div>
107 + </div>
108 +
109 + @if (!Model.IsAnonymous && option.Voters.Any())
110 + {
111 + <div class="d-flex gap-1 mt-2 flex-wrap">
112 + @foreach (var voter in option.Voters)
113 + {
114 + var voterName = $"{voter.FirstName} {voter.LastName}".Trim();
115 + var voterInitial = $"{(voter.FirstName.Length > 0 ? voter.FirstName[0].ToString() : "")}{(voter.LastName.Length > 0 ? voter.LastName[0].ToString() : "")}";
116 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((voter.Id.GetHashCode() % 8 + 8) % 8 + 1)"
117 + title="@voterName" style="border: none; width: 26px; height: 26px; font-size: 0.6rem;">@voterInitial</span>
118 + }
119 + </div>
120 + }
121 + </div>
122 + }
123 + </div>
124 + }
125 + </div>
126 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Index.cshtml +99 −0
@@ -0,0 +1,99 @@
1 +@model List<SplitApp.WebApp.Application.DTO.TripPollBllDto>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Polls"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 +}
8 +
9 +<!-- Page Header -->
10 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);">
11 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
12 + <div>
13 + <h1 style="margin-bottom: 4px;">@Localizer["Polls"]</h1>
14 + <p class="text-muted mb-0">@tripName</p>
15 + </div>
16 + <div class="d-flex gap-2">
17 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
18 + <i class="bi bi-plus-lg"></i> @Localizer["Create Poll"]
19 + </a>
20 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
21 + <i class="bi bi-arrow-left"></i>
22 + </a>
23 + </div>
24 + </div>
25 +</div>
26 +
27 +@if (!Model.Any())
28 +{
29 + <div class="sa-empty">
30 + <div class="sa-empty-icon"><i class="bi bi-bar-chart"></i></div>
31 + <div class="sa-empty-title">@Localizer["No polls yet"]</div>
32 + <p class="sa-empty-text">@Localizer["Create a poll to help your group make decisions."]</p>
33 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-primary sa-btn-pill">
34 + <i class="bi bi-plus-lg"></i> @Localizer["Create Poll"]
35 + </a>
36 + </div>
37 +}
38 +else
39 +{
40 + <div class="row justify-content-center">
41 + <div class="col-lg-8">
42 + <div class="d-flex flex-column gap-3">
43 + @{ var idx = 0; }
44 + @foreach (var poll in Model)
45 + {
46 + var totalVotes = poll.Options?.Sum(o => o.VoteCount) ?? 0;
47 + var isClosed = poll.ClosedAt != null;
48 +
49 + <div class="sa-card sa-animate-slide-up sa-stagger-@(Math.Min(idx + 1, 6))">
50 + <div class="sa-card-body">
51 + <div class="d-flex justify-content-between align-items-start mb-2">
52 + <h5 style="font-weight: 700; margin: 0; font-size: 1.05rem;">@poll.Question</h5>
53 + <div class="d-flex align-items-center gap-2" style="flex-shrink: 0;">
54 + @if (isClosed)
55 + {
56 + <span class="sa-status-dot sa-status-dot-pending"></span>
57 + <span class="sa-badge sa-badge-neutral">@Localizer["Closed"]</span>
58 + }
59 + else
60 + {
61 + <span class="sa-status-dot sa-status-dot-active sa-status-dot-pulse"></span>
62 + <span class="sa-badge sa-badge-success">@Localizer["Open"]</span>
63 + }
64 + </div>
65 + </div>
66 +
67 + <div style="font-size: 0.85rem; color: var(--sa-gray-500); margin-bottom: var(--sa-space-3);">
68 + @Localizer["Created by"] @(poll.CreatedByUser != null ? $"{poll.CreatedByUser.FirstName} {poll.CreatedByUser.LastName}" : "?")
69 + &middot; @totalVotes @Localizer["votes"]
70 + &middot; @(poll.Options?.Count ?? 0) @Localizer["options"]
71 + @if (poll.AllowMultipleVotes)
72 + {
73 + <span>&middot; <i class="bi bi-check2-all me-1"></i>@Localizer["Multiple votes"]</span>
74 + }
75 + </div>
76 +
77 + <a asp-action="Details" asp-route-id="@poll.Id"
78 + class="sa-btn sa-btn-sm sa-btn-pill @(isClosed ? "sa-btn-ghost" : "sa-btn-secondary")">
79 + @if (isClosed)
80 + {
81 + <i class="bi bi-eye"></i> @Localizer["View Results"]
82 + }
83 + else
84 + {
85 + <i class="bi bi-check2-square"></i> @Localizer["Vote"]
86 + }
87 + </a>
88 + </div>
89 + </div>
90 + idx++;
91 + }
92 + </div>
93 + </div>
94 + </div>
95 +}
96 +
97 +<a asp-action="Create" asp-route-tripId="@tripId" class="sa-fab sa-hide-desktop" title="@Localizer["Create Poll"]">
98 + <i class="bi bi-plus-lg"></i>
99 +</a>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Settlement/Index.cshtml +232 −0
@@ -0,0 +1,232 @@
1 +@model SplitApp.WebApp.Controllers.SettlementIndexViewModel
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Settlement"];
5 + var maxBalance = Model.Balances.Any() ? Model.Balances.Max(b => Math.Abs(b.NetBalance)) : 1m;
6 +}
7 +
8 +<!-- Page Header -->
9 +<div class="sa-gradient-header">
10 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
11 + <div>
12 + <h1 style="margin-bottom: 4px;">@Localizer["Settlement"]</h1>
13 + <p class="text-muted mb-0">
14 + @Model.TripName
15 + @if (Model.TripStatus != "Active" && Model.LatestPlan != null)
16 + {
17 + var statusBadge = Model.LatestPlan.Status switch
18 + {
19 + ESettlementStatus.Completed => "sa-badge-success",
20 + ESettlementStatus.InProgress => "sa-badge-warning",
21 + _ => "sa-badge-info"
22 + };
23 + var statusIcon = Model.LatestPlan.Status switch
24 + {
25 + ESettlementStatus.Completed => "bi-check-circle-fill",
26 + ESettlementStatus.InProgress => "bi-hourglass-split",
27 + _ => "bi-lock-fill"
28 + };
29 + <span class="sa-badge @statusBadge ms-2" style="font-size: 0.75rem;">
30 + <i class="bi @statusIcon me-1"></i>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.LatestPlan.Status)
31 + </span>
32 + }
33 + else if (Model.TripStatus != "Active")
34 + {
35 + <span class="sa-badge sa-badge-info ms-2" style="font-size: 0.75rem;">
36 + <i class="bi bi-lock-fill me-1"></i>@Localizer["Pending"]
37 + </span>
38 + }
39 + </p>
40 + </div>
41 + <div class="d-flex gap-2">
42 + @if (Model.IsOrganizer && Model.TripStatus == "Active")
43 + {
44 + <form asp-action="Finalize" asp-route-tripId="@Model.TripId" method="post" style="display:inline">
45 + <button type="submit" class="sa-btn sa-btn-sm" style="background: #fff; color: var(--sa-success);">
46 + <i class="bi bi-check-circle"></i> @Localizer["Finalize Trip"]
47 + </button>
48 + </form>
49 + }
50 + @if (Model.IsOrganizer && Model.TripStatus != "Active" && Model.LatestPlan != null && Model.LatestPlan.Status != ESettlementStatus.Completed)
51 + {
52 + <form asp-action="Reopen" asp-route-tripId="@Model.TripId" method="post" style="display:inline">
53 + <button type="submit" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8);">
54 + <i class="bi bi-unlock"></i> @Localizer["Reopen"]
55 + </button>
56 + </form>
57 + }
58 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
59 + <i class="bi bi-arrow-left"></i>
60 + </a>
61 + </div>
62 + </div>
63 +</div>
64 +
65 +<!-- Balances -->
66 +<div class="sa-card-static mb-4">
67 + <div class="sa-card-header">
68 + <i class="bi bi-bar-chart me-2"></i>@Localizer["Balances"]
69 + </div>
70 + <div class="sa-card-body">
71 + @foreach (var balance in Model.Balances)
72 + {
73 + var pct = maxBalance > 0 ? (int)(Math.Abs(balance.NetBalance) * 45 / maxBalance) : 0;
74 + <div class="d-flex align-items-center gap-3 mb-3">
75 + <div style="width: 120px; font-weight: 600; font-size: 0.9rem;" class="sa-truncate">
76 + @balance.UserName
77 + </div>
78 + <div style="flex: 1; position: relative; height: 28px; display: flex; align-items: center;">
79 + <!-- Center line -->
80 + <div style="position: absolute; left: 50%; top: 2px; bottom: 2px; width: 2px; background: var(--sa-gray-200); transform: translateX(-50%);"></div>
81 + @if (balance.NetBalance >= 0)
82 + {
83 + <div style="position: absolute; left: 50%; height: 20px; width: @pct%; background: linear-gradient(90deg, var(--sa-success), #16a34a); border-radius: 0 var(--sa-radius-full) var(--sa-radius-full) 0; transition: width 0.8s ease;"></div>
84 + }
85 + else
86 + {
87 + <div style="position: absolute; right: 50%; height: 20px; width: @pct%; background: linear-gradient(270deg, var(--sa-danger), #dc2626); border-radius: var(--sa-radius-full) 0 0 var(--sa-radius-full); transition: width 0.8s ease;"></div>
88 + }
89 + </div>
90 + <div style="width: 100px; text-align: right;">
91 + <span class="sa-amount @(balance.NetBalance >= 0 ? "sa-amount-positive" : "sa-amount-negative")" style="font-size: 0.95rem;">
92 + @(balance.NetBalance >= 0 ? "+" : "")@Model.CurrencySymbol@balance.NetBalance.ToString("N2")
93 + </span>
94 + </div>
95 + </div>
96 + }
97 + </div>
98 +</div>
99 +
100 +<!-- Settlement Plan -->
101 +@if (Model.LatestPlan != null && Model.LatestPlan.Payments != null && Model.LatestPlan.Payments.Any() && Model.TripStatus != "Active")
102 +{
103 + <div class="sa-card-static">
104 + <div class="sa-card-header d-flex justify-content-between align-items-center">
105 + <span><i class="bi bi-check2-circle me-2"></i>@Localizer["Settlement Plan"]</span>
106 + @{
107 + var planBadge = Model.LatestPlan.Status switch
108 + {
109 + ESettlementStatus.Completed => "sa-badge-success",
110 + ESettlementStatus.InProgress => "sa-badge-warning",
111 + _ => "sa-badge-neutral"
112 + };
113 + }
114 + <span class="sa-badge @planBadge">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.LatestPlan.Status)</span>
115 + </div>
116 + <div class="sa-card-body">
117 + <div class="d-flex flex-column gap-3">
118 + @foreach (var payment in Model.LatestPlan.Payments.OrderByDescending(p => p.Amount))
119 + {
120 + var fromName = payment.FromUser != null ? $"{payment.FromUser.FirstName} {payment.FromUser.LastName}" : "?";
121 + var toName = payment.ToUser != null ? $"{payment.ToUser.FirstName} {payment.ToUser.LastName}" : "?";
122 + var fromInitial = payment.FromUser != null ? $"{payment.FromUser.FirstName?[0]}{payment.FromUser.LastName?[0]}" : "?";
123 + var toInitial = payment.ToUser != null ? $"{payment.ToUser.FirstName?[0]}{payment.ToUser.LastName?[0]}" : "?";
124 +
125 + <div class="sa-settlement-card">
126 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1;">
127 + <span class="sa-avatar sa-avatar-sm sa-avatar-5" style="border: none;" title="@fromName">@fromInitial</span>
128 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@fromName</span>
129 + </div>
130 + <div class="d-flex flex-column align-items-center" style="flex-shrink: 0;">
131 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
132 + <span class="sa-settlement-amount">@Model.CurrencySymbol@payment.Amount.ToString("N2")</span>
133 + </div>
134 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1; justify-content: flex-end;">
135 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@toName</span>
136 + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border: none;" title="@toName">@toInitial</span>
137 + </div>
138 + <div style="flex-shrink: 0; min-width: 110px; text-align: right;">
139 + @if (payment.Status == EPaymentStatus.Confirmed)
140 + {
141 + <span class="sa-badge sa-badge-solid-success"><i class="bi bi-check2-all me-1"></i>@Localizer["Confirmed"]</span>
142 + }
143 + else if (payment.Status == EPaymentStatus.MarkedPaid)
144 + {
145 + @if (payment.ToUserId == Model.CurrentUserId)
146 + {
147 + <form asp-action="ConfirmReceipt" asp-route-paymentId="@payment.Id" method="post" style="display:inline">
148 + <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill" style="background: var(--sa-info); color: #fff;">
149 + <i class="bi bi-hand-thumbs-up"></i> @Localizer["Confirm"]
150 + </button>
151 + </form>
152 + }
153 + else
154 + {
155 + <span class="sa-badge sa-badge-warning"><i class="bi bi-hourglass-split me-1"></i>@Localizer["Awaiting Confirmation"]</span>
156 + }
157 + }
158 + else
159 + {
160 + @if (payment.FromUserId == Model.CurrentUserId)
161 + {
162 + <form asp-action="MarkPaid" asp-route-paymentId="@payment.Id" method="post" style="display:inline">
163 + <button type="submit" class="sa-btn sa-btn-success sa-btn-sm sa-btn-pill">
164 + <i class="bi bi-check-lg"></i> @Localizer["Mark Paid"]
165 + </button>
166 + </form>
167 + }
168 + else
169 + {
170 + <span class="sa-badge sa-badge-neutral"><i class="bi bi-clock me-1"></i>@Localizer["Pending"]</span>
171 + }
172 + }
173 + </div>
174 + </div>
175 + }
176 + </div>
177 +
178 + <div class="sa-divider"></div>
179 + <div class="d-flex justify-content-between" style="font-weight: 700;">
180 + <span>@Localizer["Total"]</span>
181 + <span>@Model.CurrencySymbol@Model.LatestPlan.TotalAmount.ToString("N2")</span>
182 + </div>
183 + </div>
184 + </div>
185 +}
186 +else if (Model.PreviewPayments.Any())
187 +{
188 + <div class="sa-card-static">
189 + <div class="sa-card-header d-flex justify-content-between align-items-center">
190 + <span><i class="bi bi-eye me-2"></i>@Localizer["Suggested Payments"]</span>
191 + <span class="sa-badge sa-badge-neutral">@Localizer["Preview"]</span>
192 + </div>
193 + <div class="sa-card-body">
194 + <div class="d-flex flex-column gap-3">
195 + @foreach (var payment in Model.PreviewPayments.OrderByDescending(p => p.Amount))
196 + {
197 + var fromInitial = payment.FromUserName.Length >= 2 ? payment.FromUserName[..2].ToUpper() : "?";
198 + var toInitial = payment.ToUserName.Length >= 2 ? payment.ToUserName[..2].ToUpper() : "?";
199 +
200 + <div class="sa-settlement-card">
201 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1;">
202 + <span class="sa-avatar sa-avatar-sm sa-avatar-5" style="border: none;">@fromInitial</span>
203 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@payment.FromUserName</span>
204 + </div>
205 + <div class="d-flex flex-column align-items-center" style="flex-shrink: 0;">
206 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
207 + <span class="sa-settlement-amount">@Model.CurrencySymbol@payment.Amount.ToString("N2")</span>
208 + </div>
209 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1; justify-content: flex-end;">
210 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@payment.ToUserName</span>
211 + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border: none;">@toInitial</span>
212 + </div>
213 + </div>
214 + }
215 + </div>
216 + <div class="sa-divider"></div>
217 + <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin: 0;">
218 + <i class="bi bi-info-circle me-1"></i>@Localizer["These are suggested payments. Finalize the trip to lock them in."]
219 + </p>
220 + </div>
221 + </div>
222 +}
223 +else
224 +{
225 + <div class="sa-card-static">
226 + <div class="sa-empty" style="padding: var(--sa-space-8) var(--sa-space-4);">
227 + <div class="sa-empty-icon" style="font-size: 2.5rem; color: var(--sa-success);"><i class="bi bi-check-circle-fill"></i></div>
228 + <div class="sa-empty-title" style="font-size: 1rem;">@Localizer["All settled up!"]</div>
229 + <p class="sa-empty-text" style="font-size: 0.875rem;">@Localizer["No payments needed — everyone is even."]</p>
230 + </div>
231 + </div>
232 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/Error.cshtml +29 −0
@@ -0,0 +1,29 @@
1 +@model ErrorViewModel
2 +@{
3 + ViewData["Title"] = Localizer["Error"].Value;
4 +}
5 +
6 +<div class="sa-invite-page">
7 + <div class="sa-card-static sa-invite-card">
8 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
9 + <div class="sa-confirm-icon sa-confirm-icon-danger mb-4" style="margin: 0 auto var(--sa-space-6);">
10 + <i class="bi bi-exclamation-triangle-fill"></i>
11 + </div>
12 + <h2 style="margin-bottom: var(--sa-space-3);">@Localizer["Something went wrong"]</h2>
13 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
14 + @Localizer["An error occurred while processing your request."]
15 + </p>
16 +
17 + @if (Model.ShowRequestId)
18 + {
19 + <div class="sa-bg-soft-danger" style="padding: var(--sa-space-3) var(--sa-space-4); border-radius: var(--sa-radius-sm); margin-bottom: var(--sa-space-6);">
20 + <small><strong>@Localizer["Request ID"]:</strong> <code>@Model.RequestId</code></small>
21 + </div>
22 + }
23 +
24 + <a asp-area="" asp-controller="Home" asp-action="Index" class="sa-btn sa-btn-primary sa-btn-pill">
25 + <i class="bi bi-house-door me-1"></i> @Localizer["Back to Home"]
26 + </a>
27 + </div>
28 + </div>
29 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
@@ -0,0 +1,24 @@
1 +@using Microsoft.AspNetCore.Localization
2 +@using Microsoft.Extensions.Options
3 +
4 +@inject IOptions<RequestLocalizationOptions> LocalizationOptions
5 +
6 +@{
7 + var requestCulture = Context.Features.Get<IRequestCultureFeature>();
8 + var cultureItems = LocalizationOptions.Value.SupportedUICultures!
9 + .Select(c => new { c.Name, c.NativeName })
10 + .ToList();
11 +}
12 +
13 +<div class="sa-lang-pills">
14 + @foreach (var culture in cultureItems)
15 + {
16 + <a class="sa-lang-pill @(requestCulture?.RequestCulture.UICulture.Name == culture.Name ? "active" : "")"
17 + asp-area=""
18 + asp-controller="Home" asp-action="SetLanguage"
19 + asp-route-culture="@culture.Name"
20 + asp-route-returnUrl="@(Context.Request.Path + Context.Request.QueryString)">
21 + @culture.Name.ToUpper()
22 + </a>
23 + }
24 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml +116 −0
@@ -0,0 +1,116 @@
1 +@using Microsoft.AspNetCore.Identity
2 +
3 +@inject SignInManager<AppUser> _signInManager
4 +
5 +<!DOCTYPE html>
6 +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName">
7 +<head>
8 + <meta charset="utf-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10 + <meta name="theme-color" content="#e8604c" />
11 + <title>@ViewData["Title"] - SplitApp</title>
12 + <script type="importmap"></script>
13 +
14 + <!-- Fonts -->
15 + <link rel="preconnect" href="https://fonts.googleapis.com" />
16 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
17 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
18 +
19 + <!-- Icons -->
20 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
21 +
22 + <!-- Styles -->
23 + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
24 + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
25 + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" />
26 + <link rel="stylesheet" href="~/SplitApp.WebApp.styles.css" asp-append-version="true" />
27 +</head>
28 +<body>
29 + <!-- Toast Container -->
30 + <div id="sa-toast-container" class="sa-toast-container"></div>
31 +
32 + <!-- TempData Messages (read by splitapp.js) -->
33 + <div id="sa-tempdata-messages" style="display:none"
34 + data-success="@TempData["Success"]"
35 + data-error="@TempData["Error"]"
36 + data-warning="@TempData["Warning"]"></div>
37 +
38 + <!-- Navbar -->
39 + <header>
40 + <nav class="sa-navbar navbar navbar-expand-md">
41 + <div class="container">
42 + <a class="sa-navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">
43 + <i class="bi bi-airplane-fill"></i>
44 + SplitApp
45 + </a>
46 + <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav"
47 + aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
48 + <i class="bi bi-list" style="font-size: 1.5rem; color: var(--sa-gray-700);"></i>
49 + </button>
50 + <div class="navbar-collapse collapse" id="mainNav">
51 + <ul class="navbar-nav me-auto">
52 + <li class="nav-item">
53 + <a class="nav-link" asp-area="" asp-controller="Home" asp-action="Index">
54 + <i class="bi bi-house-door me-1"></i>@Localizer["Home"]
55 + </a>
56 + </li>
57 + <li class="nav-item">
58 + <a class="nav-link" href="/swagger" target="_blank">
59 + <i class="bi bi-braces me-1"></i>API
60 + </a>
61 + </li>
62 + @if (_signInManager.IsSignedIn(User))
63 + {
64 + <li class="nav-item">
65 + <a class="nav-link" asp-area="" asp-controller="Trips" asp-action="Index">
66 + <i class="bi bi-luggage me-1"></i>@Localizer["Trips"]
67 + </a>
68 + </li>
69 + @if (User.IsInRole("admin"))
70 + {
71 + <li class="nav-item">
72 + <a class="nav-link" asp-area="Admin" asp-controller="Dashboard" asp-action="Index">
73 + <i class="bi bi-speedometer2 me-1"></i>@Localizer["Admin Panel"]
74 + </a>
75 + </li>
76 + }
77 + }
78 + </ul>
79 + <div class="d-flex align-items-center gap-3">
80 + <partial name="_LanguageSelection" />
81 + <partial name="_LoginPartial" />
82 + </div>
83 + </div>
84 + </div>
85 + </nav>
86 + </header>
87 +
88 + <!-- Main Content -->
89 + <div class="container sa-animate-fade-in" style="padding-top: var(--sa-space-6); padding-bottom: var(--sa-space-8);">
90 + <main role="main">
91 + @RenderBody()
92 + </main>
93 + </div>
94 +
95 + <!-- Footer -->
96 + <footer class="sa-footer">
97 + <div class="container d-flex justify-content-between align-items-center flex-wrap gap-3">
98 + <div>
99 + <span style="font-weight: 600; color: var(--sa-gray-200);">
100 + <i class="bi bi-airplane-fill me-1"></i> SplitApp
101 + </span>
102 + <span class="ms-2">&copy; 2026 TalTech</span>
103 + </div>
104 + <div class="d-flex align-items-center gap-2">
105 + <span style="font-size: 0.8rem;">@Thread.CurrentThread.CurrentUICulture.Name</span>
106 + </div>
107 + </div>
108 + </footer>
109 +
110 + <script src="~/lib/jquery/dist/jquery.min.js"></script>
111 + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
112 + <script src="~/js/splitapp.js" asp-append-version="true"></script>
113 + <script src="~/js/site.js" asp-append-version="true"></script>
114 + @await RenderSectionAsync("Scripts", required: false)
115 +</body>
116 +</html>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml.css +48 −0
@@ -0,0 +1,48 @@
1 +/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 +for details on configuring this project to bundle and minify static web assets. */
3 +
4 +a.navbar-brand {
5 + white-space: normal;
6 + text-align: center;
7 + word-break: break-all;
8 +}
9 +
10 +a {
11 + color: #0077cc;
12 +}
13 +
14 +.btn-primary {
15 + color: #fff;
16 + background-color: #1b6ec2;
17 + border-color: #1861ac;
18 +}
19 +
20 +.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
21 + color: #fff;
22 + background-color: #1b6ec2;
23 + border-color: #1861ac;
24 +}
25 +
26 +.border-top {
27 + border-top: 1px solid #e5e5e5;
28 +}
29 +.border-bottom {
30 + border-bottom: 1px solid #e5e5e5;
31 +}
32 +
33 +.box-shadow {
34 + box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
35 +}
36 +
37 +button.accept-policy {
38 + font-size: 1rem;
39 + line-height: inherit;
40 +}
41 +
42 +.footer {
43 + position: absolute;
44 + bottom: 0;
45 + width: 100%;
46 + white-space: nowrap;
47 + line-height: 60px;
48 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LoginPartial.cshtml +50 −0
@@ -0,0 +1,50 @@
1 +@using Microsoft.AspNetCore.Identity
2 +
3 +@inject SignInManager<AppUser> SignInManager
4 +@inject UserManager<AppUser> UserManager
5 +
6 +@if (SignInManager.IsSignedIn(User))
7 +{
8 + var user = await UserManager.GetUserAsync(User);
9 + var initials = "";
10 + if (user != null)
11 + {
12 + initials = (user.FirstName?.Length > 0 ? user.FirstName[0].ToString() : "") +
13 + (user.LastName?.Length > 0 ? user.LastName[0].ToString() : "");
14 + }
15 +
16 + <div class="dropdown">
17 + <button class="sa-nav-avatar-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"
18 + data-bs-auto-close="true" style="border: none;">
19 + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border:none;">@initials</span>
20 + <span class="sa-hide-mobile">@User.Identity?.Name</span>
21 + </button>
22 + <ul class="dropdown-menu dropdown-menu-end">
23 + <li>
24 + <a class="dropdown-item" asp-area="Identity" asp-page="/Account/Manage/Index">
25 + <i class="bi bi-person me-2"></i>@Localizer["Profile"]
26 + </a>
27 + </li>
28 + <li><hr class="dropdown-divider" /></li>
29 + <li>
30 + <form class="form-inline" asp-area="Identity" asp-page="/Account/Logout"
31 + asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })">
32 + <button type="submit" class="dropdown-item text-danger">
33 + <i class="bi bi-box-arrow-right me-2"></i>@Localizer["Logout"]
34 + </button>
35 + </form>
36 + </li>
37 + </ul>
38 + </div>
39 +}
40 +else
41 +{
42 + <div class="sa-navbar-auth-btns">
43 + <a class="sa-btn sa-btn-ghost sa-btn-sm" asp-area="Identity" asp-page="/Account/Login">
44 + @Localizer["Log in"]
45 + </a>
46 + <a class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill" asp-area="Identity" asp-page="/Account/Register">
47 + @Localizer["Sign up"]
48 + </a>
49 + </div>
50 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
@@ -0,0 +1,2 @@
1 +<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
2 +<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Create.cshtml +82 −0
@@ -0,0 +1,82 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Create Trip"];
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-8 col-lg-6">
9 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center mb-4">
12 + <div style="font-size: 2.5rem; color: var(--sa-secondary); margin-bottom: var(--sa-space-2);">
13 + <i class="bi bi-airplane-fill"></i>
14 + </div>
15 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Create Trip"]</h1>
16 + <p class="sa-text-muted">@Localizer["Set up your next adventure"]</p>
17 + </div>
18 +
19 + <form asp-action="Create" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 +
22 + <div class="mb-3">
23 + <label asp-for="Name" class="form-label">@Localizer["Name"]</label>
24 + <input asp-for="Name" class="form-control" placeholder="e.g. Barcelona 2026" />
25 + <span asp-validation-for="Name" class="text-danger"></span>
26 + </div>
27 +
28 + <div class="mb-3">
29 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
30 + <textarea asp-for="Description" class="form-control" rows="3" placeholder="@Localizer["What's the plan?"]"></textarea>
31 + <span asp-validation-for="Description" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="mb-3">
35 + <label asp-for="Destination" class="form-label">
36 + <i class="bi bi-geo-alt me-1"></i>@Localizer["Destination"]
37 + </label>
38 + <input asp-for="Destination" class="form-control" placeholder="e.g. Barcelona, Spain" />
39 + <span asp-validation-for="Destination" class="text-danger"></span>
40 + </div>
41 +
42 + <div class="row mb-3">
43 + <div class="col-6">
44 + <label asp-for="StartDate" class="form-label">
45 + <i class="bi bi-calendar-event me-1"></i>@Localizer["Start Date"]
46 + </label>
47 + <input asp-for="StartDate" type="date" class="form-control" />
48 + <span asp-validation-for="StartDate" class="text-danger"></span>
49 + </div>
50 + <div class="col-6">
51 + <label asp-for="EndDate" class="form-label">
52 + <i class="bi bi-calendar-check me-1"></i>@Localizer["End Date"]
53 + </label>
54 + <input asp-for="EndDate" type="date" class="form-control" />
55 + <span asp-validation-for="EndDate" class="text-danger"></span>
56 + </div>
57 + </div>
58 +
59 + <div class="mb-4">
60 + <label asp-for="DefaultCurrencyId" class="form-label">
61 + <i class="bi bi-currency-exchange me-1"></i>@Localizer["Default Currency"]
62 + </label>
63 + <select asp-for="DefaultCurrencyId" asp-items="@((SelectList)ViewData["DefaultCurrencyId"]!)" class="form-select">
64 + </select>
65 + <span asp-validation-for="DefaultCurrencyId" class="text-danger"></span>
66 + </div>
67 +
68 + <div class="d-flex gap-3">
69 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
70 + <i class="bi bi-check-lg"></i> @Localizer["Create"]
71 + </button>
72 + <a asp-action="Index" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
73 + </div>
74 + </form>
75 + </div>
76 + </div>
77 + </div>
78 +</div>
79 +
80 +@section Scripts {
81 + <partial name="_ValidationScriptsPartial" />
82 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Delete.cshtml +56 −0
@@ -0,0 +1,56 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Trip"];
5 +}
6 +
7 +<div class="sa-invite-page">
8 + <div class="sa-card-static sa-confirm-card sa-card-accent sa-card-accent-danger">
9 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
10 + <div class="text-center">
11 + <div class="sa-confirm-icon sa-confirm-icon-danger">
12 + <i class="bi bi-exclamation-triangle-fill"></i>
13 + </div>
14 + <h2 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Localizer["Delete Trip"]</h2>
15 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
16 + @Localizer["Are you sure you want to delete this trip? This action cannot be undone."]
17 + </p>
18 + </div>
19 +
20 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
21 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
22 + <h5 style="margin-bottom: var(--sa-space-2); font-weight: 700;">@Model.Name</h5>
23 + @if (!string.IsNullOrEmpty(Model.Destination))
24 + {
25 + <div style="color: var(--sa-gray-600); font-size: 0.9rem; margin-bottom: 4px;">
26 + <i class="bi bi-geo-alt-fill me-1" style="color: var(--sa-primary);"></i> @Model.Destination
27 + </div>
28 + }
29 + @if (Model.StartDate.HasValue || Model.EndDate.HasValue)
30 + {
31 + <div style="color: var(--sa-gray-500); font-size: 0.85rem; margin-bottom: 4px;">
32 + <i class="bi bi-calendar-event me-1"></i>
33 + @(Model.StartDate?.ToString("MMM dd, yyyy") ?? "?") — @(Model.EndDate?.ToString("MMM dd, yyyy") ?? "?")
34 + </div>
35 + }
36 + @if (Model.DefaultCurrency != null)
37 + {
38 + <div style="color: var(--sa-gray-500); font-size: 0.85rem;">
39 + <i class="bi bi-currency-exchange me-1"></i> @Model.DefaultCurrency.Code
40 + </div>
41 + }
42 + </div>
43 + </div>
44 +
45 + <form asp-action="Delete">
46 + <input type="hidden" asp-for="Id" />
47 + <div class="d-flex gap-3">
48 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
49 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
50 + </button>
51 + <a asp-action="Details" asp-route-id="@Model.Id" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
52 + </div>
53 + </form>
54 + </div>
55 + </div>
56 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Details.cshtml +291 −0
@@ -0,0 +1,291 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Model.Name;
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var participantCount = (int)ViewData["ParticipantCount"]!;
7 + var recentExpenses = (List<SplitApp.WebApp.Application.DTO.ExpenseBllDto>)ViewData["RecentExpenses"]!;
8 + var totalExpenses = (decimal)ViewData["TotalExpenses"]!;
9 + var userRole = (EParticipantRole)ViewData["UserRole"]!;
10 + var balances = (List<SplitApp.WebApp.Controllers.SettlementBalanceViewModel>)ViewData["Balances"]!;
11 + var currentUserBalance = (decimal)ViewData["CurrentUserBalance"]!;
12 + var budgetUsedPct = (int)ViewData["BudgetUsedPct"]!;
13 + var totalPlanned = (decimal)ViewData["TotalPlanned"]!;
14 + var currencySymbol = (string)ViewData["CurrencySymbol"]!;
15 +}
16 +
17 +<!-- Hero Header -->
18 +<div class="sa-gradient-header">
19 + <div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
20 + <div>
21 + <div class="d-flex align-items-center gap-3 mb-2">
22 + <h1 style="margin: 0;">@Model.Name</h1>
23 + @{
24 + var statusBadge = Model.Status switch
25 + {
26 + ETripStatus.Active => "sa-badge-success",
27 + ETripStatus.Finalizing => "sa-badge-warning",
28 + ETripStatus.Settled => "sa-badge-info",
29 + _ => "sa-badge-neutral"
30 + };
31 + }
32 + <span class="sa-badge @statusBadge">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Status)</span>
33 + </div>
34 + @if (!string.IsNullOrEmpty(Model.Destination))
35 + {
36 + <p class="text-muted mb-1" style="font-size: 1.05rem;">
37 + <i class="bi bi-geo-alt-fill me-1"></i> @Model.Destination
38 + </p>
39 + }
40 + @if (Model.StartDate.HasValue || Model.EndDate.HasValue)
41 + {
42 + <p class="text-muted mb-0" style="font-size: 0.9rem;">
43 + <i class="bi bi-calendar-event me-1"></i>
44 + @(Model.StartDate?.ToString("MMM dd, yyyy") ?? "?") — @(Model.EndDate?.ToString("MMM dd, yyyy") ?? "?")
45 + </p>
46 + }
47 + @if (Model.DefaultCurrency != null)
48 + {
49 + <p class="text-muted mb-0" style="font-size: 0.9rem; margin-top: 4px;">
50 + <i class="bi bi-currency-exchange me-1"></i> @Model.DefaultCurrency.Code (@Model.DefaultCurrency.Symbol)
51 + </p>
52 + }
53 + </div>
54 + <div class="d-flex gap-2">
55 + @if (userRole == EParticipantRole.Organizer)
56 + {
57 + <a asp-action="Edit" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff;">
58 + <i class="bi bi-pencil"></i> @Localizer["Edit"]
59 + </a>
60 + <a asp-action="Delete" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(220,38,38,0.3); color: #fff;">
61 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
62 + </a>
63 + }
64 + <a asp-action="Index" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9);">
65 + <i class="bi bi-arrow-left"></i> @Localizer["Back"]
66 + </a>
67 + </div>
68 + </div>
69 +
70 + <!-- Participant Avatars -->
71 + @if (Model.Participants != null && Model.Participants.Any())
72 + {
73 + <div class="sa-avatar-stack mt-3">
74 + @{ var pIndex = 0; }
75 + @foreach (var participant in Model.Participants.Where(p => p.IsActive).Take(6))
76 + {
77 + var name = participant.User != null ? $"{participant.User.FirstName} {participant.User.LastName}" : "?";
78 + var initial = participant.User != null ? $"{participant.User.FirstName?[0]}{participant.User.LastName?[0]}" : "?";
79 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((pIndex % 8) + 1)" title="@name">@initial</span>
80 + pIndex++;
81 + }
82 + @if (Model.Participants.Count(p => p.IsActive) > 6)
83 + {
84 + <span class="sa-avatar sa-avatar-sm sa-avatar-overflow">+@(Model.Participants.Count(p => p.IsActive) - 6)</span>
85 + }
86 + </div>
87 + }
88 +</div>
89 +
90 +<!-- Stats Row -->
91 +<div class="row g-3 mb-4">
92 + <div class="col-6 col-md-3">
93 + <div class="sa-card-static">
94 + <div class="sa-stat">
95 + <div class="sa-stat-icon" style="color: var(--sa-primary);"><i class="bi bi-receipt"></i></div>
96 + <div class="sa-stat-value">@currencySymbol@totalExpenses.ToString("N0")</div>
97 + <div class="sa-stat-label">@Localizer["Total Expenses"]</div>
98 + </div>
99 + </div>
100 + </div>
101 + <div class="col-6 col-md-3">
102 + <div class="sa-card-static">
103 + <div class="sa-stat">
104 + <div class="sa-stat-icon" style="color: var(--sa-success);"><i class="bi bi-pie-chart-fill"></i></div>
105 + <div class="sa-stat-value" style="color: @(budgetUsedPct > 90 ? "var(--sa-danger)" : budgetUsedPct > 70 ? "var(--sa-accent)" : "var(--sa-success)");">
106 + @(totalPlanned > 0 ? $"{budgetUsedPct}%" : "—")
107 + </div>
108 + <div class="sa-stat-label">@Localizer["Budget Used"]</div>
109 + </div>
110 + </div>
111 + </div>
112 + <div class="col-6 col-md-3">
113 + <div class="sa-card-static" style="@(currentUserBalance >= 0 ? "border-bottom: 3px solid var(--sa-success);" : "border-bottom: 3px solid var(--sa-danger);")">
114 + <div class="sa-stat">
115 + <div class="sa-stat-icon" style="color: @(currentUserBalance >= 0 ? "var(--sa-success)" : "var(--sa-danger)");"><i class="bi bi-wallet2"></i></div>
116 + <div class="sa-stat-value" style="color: @(currentUserBalance >= 0 ? "var(--sa-success)" : "var(--sa-danger)");">
117 + @(currentUserBalance >= 0 ? "+" : "")@currencySymbol@currentUserBalance.ToString("N2")
118 + </div>
119 + <div class="sa-stat-label">@Localizer["Your Balance"]</div>
120 + </div>
121 + </div>
122 + </div>
123 + <div class="col-6 col-md-3">
124 + <div class="sa-card-static">
125 + <div class="sa-stat">
126 + <div class="sa-stat-icon" style="color: #6366f1;"><i class="bi bi-people-fill"></i></div>
127 + <div class="sa-stat-value">@participantCount</div>
128 + <div class="sa-stat-label">@Localizer["Members"]</div>
129 + </div>
130 + </div>
131 + </div>
132 +</div>
133 +
134 +<!-- Navigation Grid -->
135 +<div class="row g-3 mb-4">
136 + <div class="col-4 col-md-2">
137 + <a asp-controller="Expenses" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
138 + <div class="sa-nav-card-icon sa-nav-icon-expenses"><i class="bi bi-receipt-cutoff"></i></div>
139 + <span class="sa-nav-card-label">@Localizer["Expenses"]</span>
140 + </a>
141 + </div>
142 + <div class="col-4 col-md-2">
143 + <a asp-controller="Members" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
144 + <div class="sa-nav-card-icon sa-nav-icon-members"><i class="bi bi-people-fill"></i></div>
145 + <span class="sa-nav-card-label">@Localizer["Members"]</span>
146 + </a>
147 + </div>
148 + <div class="col-4 col-md-2">
149 + <a asp-controller="Budget" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
150 + <div class="sa-nav-card-icon sa-nav-icon-budget"><i class="bi bi-wallet2"></i></div>
151 + <span class="sa-nav-card-label">@Localizer["Budget"]</span>
152 + </a>
153 + </div>
154 + <div class="col-4 col-md-2">
155 + <a asp-controller="WishlistClient" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
156 + <div class="sa-nav-card-icon sa-nav-icon-wishlist"><i class="bi bi-star-fill"></i></div>
157 + <span class="sa-nav-card-label">@Localizer["Wishlist"]</span>
158 + </a>
159 + </div>
160 + <div class="col-4 col-md-2">
161 + <a asp-controller="PollsClient" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
162 + <div class="sa-nav-card-icon sa-nav-icon-polls"><i class="bi bi-bar-chart-fill"></i></div>
163 + <span class="sa-nav-card-label">@Localizer["Polls"]</span>
164 + </a>
165 + </div>
166 + <div class="col-4 col-md-2">
167 + <a asp-controller="Settlement" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
168 + <div class="sa-nav-card-icon sa-nav-icon-settlement"><i class="bi bi-check2-circle"></i></div>
169 + <span class="sa-nav-card-label">@Localizer["Settlement"]</span>
170 + </a>
171 + </div>
172 +</div>
173 +
174 +<!-- Balances -->
175 +@if (balances.Any())
176 +{
177 + <div class="row g-3 mb-4">
178 + <!-- Budget Progress -->
179 + @if (totalPlanned > 0)
180 + {
181 + <div class="col-md-6">
182 + <div class="sa-card-static h-100">
183 + <div class="sa-card-header">
184 + <i class="bi bi-pie-chart-fill me-2"></i>@Localizer["Budget Progress"]
185 + </div>
186 + <div class="sa-card-body">
187 + @{
188 + var budgetCategories = balances; // just for the section - actual categories loaded separately
189 + }
190 + <div class="d-flex justify-content-between mb-2">
191 + <span style="font-weight: 600; font-size: 0.9rem;">@currencySymbol@totalExpenses.ToString("N0") / @currencySymbol@totalPlanned.ToString("N0")</span>
192 + <span class="@(budgetUsedPct > 100 ? "text-danger fw-bold" : "sa-text-muted")" style="font-size: 0.85rem;">@budgetUsedPct%</span>
193 + </div>
194 + @{
195 + var overallBarClass = budgetUsedPct <= 60 ? "sa-progress-bar-success" : budgetUsedPct <= 85 ? "sa-progress-bar-warning" : "sa-progress-bar-danger";
196 + }
197 + <div class="sa-progress sa-progress-lg">
198 + <div class="sa-progress-bar @overallBarClass" data-width="@Math.Min(budgetUsedPct, 100)%" style="width: 0%;"></div>
199 + </div>
200 + </div>
201 + </div>
202 + </div>
203 + }
204 +
205 + <!-- Balances -->
206 + <div class="@(totalPlanned > 0 ? "col-md-6" : "col-12")">
207 + <div class="sa-card-static h-100">
208 + <div class="sa-card-header">
209 + <i class="bi bi-people-fill me-2"></i>@Localizer["Balances"]
210 + </div>
211 + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-5);">
212 + @{ var bIdx = 0; }
213 + @foreach (var balance in balances)
214 + {
215 + var initial = balance.UserName.Length >= 2
216 + ? $"{balance.UserName.Split(' ')[0][0]}{(balance.UserName.Split(' ').Length > 1 ? balance.UserName.Split(' ')[1][0] : ' ')}"
217 + : balance.UserName[0].ToString();
218 + <div class="d-flex align-items-center gap-3 py-2">
219 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((bIdx % 8) + 1)" style="border: none; font-size: 0.65rem;">@initial.Trim()</span>
220 + <span style="flex: 1; font-weight: 500; font-size: 0.9rem;">@balance.UserName</span>
221 + <span class="sa-amount @(balance.NetBalance >= 0 ? "sa-amount-positive" : "sa-amount-negative")" style="font-size: 0.95rem;">
222 + @(balance.NetBalance >= 0 ? "+" : "")@currencySymbol@balance.NetBalance.ToString("N2")
223 + </span>
224 + </div>
225 + bIdx++;
226 + }
227 + </div>
228 + </div>
229 + </div>
230 + </div>
231 +}
232 +
233 +<!-- Recent Expenses -->
234 +@if (recentExpenses.Any())
235 +{
236 + <div class="sa-card-static mb-4">
237 + <div class="sa-card-header d-flex justify-content-between align-items-center">
238 + <span><i class="bi bi-receipt me-2"></i>@Localizer["Recent Expenses"]</span>
239 + <a asp-controller="Expenses" asp-action="Index" asp-route-tripId="@tripId"
240 + class="sa-btn sa-btn-ghost sa-btn-sm">
241 + @Localizer["View All"] <i class="bi bi-arrow-right ms-1"></i>
242 + </a>
243 + </div>
244 + <div>
245 + @foreach (var expense in recentExpenses)
246 + {
247 + <div class="sa-expense-item">
248 + <div class="sa-expense-icon sa-category-default">
249 + <i class="bi bi-receipt"></i>
250 + </div>
251 + <div class="sa-expense-details">
252 + <div class="sa-expense-desc">@(expense.Description ?? Localizer["No description"].Value)</div>
253 + <div class="sa-expense-meta">
254 + @(expense.PaidByUser != null ? $"{expense.PaidByUser.FirstName} {expense.PaidByUser.LastName}" : "?")
255 + &middot; @expense.ExpenseDate.ToString("MMM dd")
256 + </div>
257 + </div>
258 + <div class="sa-expense-amount">
259 + @expense.Amount.ToString("N2")
260 + </div>
261 + </div>
262 + }
263 + </div>
264 + </div>
265 +}
266 +else
267 +{
268 + <div class="sa-card-static mb-4">
269 + <div class="sa-empty" style="padding: var(--sa-space-8) var(--sa-space-4);">
270 + <div class="sa-empty-icon" style="font-size: 2.5rem;"><i class="bi bi-receipt"></i></div>
271 + <div class="sa-empty-title" style="font-size: 1rem;">@Localizer["No expenses yet"]</div>
272 + <p class="sa-empty-text" style="font-size: 0.875rem;">@Localizer["Start tracking your group expenses."]</p>
273 + <a asp-controller="Expenses" asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill">
274 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
275 + </a>
276 + </div>
277 + </div>
278 +}
279 +
280 +<!-- Description -->
281 +@if (!string.IsNullOrEmpty(Model.Description))
282 +{
283 + <div class="sa-card-static">
284 + <div class="sa-card-header">
285 + <i class="bi bi-text-paragraph me-2"></i>@Localizer["Description"]
286 + </div>
287 + <div class="sa-card-body">
288 + <p style="margin: 0; color: var(--sa-gray-600); line-height: 1.7;">@Model.Description</p>
289 + </div>
290 + </div>
291 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Edit.cshtml +87 −0
@@ -0,0 +1,87 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Trip"];
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-8 col-lg-6">
9 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center mb-4">
12 + <div style="font-size: 2.5rem; color: var(--sa-secondary); margin-bottom: var(--sa-space-2);">
13 + <i class="bi bi-pencil-square"></i>
14 + </div>
15 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Trip"]</h1>
16 + <p class="sa-text-muted">@Model.Name</p>
17 + </div>
18 +
19 + <form asp-action="Edit" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="CreatedById" />
23 +
24 + <div class="mb-3">
25 + <label asp-for="Name" class="form-label">@Localizer["Name"]</label>
26 + <input asp-for="Name" class="form-control" />
27 + <span asp-validation-for="Name" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="mb-3">
31 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
32 + <textarea asp-for="Description" class="form-control" rows="3"></textarea>
33 + <span asp-validation-for="Description" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="mb-3">
37 + <label asp-for="Destination" class="form-label">
38 + <i class="bi bi-geo-alt me-1"></i>@Localizer["Destination"]
39 + </label>
40 + <input asp-for="Destination" class="form-control" />
41 + <span asp-validation-for="Destination" class="text-danger"></span>
42 + </div>
43 +
44 + <div class="row mb-3">
45 + <div class="col-6">
46 + <label asp-for="StartDate" class="form-label">@Localizer["Start Date"]</label>
47 + <input asp-for="StartDate" type="date" class="form-control" />
48 + <span asp-validation-for="StartDate" class="text-danger"></span>
49 + </div>
50 + <div class="col-6">
51 + <label asp-for="EndDate" class="form-label">@Localizer["End Date"]</label>
52 + <input asp-for="EndDate" type="date" class="form-control" />
53 + <span asp-validation-for="EndDate" class="text-danger"></span>
54 + </div>
55 + </div>
56 +
57 + <div class="mb-3">
58 + <label asp-for="DefaultCurrencyId" class="form-label">
59 + <i class="bi bi-currency-exchange me-1"></i>@Localizer["Default Currency"]
60 + </label>
61 + <select asp-for="DefaultCurrencyId" asp-items="@((SelectList)ViewData["DefaultCurrencyId"]!)" class="form-select">
62 + <option value="">@Localizer["Select currency..."]</option>
63 + </select>
64 + <span asp-validation-for="DefaultCurrencyId" class="text-danger"></span>
65 + </div>
66 +
67 + <div class="mb-4">
68 + <label asp-for="Status" class="form-label">@Localizer["Status"]</label>
69 + <select asp-for="Status" asp-items="Html.GetEnumSelectList<ETripStatus>()" class="form-select"></select>
70 + <span asp-validation-for="Status" class="text-danger"></span>
71 + </div>
72 +
73 + <div class="d-flex gap-3">
74 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
75 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
76 + </button>
77 + <a asp-action="Details" asp-route-id="@Model.Id" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
78 + </div>
79 + </form>
80 + </div>
81 + </div>
82 + </div>
83 +</div>
84 +
85 +@section Scripts {
86 + <partial name="_ValidationScriptsPartial" />
87 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Index.cshtml +104 −0
@@ -0,0 +1,104 @@
1 +@model List<SplitApp.WebApp.Controllers.TripIndexViewModel>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["My Trips"];
5 +}
6 +
7 +<!-- Page Header -->
8 +<div class="sa-gradient-header">
9 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
10 + <div>
11 + <h1 style="margin-bottom: 4px;">@Localizer["My Trips"]</h1>
12 + <p class="text-muted mb-0">
13 + @Model.Count @Localizer["trip(s)"]
14 + </p>
15 + </div>
16 + <a asp-action="Create" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
17 + <i class="bi bi-plus-lg"></i> @Localizer["Create Trip"]
18 + </a>
19 + </div>
20 +</div>
21 +
22 +@if (!Model.Any())
23 +{
24 + <div class="sa-empty">
25 + <div class="sa-empty-icon">
26 + <i class="bi bi-luggage"></i>
27 + </div>
28 + <div class="sa-empty-title">@Localizer["No trips yet"]</div>
29 + <p class="sa-empty-text">@Localizer["Create your first trip to get started!"]</p>
30 + <a asp-action="Create" class="sa-btn sa-btn-primary sa-btn-pill">
31 + <i class="bi bi-plus-lg"></i> @Localizer["Create Trip"]
32 + </a>
33 + </div>
34 +}
35 +else
36 +{
37 + <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
38 + @{ var index = 0; }
39 + @foreach (var trip in Model)
40 + {
41 + <div class="col sa-animate-slide-up sa-stagger-@(Math.Min(index + 1, 6))">
42 + <a asp-action="Details" asp-route-id="@trip.Id" class="text-decoration-none">
43 + <div class="sa-card sa-trip-card h-100">
44 + <div class="sa-trip-card-gradient"></div>
45 + <div class="sa-card-body">
46 + <div class="d-flex justify-content-between align-items-start mb-3">
47 + <h5 style="font-weight: 700; color: var(--sa-gray-900); margin: 0;">@trip.Name</h5>
48 + @{
49 + var statusClass = trip.Status switch
50 + {
51 + ETripStatus.Active => "sa-badge-success",
52 + ETripStatus.Finalizing => "sa-badge-warning",
53 + ETripStatus.Settled => "sa-badge-info",
54 + _ => "sa-badge-neutral"
55 + };
56 + }
57 + <span class="sa-badge @statusClass">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(trip.Status)</span>
58 + </div>
59 +
60 + @if (!string.IsNullOrEmpty(trip.Destination))
61 + {
62 + <div class="d-flex align-items-center gap-2 mb-2" style="color: var(--sa-gray-600); font-size: 0.9rem;">
63 + <i class="bi bi-geo-alt-fill" style="color: var(--sa-primary);"></i>
64 + @trip.Destination
65 + </div>
66 + }
67 +
68 + @if (trip.StartDate.HasValue || trip.EndDate.HasValue)
69 + {
70 + <div class="d-flex align-items-center gap-2 mb-3" style="color: var(--sa-gray-500); font-size: 0.85rem;">
71 + <i class="bi bi-calendar-event"></i>
72 + @(trip.StartDate?.ToString("MMM dd") ?? "?") — @(trip.EndDate?.ToString("MMM dd, yyyy") ?? "?")
73 + </div>
74 + }
75 +
76 + <div class="d-flex align-items-center gap-2 mt-auto">
77 + @if (trip.Role == EParticipantRole.Organizer)
78 + {
79 + <span class="sa-badge sa-badge-accent">
80 + <i class="bi bi-star-fill" style="font-size: 0.65rem;"></i> @Localizer["Organizer"]
81 + </span>
82 + }
83 + else
84 + {
85 + <span class="sa-badge sa-badge-neutral">@Localizer["Participant"]</span>
86 + }
87 + @if (!string.IsNullOrEmpty(trip.CurrencyCode))
88 + {
89 + <span class="sa-badge sa-badge-neutral">@trip.CurrencyCode</span>
90 + }
91 + </div>
92 + </div>
93 + </div>
94 + </a>
95 + </div>
96 + index++;
97 + }
98 + </div>
99 +}
100 +
101 +<!-- Mobile FAB -->
102 +<a asp-action="Create" class="sa-fab sa-hide-desktop" title="@Localizer["Create Trip"]">
103 + <i class="bi bi-plus-lg"></i>
104 +</a>
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Create.cshtml +85 −0
@@ -0,0 +1,85 @@
1 +@model SplitApp.WebApp.Application.DTO.TripWishlistItemBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Add Wishlist Item"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-accent">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-accent); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-star-fill"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Wishlist Item"]</h1>
17 + <p class="sa-text-muted">@Localizer["Share what you want to experience"]</p>
18 + </div>
19 +
20 + <form asp-action="Create" data-sa-loading>
21 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <div class="mb-3">
25 + <label asp-for="Title" class="form-label">@Localizer["Title"]</label>
26 + <input asp-for="Title" class="form-control" placeholder="@Localizer["e.g. Sagrada Familia, Local tapas bar"]" />
27 + <span asp-validation-for="Title" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="mb-3">
31 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
32 + <textarea asp-for="Description" class="form-control" rows="3" placeholder="@Localizer["Why do you want to go here?"]"></textarea>
33 + <span asp-validation-for="Description" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="row mb-3">
37 + <div class="col-md-6 mb-3 mb-md-0">
38 + <label asp-for="Category" class="form-label">@Localizer["Category"]</label>
39 + <select asp-for="Category" asp-items="@((SelectList)ViewData["Categories"]!)" class="form-select"></select>
40 + <span asp-validation-for="Category" class="text-danger"></span>
41 + </div>
42 + <div class="col-md-6">
43 + <label asp-for="Priority" class="form-label">@Localizer["Priority"]</label>
44 + <select asp-for="Priority" asp-items="@((SelectList)ViewData["Priorities"]!)" class="form-select"></select>
45 + <span asp-validation-for="Priority" class="text-danger"></span>
46 + </div>
47 + </div>
48 +
49 + <div class="mb-3">
50 + <label asp-for="EstimatedCost" class="form-label">@Localizer["Estimated Cost"]</label>
51 + <input asp-for="EstimatedCost" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
52 + <span asp-validation-for="EstimatedCost" class="text-danger"></span>
53 + </div>
54 +
55 + <div class="mb-3">
56 + <label asp-for="Location" class="form-label">
57 + <i class="bi bi-geo-alt me-1"></i>@Localizer["Location"]
58 + </label>
59 + <input asp-for="Location" class="form-control" placeholder="@Localizer["e.g. Old Town, City Center"]" />
60 + <span asp-validation-for="Location" class="text-danger"></span>
61 + </div>
62 +
63 + <div class="mb-4">
64 + <label asp-for="Url" class="form-label">
65 + <i class="bi bi-link-45deg me-1"></i>@Localizer["URL"]
66 + </label>
67 + <input asp-for="Url" type="url" class="form-control" placeholder="https://..." />
68 + <span asp-validation-for="Url" class="text-danger"></span>
69 + </div>
70 +
71 + <div class="d-flex gap-3">
72 + <button type="submit" class="sa-btn sa-btn-accent flex-grow-1">
73 + <i class="bi bi-check-lg"></i> @Localizer["Add Item"]
74 + </button>
75 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
76 + </div>
77 + </form>
78 + </div>
79 + </div>
80 + </div>
81 +</div>
82 +
83 +@section Scripts {
84 + <partial name="_ValidationScriptsPartial" />
85 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Delete.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@model SplitApp.WebApp.Application.DTO.TripWishlistItemBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Wishlist Item"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-danger); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-trash3"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Delete Wishlist Item"]</h1>
17 + <p class="sa-text-muted">@Localizer["AreYouSure"]</p>
18 + </div>
19 +
20 + <div class="sa-card-static mb-4">
21 + <div class="sa-card-body">
22 + <h5>@Model.Title</h5>
23 + @if (!string.IsNullOrEmpty(Model.Description))
24 + {
25 + <p class="sa-text-muted">@Model.Description</p>
26 + }
27 + @if (Model.EstimatedCost.HasValue)
28 + {
29 + <p>@Localizer["Estimated Cost"]: @Model.EstimatedCost.Value.ToString("N2")</p>
30 + }
31 + </div>
32 + </div>
33 +
34 + <form asp-action="Delete" data-sa-loading>
35 + <div class="d-flex gap-3">
36 + <button type="submit" class="sa-btn flex-grow-1" style="background: var(--sa-danger); color: #fff;">
37 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
38 + </button>
39 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
40 + </div>
41 + </form>
42 + </div>
43 + </div>
44 + </div>
45 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Edit.cshtml +77 −0
@@ -0,0 +1,77 @@
1 +@model SplitApp.WebApp.Application.DTO.TripWishlistItemBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Wishlist Item"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-accent">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-accent); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-pencil-square"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Wishlist Item"]</h1>
17 + </div>
18 +
19 + <form asp-action="Edit" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="TripId" />
23 + <input type="hidden" asp-for="AddedByUserId" />
24 +
25 + <div class="mb-3">
26 + <label asp-for="Title" class="form-label">@Localizer["Title"]</label>
27 + <input asp-for="Title" class="form-control" />
28 + <span asp-validation-for="Title" class="text-danger"></span>
29 + </div>
30 +
31 + <div class="mb-3">
32 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
33 + <textarea asp-for="Description" class="form-control" rows="3"></textarea>
34 + <span asp-validation-for="Description" class="text-danger"></span>
35 + </div>
36 +
37 + <div class="row mb-3">
38 + <div class="col-md-6 mb-3 mb-md-0">
39 + <label asp-for="Category" class="form-label">@Localizer["Category"]</label>
40 + <select asp-for="Category" asp-items="@((SelectList)ViewData["Categories"]!)" class="form-select"></select>
41 + </div>
42 + <div class="col-md-6">
43 + <label asp-for="Priority" class="form-label">@Localizer["Priority"]</label>
44 + <select asp-for="Priority" asp-items="@((SelectList)ViewData["Priorities"]!)" class="form-select"></select>
45 + </div>
46 + </div>
47 +
48 + <div class="mb-3">
49 + <label asp-for="EstimatedCost" class="form-label">@Localizer["Estimated Cost"]</label>
50 + <input asp-for="EstimatedCost" type="number" step="0.01" min="0" class="form-control" />
51 + </div>
52 +
53 + <div class="mb-3">
54 + <label asp-for="Location" class="form-label"><i class="bi bi-geo-alt me-1"></i>@Localizer["Location"]</label>
55 + <input asp-for="Location" class="form-control" />
56 + </div>
57 +
58 + <div class="mb-4">
59 + <label asp-for="Url" class="form-label"><i class="bi bi-link-45deg me-1"></i>@Localizer["URL"]</label>
60 + <input asp-for="Url" type="url" class="form-control" />
61 + </div>
62 +
63 + <div class="d-flex gap-3">
64 + <button type="submit" class="sa-btn sa-btn-accent flex-grow-1">
65 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
66 + </button>
67 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
68 + </div>
69 + </form>
70 + </div>
71 + </div>
72 + </div>
73 +</div>
74 +
75 +@section Scripts {
76 + <partial name="_ValidationScriptsPartial" />
77 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Index.cshtml +147 −0
@@ -0,0 +1,147 @@
1 +@model List<SplitApp.WebApp.Controllers.WishlistItemViewModel>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Wishlist"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 + var currentUserId = (Guid)ViewData["CurrentUserId"]!;
8 +}
9 +
10 +<!-- Page Header -->
11 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%);">
12 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
13 + <div>
14 + <h1 style="margin-bottom: 4px;">@Localizer["Wishlist"]</h1>
15 + <p class="text-muted mb-0">@tripName</p>
16 + </div>
17 + <div class="d-flex gap-2">
18 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
19 + <i class="bi bi-plus-lg"></i> @Localizer["Add Item"]
20 + </a>
21 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
22 + <i class="bi bi-arrow-left"></i>
23 + </a>
24 + </div>
25 + </div>
26 +</div>
27 +
28 +@if (!Model.Any())
29 +{
30 + <div class="sa-empty">
31 + <div class="sa-empty-icon"><i class="bi bi-star"></i></div>
32 + <div class="sa-empty-title">@Localizer["No wishlist items yet"]</div>
33 + <p class="sa-empty-text">@Localizer["Add places, activities, or restaurants you want to visit."]</p>
34 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-accent sa-btn-pill">
35 + <i class="bi bi-plus-lg"></i> @Localizer["Add Item"]
36 + </a>
37 + </div>
38 +}
39 +else
40 +{
41 + <div class="row g-3">
42 + @{ var idx = 0; }
43 + @foreach (var item in Model.OrderByDescending(i => i.VoteCount).ThenBy(i => i.IsCompleted))
44 + {
45 + var categoryColor = item.Category switch
46 + {
47 + EWishlistCategory.Place => "var(--sa-info)",
48 + EWishlistCategory.Activity => "var(--sa-success)",
49 + EWishlistCategory.Restaurant => "var(--sa-accent)",
50 + _ => "var(--sa-gray-400)"
51 + };
52 + var categoryBadge = item.Category switch
53 + {
54 + EWishlistCategory.Place => "sa-badge-info",
55 + EWishlistCategory.Activity => "sa-badge-success",
56 + EWishlistCategory.Restaurant => "sa-badge-accent",
57 + _ => "sa-badge-neutral"
58 + };
59 + var priorityBadge = item.Priority switch
60 + {
61 + EWishlistPriority.MustDo => "sa-badge-danger",
62 + EWishlistPriority.NiceToHave => "sa-badge-info",
63 + _ => "sa-badge-neutral"
64 + };
65 +
66 + <div class="col-md-6 col-lg-4 sa-animate-slide-up sa-stagger-@(Math.Min(idx + 1, 6))">
67 + <div class="sa-card h-100 @(item.IsCompleted ? "" : "")" style="@(item.IsCompleted ? "opacity: 0.7;" : "")">
68 + <div style="height: 4px; background: @categoryColor;"></div>
69 + <div class="sa-card-body">
70 + <div class="d-flex justify-content-between align-items-start mb-2">
71 + <h5 style="font-weight: 700; font-size: 1rem; margin: 0; @(item.IsCompleted ? "text-decoration: line-through; color: var(--sa-gray-400);" : "")">
72 + @if (item.IsCompleted) { <i class="bi bi-check-circle-fill me-1" style="color: var(--sa-success);"></i> }
73 + @item.Title
74 + </h5>
75 + <span class="sa-badge @categoryBadge" style="flex-shrink: 0;">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Category)</span>
76 + </div>
77 +
78 + @if (!string.IsNullOrEmpty(item.Description))
79 + {
80 + <p style="font-size: 0.875rem; color: var(--sa-gray-600); margin-bottom: var(--sa-space-3); line-height: 1.5;">
81 + @item.Description
82 + </p>
83 + }
84 +
85 + <div class="d-flex flex-wrap gap-2 mb-3">
86 + <span class="sa-badge @priorityBadge">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Priority)</span>
87 + @if (item.EstimatedCost.HasValue)
88 + {
89 + <span class="sa-badge sa-badge-neutral">~@item.EstimatedCost.Value.ToString("N2")</span>
90 + }
91 + </div>
92 +
93 + @if (!string.IsNullOrEmpty(item.Location))
94 + {
95 + <div style="font-size: 0.85rem; color: var(--sa-gray-500); margin-bottom: 4px;">
96 + <i class="bi bi-geo-alt-fill me-1" style="color: var(--sa-primary);"></i> @item.Location
97 + </div>
98 + }
99 +
100 + @if (!string.IsNullOrEmpty(item.Url))
101 + {
102 + <div style="font-size: 0.85rem; margin-bottom: 4px;">
103 + <a href="@item.Url" target="_blank" rel="noopener" style="color: var(--sa-secondary);">
104 + <i class="bi bi-link-45deg me-1"></i>@Localizer["View Link"]
105 + </a>
106 + </div>
107 + }
108 +
109 + <div style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-2);">
110 + @Localizer["Added by"] @item.AddedByName
111 + </div>
112 + </div>
113 + <div class="sa-card-footer d-flex justify-content-between align-items-center">
114 + <form asp-action="Vote" asp-route-id="@item.Id" method="post" style="display:inline">
115 + <button type="submit" class="sa-vote-btn @(item.UserHasVoted ? "sa-vote-btn-active" : "")">
116 + <i class="bi @(item.UserHasVoted ? "bi-heart-fill" : "bi-heart")"></i>
117 + @item.VoteCount
118 + </button>
119 + </form>
120 + <div class="d-flex align-items-center gap-2">
121 + <form asp-action="Complete" asp-route-id="@item.Id" method="post" style="display:inline">
122 + <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill @(item.IsCompleted ? "sa-btn-success" : "sa-btn-ghost")">
123 + <i class="bi @(item.IsCompleted ? "bi-check-circle-fill" : "bi-circle")"></i>
124 + @(item.IsCompleted ? Localizer["Done"] : Localizer["Mark Done"])
125 + </button>
126 + </form>
127 + @if (item.AddedByUserId == currentUserId)
128 + {
129 + <a asp-action="Edit" asp-route-id="@item.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
130 + <i class="bi bi-pencil"></i>
131 + </a>
132 + <a asp-action="Delete" asp-route-id="@item.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
133 + <i class="bi bi-trash3"></i>
134 + </a>
135 + }
136 + </div>
137 + </div>
138 + </div>
139 + </div>
140 + idx++;
141 + }
142 + </div>
143 +}
144 +
145 +<a asp-action="Create" asp-route-tripId="@tripId" class="sa-fab sa-hide-desktop" title="@Localizer["Add Item"]">
146 + <i class="bi bi-plus-lg"></i>
147 +</a>
added SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewImports.cshtml +15 −0
@@ -0,0 +1,15 @@
1 +@using SplitApp.WebApp
2 +@using SplitApp.WebApp.Models
3 +@using SplitApp.WebApp.Hosting.Helpers
4 +@using SplitApp.WebApp.Application.DTO
5 +@using SplitApp.WebApp.Controllers
6 +@using SplitApp.Modules.Trips.Domain.Entities
7 +@using SplitApp.Modules.Trips.Domain.Enums
8 +@using SplitApp.Modules.Expenses.Domain.Entities
9 +@using SplitApp.Modules.Expenses.Domain.Enums
10 +@using SplitApp.Modules.Users.Domain.Entities
11 +@using SplitApp.Shared.Kernel.Localization
12 +@using Microsoft.Extensions.Localization
13 +@using Microsoft.AspNetCore.Mvc.Localization
14 +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
15 +@inject IStringLocalizer<App.Resources.Views.Shared> Localizer
added SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewStart.cshtml +3 −0
@@ -0,0 +1,3 @@
1 +@{
2 + Layout = "_Layout";
3 +}
added SplitApp.Modular/src/SplitApp.WebApp/appsettings.json +27 −0
@@ -0,0 +1,27 @@
1 +{
2 + "ConnectionStrings": {
3 + "DefaultConnection": "Host=localhost;Port=5432;Database=splitapp;Username=postgres;Password=postgres"
4 + },
5 + "DataInitialization": {
6 + "DropDatabase": false,
7 + "MigrateDatabase": true,
8 + "SeedIdentity": true,
9 + "SeedData": true
10 + },
11 + "SupportedCultures": ["en", "et"],
12 + "DefaultCulture": "en",
13 + "LangStrDefaultCulture": "en",
14 + "JWT": {
15 + "Key": "dev-only-jwt-signing-key-override-in-production-via-JWT_KEY-env-var",
16 + "Issuer": "itcollege.taltech.ee",
17 + "Audience": "itcollege.taltech.ee",
18 + "ExpiresInSeconds": 1800
19 + },
20 + "Logging": {
21 + "LogLevel": {
22 + "Default": "Information",
23 + "Microsoft.AspNetCore": "Warning"
24 + }
25 + },
26 + "AllowedHosts": "*"
27 +}
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/admin.css +439 −0
@@ -0,0 +1,439 @@
1 +/* Admin area styles — sidebar layout, metric cards, badges */
2 +
3 +:root {
4 + --admin-sidebar-width: 256px;
5 + --admin-sidebar-bg: #1f2430;
6 + --admin-sidebar-bg-hover: #2a3040;
7 + --admin-sidebar-text: #cbd1dc;
8 + --admin-sidebar-text-muted: #7b8596;
9 + --admin-sidebar-active: #e8604c;
10 + --admin-topbar-height: 62px;
11 + --admin-bg: #f5f6fa;
12 + --admin-card-border: #e6e8ef;
13 + --admin-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
14 + --admin-shadow-md: 0 6px 24px -8px rgba(15, 23, 42, 0.12);
15 +}
16 +
17 +.admin-body {
18 + background: var(--admin-bg);
19 + font-family: 'Inter', system-ui, sans-serif;
20 +}
21 +
22 +.admin-shell {
23 + display: flex;
24 + min-height: 100vh;
25 +}
26 +
27 +/* === SIDEBAR === */
28 +.admin-sidebar {
29 + width: var(--admin-sidebar-width);
30 + background: var(--admin-sidebar-bg);
31 + color: var(--admin-sidebar-text);
32 + display: flex;
33 + flex-direction: column;
34 + position: fixed;
35 + top: 0;
36 + left: 0;
37 + bottom: 0;
38 + z-index: 1040;
39 + transition: transform 0.25s ease;
40 +}
41 +
42 +.admin-sidebar-brand {
43 + padding: 1.25rem 1.25rem 1rem;
44 + border-bottom: 1px solid rgba(255, 255, 255, 0.06);
45 + display: flex;
46 + align-items: center;
47 + gap: 0.55rem;
48 + color: #fff;
49 + font-weight: 700;
50 + font-size: 1.15rem;
51 +}
52 +
53 +.admin-sidebar-brand i {
54 + color: var(--admin-sidebar-active);
55 + font-size: 1.35rem;
56 +}
57 +
58 +.admin-sidebar-brand-sub {
59 + margin-left: auto;
60 + font-size: 0.65rem;
61 + letter-spacing: 0.18em;
62 + text-transform: uppercase;
63 + color: var(--admin-sidebar-text-muted);
64 + font-weight: 600;
65 +}
66 +
67 +.admin-sidebar-nav {
68 + flex: 1;
69 + overflow-y: auto;
70 + padding: 0.75rem 0.5rem 1rem;
71 +}
72 +
73 +.admin-nav-section {
74 + font-size: 0.68rem;
75 + letter-spacing: 0.16em;
76 + text-transform: uppercase;
77 + color: var(--admin-sidebar-text-muted);
78 + padding: 1rem 0.8rem 0.35rem;
79 + font-weight: 600;
80 +}
81 +
82 +.admin-nav-link {
83 + display: flex;
84 + align-items: center;
85 + gap: 0.7rem;
86 + padding: 0.55rem 0.8rem;
87 + margin: 0.1rem 0;
88 + color: var(--admin-sidebar-text);
89 + text-decoration: none;
90 + border-radius: 8px;
91 + font-size: 0.92rem;
92 + transition: background 0.15s, color 0.15s;
93 +}
94 +
95 +.admin-nav-link i {
96 + font-size: 1.05rem;
97 + width: 1.1rem;
98 + text-align: center;
99 + color: var(--admin-sidebar-text-muted);
100 +}
101 +
102 +.admin-nav-link:hover {
103 + background: var(--admin-sidebar-bg-hover);
104 + color: #fff;
105 + text-decoration: none;
106 +}
107 +
108 +.admin-nav-link:hover i {
109 + color: #fff;
110 +}
111 +
112 +.admin-nav-link.active {
113 + background: var(--admin-sidebar-active);
114 + color: #fff;
115 + font-weight: 600;
116 +}
117 +
118 +.admin-nav-link.active i {
119 + color: #fff;
120 +}
121 +
122 +.admin-sidebar-footer {
123 + padding: 0.9rem 1rem 1.2rem;
124 + border-top: 1px solid rgba(255, 255, 255, 0.06);
125 +}
126 +
127 +.admin-back-link {
128 + color: var(--admin-sidebar-text-muted);
129 + text-decoration: none;
130 + font-size: 0.88rem;
131 + display: inline-flex;
132 + align-items: center;
133 + gap: 0.4rem;
134 +}
135 +
136 +.admin-back-link:hover {
137 + color: #fff;
138 +}
139 +
140 +/* === MAIN AREA === */
141 +.admin-main {
142 + margin-left: var(--admin-sidebar-width);
143 + flex: 1;
144 + display: flex;
145 + flex-direction: column;
146 + min-width: 0;
147 +}
148 +
149 +.admin-topbar {
150 + height: var(--admin-topbar-height);
151 + background: #fff;
152 + border-bottom: 1px solid var(--admin-card-border);
153 + display: flex;
154 + align-items: center;
155 + padding: 0 1.5rem;
156 + gap: 1rem;
157 + position: sticky;
158 + top: 0;
159 + z-index: 1030;
160 +}
161 +
162 +.admin-topbar-toggle {
163 + background: transparent;
164 + border: none;
165 + font-size: 1.4rem;
166 + color: #334155;
167 +}
168 +
169 +.admin-topbar-title {
170 + font-size: 1.05rem;
171 + font-weight: 600;
172 + color: #334155;
173 + display: flex;
174 + align-items: center;
175 + gap: 0.5rem;
176 +}
177 +
178 +.admin-topbar-title i {
179 + color: var(--admin-sidebar-active);
180 +}
181 +
182 +.admin-topbar-actions {
183 + margin-left: auto;
184 + display: flex;
185 + align-items: center;
186 + gap: 1rem;
187 +}
188 +
189 +.admin-content {
190 + padding: 1.5rem 1.75rem 2.5rem;
191 + flex: 1;
192 +}
193 +
194 +.admin-footer {
195 + padding: 1rem 1.75rem;
196 + font-size: 0.82rem;
197 + color: #64748b;
198 + border-top: 1px solid var(--admin-card-border);
199 + background: #fff;
200 + display: flex;
201 + justify-content: space-between;
202 + align-items: center;
203 + flex-wrap: wrap;
204 + gap: 0.5rem;
205 +}
206 +
207 +/* === PAGE HEADERS === */
208 +.admin-page-header {
209 + display: flex;
210 + justify-content: space-between;
211 + align-items: flex-start;
212 + gap: 1rem;
213 + margin-bottom: 1.5rem;
214 + flex-wrap: wrap;
215 +}
216 +
217 +.admin-page-header h1 {
218 + margin: 0 0 0.25rem;
219 + font-size: 1.65rem;
220 + font-weight: 700;
221 + color: #0f172a;
222 +}
223 +
224 +.admin-page-header .lead {
225 + margin: 0;
226 + color: #64748b;
227 + font-size: 0.95rem;
228 +}
229 +
230 +/* === CARDS === */
231 +.admin-card {
232 + background: #fff;
233 + border: 1px solid var(--admin-card-border);
234 + border-radius: 12px;
235 + box-shadow: var(--admin-shadow-sm);
236 + margin-bottom: 1rem;
237 + overflow: hidden;
238 +}
239 +
240 +.admin-card-header {
241 + padding: 0.9rem 1.15rem;
242 + border-bottom: 1px solid var(--admin-card-border);
243 + display: flex;
244 + align-items: center;
245 + justify-content: space-between;
246 + gap: 0.5rem;
247 + font-weight: 600;
248 + color: #334155;
249 +}
250 +
251 +.admin-card-header i {
252 + color: var(--admin-sidebar-active);
253 + margin-right: 0.4rem;
254 +}
255 +
256 +.admin-card-body {
257 + padding: 1.15rem;
258 +}
259 +
260 +/* === METRIC CARDS === */
261 +.admin-metric {
262 + background: #fff;
263 + border: 1px solid var(--admin-card-border);
264 + border-radius: 12px;
265 + padding: 1.1rem 1.25rem;
266 + box-shadow: var(--admin-shadow-sm);
267 + display: flex;
268 + align-items: center;
269 + gap: 1rem;
270 + height: 100%;
271 + transition: transform 0.15s, box-shadow 0.15s;
272 +}
273 +
274 +.admin-metric:hover {
275 + transform: translateY(-2px);
276 + box-shadow: var(--admin-shadow-md);
277 +}
278 +
279 +.admin-metric-icon {
280 + width: 48px;
281 + height: 48px;
282 + border-radius: 10px;
283 + display: flex;
284 + align-items: center;
285 + justify-content: center;
286 + font-size: 1.4rem;
287 + color: #fff;
288 + flex-shrink: 0;
289 +}
290 +
291 +.admin-metric-icon.bg-primary { background: #3b82f6; }
292 +.admin-metric-icon.bg-success { background: #10b981; }
293 +.admin-metric-icon.bg-warning { background: #f59e0b; }
294 +.admin-metric-icon.bg-danger { background: #ef4444; }
295 +.admin-metric-icon.bg-info { background: #06b6d4; }
296 +.admin-metric-icon.bg-purple { background: #8b5cf6; }
297 +
298 +.admin-metric-value {
299 + font-size: 1.65rem;
300 + font-weight: 700;
301 + color: #0f172a;
302 + line-height: 1.1;
303 +}
304 +
305 +.admin-metric-label {
306 + color: #64748b;
307 + font-size: 0.85rem;
308 + margin-top: 0.15rem;
309 +}
310 +
311 +/* === TABLES === */
312 +.admin-table {
313 + margin-bottom: 0;
314 +}
315 +
316 +.admin-table thead th {
317 + background: #f8fafc;
318 + border-bottom: 2px solid var(--admin-card-border);
319 + color: #475569;
320 + font-size: 0.82rem;
321 + text-transform: uppercase;
322 + letter-spacing: 0.04em;
323 + font-weight: 600;
324 + padding: 0.75rem 1rem;
325 +}
326 +
327 +.admin-table tbody td {
328 + padding: 0.75rem 1rem;
329 + vertical-align: middle;
330 + border-color: #f1f5f9;
331 +}
332 +
333 +.admin-table tbody tr:hover {
334 + background: #f8fafc;
335 +}
336 +
337 +/* === BADGES === */
338 +.badge.status-active { background-color: #10b981; }
339 +.badge.status-settled { background-color: #3b82f6; }
340 +.badge.status-archived { background-color: #64748b; }
341 +.badge.status-pending { background-color: #f59e0b; color: #fff; }
342 +.badge.status-inprogress { background-color: #06b6d4; color: #fff; }
343 +.badge.status-completed { background-color: #10b981; }
344 +.badge.status-accepted { background-color: #10b981; }
345 +.badge.status-declined { background-color: #ef4444; }
346 +.badge.status-expired { background-color: #64748b; }
347 +.badge.status-markedpaid { background-color: #06b6d4; color: #fff; }
348 +.badge.status-confirmed { background-color: #10b981; }
349 +
350 +/* === ACTION BUTTONS === */
351 +.admin-action-group {
352 + display: inline-flex;
353 + gap: 0.25rem;
354 +}
355 +
356 +.admin-action-group .btn {
357 + padding: 0.3rem 0.55rem;
358 + border-radius: 6px;
359 + font-size: 0.82rem;
360 +}
361 +
362 +/* === TIMELINE / FEED === */
363 +.admin-feed {
364 + list-style: none;
365 + margin: 0;
366 + padding: 0;
367 +}
368 +
369 +.admin-feed-item {
370 + display: flex;
371 + gap: 0.85rem;
372 + padding: 0.7rem 0;
373 + border-bottom: 1px solid #f1f5f9;
374 +}
375 +
376 +.admin-feed-item:last-child {
377 + border-bottom: none;
378 +}
379 +
380 +.admin-feed-icon {
381 + width: 34px;
382 + height: 34px;
383 + border-radius: 50%;
384 + display: flex;
385 + align-items: center;
386 + justify-content: center;
387 + color: #fff;
388 + font-size: 0.9rem;
389 + flex-shrink: 0;
390 +}
391 +
392 +.admin-feed-body {
393 + flex: 1;
394 + min-width: 0;
395 +}
396 +
397 +.admin-feed-message {
398 + color: #334155;
399 + font-size: 0.9rem;
400 +}
401 +
402 +.admin-feed-date {
403 + color: #94a3b8;
404 + font-size: 0.78rem;
405 + margin-top: 0.1rem;
406 +}
407 +
408 +/* === EMPTY STATE === */
409 +.admin-empty {
410 + text-align: center;
411 + padding: 2.5rem 1rem;
412 + color: #94a3b8;
413 +}
414 +
415 +.admin-empty i {
416 + font-size: 2.4rem;
417 + display: block;
418 + margin-bottom: 0.5rem;
419 + color: #cbd5e1;
420 +}
421 +
422 +/* === RESPONSIVE === */
423 +@media (max-width: 767.98px) {
424 + .admin-sidebar {
425 + transform: translateX(-100%);
426 + }
427 +
428 + body.admin-sidebar-open .admin-sidebar {
429 + transform: translateX(0);
430 + }
431 +
432 + .admin-main {
433 + margin-left: 0;
434 + }
435 +
436 + .admin-content {
437 + padding: 1rem;
438 + }
439 +}
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/site.css +31 −0
@@ -0,0 +1,31 @@
1 +html {
2 + font-size: 14px;
3 +}
4 +
5 +@media (min-width: 768px) {
6 + html {
7 + font-size: 16px;
8 + }
9 +}
10 +
11 +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
12 + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
13 +}
14 +
15 +html {
16 + position: relative;
17 + min-height: 100%;
18 +}
19 +
20 +body {
21 + margin-bottom: 60px;
22 +}
23 +
24 +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
25 + color: var(--bs-secondary-color);
26 + text-align: end;
27 +}
28 +
29 +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
30 + text-align: start;
31 +}
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/splitapp-design.css +1605 −0
@@ -0,0 +1,1605 @@
1 +/* ============================================================
2 + SplitApp Design System
3 + A premium travel app design layer on top of Bootstrap 5
4 + ============================================================ */
5 +
6 +/* ---------- CSS Custom Properties ---------- */
7 +:root {
8 + /* Primary: Coral-to-Rose */
9 + --sa-primary: #e8604c;
10 + --sa-primary-light: #ff7e6b;
11 + --sa-primary-dark: #c94535;
12 + --sa-primary-gradient: linear-gradient(135deg, #e8604c 0%, #d4456a 100%);
13 +
14 + /* Secondary: Ocean Teal */
15 + --sa-secondary: #1a9e8f;
16 + --sa-secondary-light: #2ec4b6;
17 + --sa-secondary-dark: #147a6e;
18 + --sa-secondary-gradient: linear-gradient(135deg, #1a9e8f 0%, #2176ae 100%);
19 +
20 + /* Accent: Golden Amber */
21 + --sa-accent: #f4a623;
22 + --sa-accent-light: #ffc857;
23 + --sa-accent-dark: #d48e15;
24 +
25 + /* Neutrals: Warm Grays */
26 + --sa-gray-50: #faf9f7;
27 + --sa-gray-100: #f3f1ed;
28 + --sa-gray-200: #e8e5df;
29 + --sa-gray-300: #d4d0c8;
30 + --sa-gray-400: #a8a29e;
31 + --sa-gray-500: #78716c;
32 + --sa-gray-600: #57534e;
33 + --sa-gray-700: #44403c;
34 + --sa-gray-800: #292524;
35 + --sa-gray-900: #1c1917;
36 +
37 + /* Semantic */
38 + --sa-success: #22c55e;
39 + --sa-success-light: #dcfce7;
40 + --sa-warning: #f59e0b;
41 + --sa-warning-light: #fef3c7;
42 + --sa-danger: #ef4444;
43 + --sa-danger-light: #fee2e2;
44 + --sa-info: #3b82f6;
45 + --sa-info-light: #dbeafe;
46 +
47 + /* Spacing (4px base) */
48 + --sa-space-1: 4px;
49 + --sa-space-2: 8px;
50 + --sa-space-3: 12px;
51 + --sa-space-4: 16px;
52 + --sa-space-5: 20px;
53 + --sa-space-6: 24px;
54 + --sa-space-8: 32px;
55 + --sa-space-10: 40px;
56 + --sa-space-12: 48px;
57 + --sa-space-16: 64px;
58 +
59 + /* Border Radius */
60 + --sa-radius-sm: 6px;
61 + --sa-radius-md: 12px;
62 + --sa-radius-lg: 16px;
63 + --sa-radius-xl: 24px;
64 + --sa-radius-full: 9999px;
65 +
66 + /* Shadows (warm-tinted) */
67 + --sa-shadow-sm: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04);
68 + --sa-shadow-md: 0 4px 12px rgba(28, 25, 23, 0.08), 0 2px 4px rgba(28, 25, 23, 0.04);
69 + --sa-shadow-lg: 0 12px 32px rgba(28, 25, 23, 0.1), 0 4px 8px rgba(28, 25, 23, 0.06);
70 + --sa-shadow-xl: 0 20px 48px rgba(28, 25, 23, 0.14), 0 8px 16px rgba(28, 25, 23, 0.06);
71 +
72 + /* Transitions */
73 + --sa-transition-fast: 150ms ease;
74 + --sa-transition-normal: 250ms ease;
75 + --sa-transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
76 +
77 + /* Typography */
78 + --sa-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
79 + --sa-font-mono: 'JetBrains Mono', 'Fira Code', monospace;
80 +}
81 +
82 +/* ---------- Base Overrides ---------- */
83 +html {
84 + scroll-behavior: smooth;
85 +}
86 +
87 +body {
88 + font-family: var(--sa-font-sans);
89 + background-color: var(--sa-gray-50);
90 + color: var(--sa-gray-800);
91 + -webkit-font-smoothing: antialiased;
92 + -moz-osx-font-smoothing: grayscale;
93 + margin-bottom: 0;
94 +}
95 +
96 +h1, h2, h3, h4, h5, h6 {
97 + font-weight: 700;
98 + color: var(--sa-gray-900);
99 + letter-spacing: -0.02em;
100 +}
101 +
102 +h1 { font-size: 2rem; }
103 +h2 { font-size: 1.5rem; }
104 +h3 { font-size: 1.25rem; }
105 +
106 +a {
107 + color: var(--sa-secondary);
108 + text-decoration: none;
109 + transition: color var(--sa-transition-fast);
110 +}
111 +
112 +a:hover {
113 + color: var(--sa-secondary-dark);
114 +}
115 +
116 +/* Focus rings */
117 +:focus-visible {
118 + outline: 2px solid var(--sa-secondary);
119 + outline-offset: 2px;
120 + box-shadow: none;
121 +}
122 +
123 +.btn:focus-visible, .form-control:focus-visible, .form-select:focus-visible, .form-check-input:focus-visible {
124 + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.25);
125 + border-color: var(--sa-secondary);
126 +}
127 +
128 +/* ---------- Form Enhancements ---------- */
129 +.form-control, .form-select {
130 + border-radius: var(--sa-radius-sm);
131 + border: 1.5px solid var(--sa-gray-200);
132 + padding: 10px 14px;
133 + font-size: 0.938rem;
134 + transition: border-color var(--sa-transition-fast), box-shadow var(--sa-transition-fast);
135 + background-color: #fff;
136 +}
137 +
138 +.form-control:focus, .form-select:focus {
139 + border-color: var(--sa-secondary);
140 + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.15);
141 +}
142 +
143 +.form-label {
144 + font-weight: 600;
145 + font-size: 0.85rem;
146 + color: var(--sa-gray-600);
147 + text-transform: uppercase;
148 + letter-spacing: 0.04em;
149 + margin-bottom: 6px;
150 +}
151 +
152 +.form-check-input:checked {
153 + background-color: var(--sa-secondary);
154 + border-color: var(--sa-secondary);
155 +}
156 +
157 +/* ---------- sa-card ---------- */
158 +.sa-card {
159 + background: #fff;
160 + border-radius: var(--sa-radius-md);
161 + box-shadow: var(--sa-shadow-sm);
162 + border: 1px solid var(--sa-gray-100);
163 + overflow: hidden;
164 + transition: transform var(--sa-transition-normal), box-shadow var(--sa-transition-normal);
165 +}
166 +
167 +.sa-card:hover {
168 + transform: translateY(-2px);
169 + box-shadow: var(--sa-shadow-md);
170 +}
171 +
172 +.sa-card-static {
173 + background: #fff;
174 + border-radius: var(--sa-radius-md);
175 + box-shadow: var(--sa-shadow-sm);
176 + border: 1px solid var(--sa-gray-100);
177 + overflow: hidden;
178 +}
179 +
180 +.sa-card-body {
181 + padding: var(--sa-space-6);
182 +}
183 +
184 +.sa-card-header {
185 + padding: var(--sa-space-5) var(--sa-space-6);
186 + border-bottom: 1px solid var(--sa-gray-100);
187 + font-weight: 600;
188 +}
189 +
190 +.sa-card-footer {
191 + padding: var(--sa-space-4) var(--sa-space-6);
192 + border-top: 1px solid var(--sa-gray-100);
193 + background: var(--sa-gray-50);
194 +}
195 +
196 +.sa-card-accent {
197 + border-top: 3px solid;
198 +}
199 +
200 +.sa-card-accent-primary {
201 + border-top-color: var(--sa-primary);
202 +}
203 +
204 +.sa-card-accent-secondary {
205 + border-top-color: var(--sa-secondary);
206 +}
207 +
208 +.sa-card-accent-accent {
209 + border-top-color: var(--sa-accent);
210 +}
211 +
212 +.sa-card-accent-danger {
213 + border-top-color: var(--sa-danger);
214 +}
215 +
216 +/* Colored gradient strip on top */
217 +.sa-card-gradient-strip {
218 + height: 4px;
219 + background: var(--sa-primary-gradient);
220 +}
221 +
222 +.sa-card-gradient-strip-teal {
223 + background: var(--sa-secondary-gradient);
224 +}
225 +
226 +.sa-card-gradient-strip-amber {
227 + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-primary) 100%);
228 +}
229 +
230 +/* ---------- Buttons ---------- */
231 +.sa-btn {
232 + display: inline-flex;
233 + align-items: center;
234 + justify-content: center;
235 + gap: 8px;
236 + padding: 10px 20px;
237 + font-weight: 600;
238 + font-size: 0.938rem;
239 + border-radius: var(--sa-radius-sm);
240 + border: none;
241 + cursor: pointer;
242 + transition: all var(--sa-transition-fast);
243 + text-decoration: none;
244 + line-height: 1.4;
245 +}
246 +
247 +.sa-btn:hover {
248 + transform: translateY(-1px);
249 + text-decoration: none;
250 +}
251 +
252 +.sa-btn:active {
253 + transform: translateY(0);
254 +}
255 +
256 +.sa-btn-primary {
257 + background: var(--sa-primary-gradient);
258 + color: #fff;
259 + box-shadow: 0 2px 8px rgba(232, 96, 76, 0.3);
260 +}
261 +
262 +.sa-btn-primary:hover {
263 + box-shadow: 0 4px 16px rgba(232, 96, 76, 0.4);
264 + color: #fff;
265 +}
266 +
267 +.sa-btn-secondary {
268 + background: var(--sa-secondary-gradient);
269 + color: #fff;
270 + box-shadow: 0 2px 8px rgba(26, 158, 143, 0.3);
271 +}
272 +
273 +.sa-btn-secondary:hover {
274 + box-shadow: 0 4px 16px rgba(26, 158, 143, 0.4);
275 + color: #fff;
276 +}
277 +
278 +.sa-btn-accent {
279 + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-accent-dark) 100%);
280 + color: #fff;
281 + box-shadow: 0 2px 8px rgba(244, 166, 35, 0.3);
282 +}
283 +
284 +.sa-btn-ghost {
285 + background: transparent;
286 + color: var(--sa-gray-700);
287 + border: 1.5px solid var(--sa-gray-300);
288 +}
289 +
290 +.sa-btn-ghost:hover {
291 + background: var(--sa-gray-100);
292 + color: var(--sa-gray-900);
293 + border-color: var(--sa-gray-400);
294 +}
295 +
296 +.sa-btn-danger {
297 + background: linear-gradient(135deg, var(--sa-danger) 0%, #dc2626 100%);
298 + color: #fff;
299 +}
300 +
301 +.sa-btn-success {
302 + background: linear-gradient(135deg, var(--sa-success) 0%, #16a34a 100%);
303 + color: #fff;
304 +}
305 +
306 +.sa-btn-sm {
307 + padding: 6px 14px;
308 + font-size: 0.813rem;
309 +}
310 +
311 +.sa-btn-lg {
312 + padding: 14px 28px;
313 + font-size: 1.063rem;
314 +}
315 +
316 +.sa-btn-pill {
317 + border-radius: var(--sa-radius-full);
318 +}
319 +
320 +.sa-btn-icon {
321 + width: 40px;
322 + height: 40px;
323 + padding: 0;
324 + border-radius: var(--sa-radius-full);
325 + font-size: 1.1rem;
326 +}
327 +
328 +.sa-btn-icon.sa-btn-sm {
329 + width: 32px;
330 + height: 32px;
331 + font-size: 0.9rem;
332 +}
333 +
334 +/* Loading state */
335 +.sa-btn-loading {
336 + position: relative;
337 + pointer-events: none;
338 + opacity: 0.75;
339 +}
340 +
341 +.sa-btn-loading::after {
342 + content: '';
343 + position: absolute;
344 + width: 16px;
345 + height: 16px;
346 + border: 2px solid transparent;
347 + border-top-color: currentColor;
348 + border-radius: 50%;
349 + animation: sa-spin 0.6s linear infinite;
350 +}
351 +
352 +/* ---------- Badges ---------- */
353 +.sa-badge {
354 + display: inline-flex;
355 + align-items: center;
356 + gap: 4px;
357 + padding: 3px 10px;
358 + font-size: 0.75rem;
359 + font-weight: 600;
360 + border-radius: var(--sa-radius-full);
361 + letter-spacing: 0.02em;
362 +}
363 +
364 +.sa-badge-primary { background: rgba(232, 96, 76, 0.12); color: var(--sa-primary-dark); }
365 +.sa-badge-secondary { background: rgba(26, 158, 143, 0.12); color: var(--sa-secondary-dark); }
366 +.sa-badge-accent { background: rgba(244, 166, 35, 0.12); color: var(--sa-accent-dark); }
367 +.sa-badge-success { background: var(--sa-success-light); color: #15803d; }
368 +.sa-badge-warning { background: var(--sa-warning-light); color: #92400e; }
369 +.sa-badge-danger { background: var(--sa-danger-light); color: #dc2626; }
370 +.sa-badge-info { background: var(--sa-info-light); color: #1d4ed8; }
371 +.sa-badge-neutral { background: var(--sa-gray-100); color: var(--sa-gray-600); }
372 +
373 +.sa-badge-solid-success { background: var(--sa-success); color: #fff; }
374 +.sa-badge-solid-warning { background: var(--sa-warning); color: #fff; }
375 +.sa-badge-solid-danger { background: var(--sa-danger); color: #fff; }
376 +.sa-badge-solid-info { background: var(--sa-info); color: #fff; }
377 +.sa-badge-solid-secondary { background: var(--sa-secondary); color: #fff; }
378 +.sa-badge-solid-neutral { background: var(--sa-gray-400); color: #fff; }
379 +
380 +/* ---------- Progress Bar ---------- */
381 +.sa-progress {
382 + height: 10px;
383 + background: var(--sa-gray-100);
384 + border-radius: var(--sa-radius-full);
385 + overflow: hidden;
386 +}
387 +
388 +.sa-progress-bar {
389 + height: 100%;
390 + border-radius: var(--sa-radius-full);
391 + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
392 + background: var(--sa-secondary-gradient);
393 +}
394 +
395 +.sa-progress-bar-success { background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%); }
396 +.sa-progress-bar-warning { background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); }
397 +.sa-progress-bar-danger { background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); }
398 +.sa-progress-bar-primary { background: var(--sa-primary-gradient); }
399 +
400 +.sa-progress-lg {
401 + height: 16px;
402 +}
403 +
404 +.sa-progress-sm {
405 + height: 6px;
406 +}
407 +
408 +/* ---------- Avatar ---------- */
409 +.sa-avatar {
410 + display: inline-flex;
411 + align-items: center;
412 + justify-content: center;
413 + width: 40px;
414 + height: 40px;
415 + border-radius: 50%;
416 + font-weight: 700;
417 + font-size: 0.875rem;
418 + color: #fff;
419 + text-transform: uppercase;
420 + flex-shrink: 0;
421 + border: 2px solid #fff;
422 + box-shadow: 0 1px 3px rgba(0,0,0,0.1);
423 +}
424 +
425 +.sa-avatar-sm { width: 32px; height: 32px; font-size: 0.75rem; }
426 +.sa-avatar-lg { width: 52px; height: 52px; font-size: 1.1rem; }
427 +.sa-avatar-xl { width: 64px; height: 64px; font-size: 1.3rem; }
428 +
429 +/* Avatar color palette */
430 +.sa-avatar-1 { background: #e8604c; }
431 +.sa-avatar-2 { background: #1a9e8f; }
432 +.sa-avatar-3 { background: #6366f1; }
433 +.sa-avatar-4 { background: #f59e0b; }
434 +.sa-avatar-5 { background: #ec4899; }
435 +.sa-avatar-6 { background: #14b8a6; }
436 +.sa-avatar-7 { background: #8b5cf6; }
437 +.sa-avatar-8 { background: #f97316; }
438 +
439 +/* Avatar stack (overlapping) */
440 +.sa-avatar-stack {
441 + display: flex;
442 +}
443 +
444 +.sa-avatar-stack .sa-avatar {
445 + margin-left: -10px;
446 +}
447 +
448 +.sa-avatar-stack .sa-avatar:first-child {
449 + margin-left: 0;
450 +}
451 +
452 +.sa-avatar-overflow {
453 + background: var(--sa-gray-200);
454 + color: var(--sa-gray-600);
455 + font-size: 0.7rem;
456 +}
457 +
458 +/* ---------- Stats ---------- */
459 +.sa-stat {
460 + text-align: center;
461 + padding: var(--sa-space-5);
462 +}
463 +
464 +.sa-stat-value {
465 + font-size: 1.75rem;
466 + font-weight: 800;
467 + line-height: 1.2;
468 + color: var(--sa-gray-900);
469 + letter-spacing: -0.03em;
470 +}
471 +
472 +.sa-stat-label {
473 + font-size: 0.8rem;
474 + font-weight: 500;
475 + color: var(--sa-gray-500);
476 + text-transform: uppercase;
477 + letter-spacing: 0.06em;
478 + margin-top: 4px;
479 +}
480 +
481 +.sa-stat-icon {
482 + font-size: 1.5rem;
483 + margin-bottom: 8px;
484 + opacity: 0.8;
485 +}
486 +
487 +/* ---------- Empty State ---------- */
488 +.sa-empty {
489 + text-align: center;
490 + padding: var(--sa-space-16) var(--sa-space-6);
491 +}
492 +
493 +.sa-empty-icon {
494 + font-size: 3.5rem;
495 + color: var(--sa-gray-300);
496 + margin-bottom: var(--sa-space-4);
497 +}
498 +
499 +.sa-empty-title {
500 + font-size: 1.25rem;
501 + font-weight: 700;
502 + color: var(--sa-gray-700);
503 + margin-bottom: var(--sa-space-2);
504 +}
505 +
506 +.sa-empty-text {
507 + font-size: 0.938rem;
508 + color: var(--sa-gray-500);
509 + margin-bottom: var(--sa-space-6);
510 + max-width: 360px;
511 + margin-left: auto;
512 + margin-right: auto;
513 +}
514 +
515 +/* ---------- Gradient Header ---------- */
516 +.sa-gradient-header {
517 + background: var(--sa-secondary-gradient);
518 + color: #fff;
519 + padding: var(--sa-space-8) 0;
520 + margin: -1rem calc(-1 * (50vw - 50%)) var(--sa-space-8);
521 + padding-left: calc(50vw - 50%);
522 + padding-right: calc(50vw - 50%);
523 +}
524 +
525 +.sa-gradient-header-coral {
526 + background: var(--sa-primary-gradient);
527 +}
528 +
529 +.sa-gradient-header h1,
530 +.sa-gradient-header h2,
531 +.sa-gradient-header h3 {
532 + color: #fff;
533 +}
534 +
535 +.sa-gradient-header .sa-badge {
536 + background: rgba(255,255,255,0.2);
537 + color: #fff;
538 +}
539 +
540 +.sa-gradient-header .text-muted {
541 + color: rgba(255,255,255,0.8) !important;
542 +}
543 +
544 +/* ---------- Navigation Card ---------- */
545 +.sa-nav-card {
546 + display: flex;
547 + flex-direction: column;
548 + align-items: center;
549 + gap: 8px;
550 + padding: var(--sa-space-5) var(--sa-space-4);
551 + background: #fff;
552 + border-radius: var(--sa-radius-md);
553 + box-shadow: var(--sa-shadow-sm);
554 + border: 1px solid var(--sa-gray-100);
555 + text-decoration: none;
556 + color: var(--sa-gray-700);
557 + transition: all var(--sa-transition-normal);
558 + text-align: center;
559 +}
560 +
561 +.sa-nav-card:hover {
562 + transform: translateY(-3px);
563 + box-shadow: var(--sa-shadow-md);
564 + color: var(--sa-gray-900);
565 + text-decoration: none;
566 +}
567 +
568 +.sa-nav-card-icon {
569 + width: 48px;
570 + height: 48px;
571 + border-radius: var(--sa-radius-md);
572 + display: flex;
573 + align-items: center;
574 + justify-content: center;
575 + font-size: 1.4rem;
576 +}
577 +
578 +.sa-nav-card-label {
579 + font-weight: 600;
580 + font-size: 0.875rem;
581 +}
582 +
583 +.sa-nav-card-count {
584 + font-size: 0.75rem;
585 + color: var(--sa-gray-500);
586 +}
587 +
588 +/* Nav card icon themes */
589 +.sa-nav-icon-expenses { background: rgba(232, 96, 76, 0.1); color: var(--sa-primary); }
590 +.sa-nav-icon-members { background: rgba(99, 102, 241, 0.1); color: #6366f1; }
591 +.sa-nav-icon-budget { background: rgba(34, 197, 94, 0.1); color: var(--sa-success); }
592 +.sa-nav-icon-wishlist { background: rgba(244, 166, 35, 0.1); color: var(--sa-accent); }
593 +.sa-nav-icon-polls { background: rgba(59, 130, 246, 0.1); color: var(--sa-info); }
594 +.sa-nav-icon-settlement { background: rgba(26, 158, 143, 0.1); color: var(--sa-secondary); }
595 +
596 +/* ---------- Floating Action Button ---------- */
597 +.sa-fab {
598 + position: fixed;
599 + bottom: 24px;
600 + right: 24px;
601 + width: 56px;
602 + height: 56px;
603 + border-radius: 50%;
604 + background: var(--sa-primary-gradient);
605 + color: #fff;
606 + display: flex;
607 + align-items: center;
608 + justify-content: center;
609 + font-size: 1.5rem;
610 + border: none;
611 + cursor: pointer;
612 + box-shadow: 0 4px 16px rgba(232, 96, 76, 0.4);
613 + transition: all var(--sa-transition-normal);
614 + z-index: 1000;
615 + text-decoration: none;
616 +}
617 +
618 +.sa-fab:hover {
619 + transform: scale(1.1);
620 + box-shadow: 0 6px 24px rgba(232, 96, 76, 0.5);
621 + color: #fff;
622 +}
623 +
624 +@media (min-width: 768px) {
625 + .sa-fab {
626 + display: none;
627 + }
628 +}
629 +
630 +/* ---------- Amount Display ---------- */
631 +.sa-amount {
632 + font-variant-numeric: tabular-nums;
633 + font-weight: 700;
634 +}
635 +
636 +.sa-amount-lg {
637 + font-size: 2rem;
638 + letter-spacing: -0.02em;
639 +}
640 +
641 +.sa-amount-positive { color: var(--sa-success); }
642 +.sa-amount-negative { color: var(--sa-danger); }
643 +
644 +/* ---------- Donut Chart (CSS) ---------- */
645 +.sa-donut {
646 + width: 100px;
647 + height: 100px;
648 + border-radius: 50%;
649 + display: flex;
650 + align-items: center;
651 + justify-content: center;
652 + position: relative;
653 + margin: 0 auto;
654 +}
655 +
656 +.sa-donut-label {
657 + font-weight: 800;
658 + font-size: 1.1rem;
659 + z-index: 1;
660 +}
661 +
662 +/* ---------- Balance Bar ---------- */
663 +.sa-balance-bar-container {
664 + display: flex;
665 + align-items: center;
666 + gap: 8px;
667 + height: 28px;
668 +}
669 +
670 +.sa-balance-bar-track {
671 + flex: 1;
672 + display: flex;
673 + align-items: center;
674 + justify-content: center;
675 + position: relative;
676 + height: 8px;
677 + background: var(--sa-gray-100);
678 + border-radius: var(--sa-radius-full);
679 +}
680 +
681 +.sa-balance-bar-fill {
682 + position: absolute;
683 + height: 100%;
684 + border-radius: var(--sa-radius-full);
685 + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
686 +}
687 +
688 +.sa-balance-bar-positive {
689 + right: 50%;
690 + left: auto;
691 + background: var(--sa-success);
692 +}
693 +
694 +.sa-balance-bar-negative {
695 + left: 50%;
696 + right: auto;
697 + background: var(--sa-danger);
698 +}
699 +
700 +.sa-balance-bar-center {
701 + position: absolute;
702 + width: 2px;
703 + height: 16px;
704 + background: var(--sa-gray-400);
705 + left: 50%;
706 + transform: translateX(-50%);
707 +}
708 +
709 +/* ---------- Settlement Flow Card ---------- */
710 +.sa-settlement-card {
711 + display: flex;
712 + align-items: center;
713 + gap: var(--sa-space-4);
714 + padding: var(--sa-space-4) var(--sa-space-5);
715 + background: #fff;
716 + border-radius: var(--sa-radius-md);
717 + box-shadow: var(--sa-shadow-sm);
718 + border: 1px solid var(--sa-gray-100);
719 +}
720 +
721 +.sa-settlement-arrow {
722 + color: var(--sa-gray-400);
723 + font-size: 1.2rem;
724 + flex-shrink: 0;
725 +}
726 +
727 +.sa-settlement-amount {
728 + font-size: 1.1rem;
729 + font-weight: 700;
730 + color: var(--sa-gray-900);
731 + flex-shrink: 0;
732 +}
733 +
734 +/* Status dots */
735 +.sa-status-dot {
736 + width: 10px;
737 + height: 10px;
738 + border-radius: 50%;
739 + display: inline-block;
740 +}
741 +
742 +.sa-status-dot-pending { background: var(--sa-gray-300); }
743 +.sa-status-dot-active { background: var(--sa-success); }
744 +.sa-status-dot-warning { background: var(--sa-warning); }
745 +.sa-status-dot-pulse {
746 + animation: sa-pulse 2s ease-in-out infinite;
747 +}
748 +
749 +/* ---------- Toggle Switch ---------- */
750 +.sa-toggle {
751 + position: relative;
752 + width: 44px;
753 + height: 24px;
754 + appearance: none;
755 + -webkit-appearance: none;
756 + background: var(--sa-gray-300);
757 + border-radius: var(--sa-radius-full);
758 + outline: none;
759 + cursor: pointer;
760 + transition: background var(--sa-transition-fast);
761 +}
762 +
763 +.sa-toggle:checked {
764 + background: var(--sa-secondary);
765 +}
766 +
767 +.sa-toggle::before {
768 + content: '';
769 + position: absolute;
770 + top: 2px;
771 + left: 2px;
772 + width: 20px;
773 + height: 20px;
774 + border-radius: 50%;
775 + background: #fff;
776 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
777 + transition: transform var(--sa-transition-fast);
778 +}
779 +
780 +.sa-toggle:checked::before {
781 + transform: translateX(20px);
782 +}
783 +
784 +/* ---------- Icon Picker ---------- */
785 +.sa-icon-picker {
786 + display: grid;
787 + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
788 + gap: 8px;
789 +}
790 +
791 +.sa-icon-option {
792 + width: 48px;
793 + height: 48px;
794 + display: flex;
795 + align-items: center;
796 + justify-content: center;
797 + border: 2px solid var(--sa-gray-200);
798 + border-radius: var(--sa-radius-sm);
799 + cursor: pointer;
800 + transition: all var(--sa-transition-fast);
801 + font-size: 1.2rem;
802 + color: var(--sa-gray-600);
803 + background: #fff;
804 +}
805 +
806 +.sa-icon-option:hover {
807 + border-color: var(--sa-secondary-light);
808 + color: var(--sa-secondary);
809 +}
810 +
811 +.sa-icon-option.active,
812 +.sa-icon-option input:checked + & {
813 + border-color: var(--sa-secondary);
814 + background: rgba(26, 158, 143, 0.08);
815 + color: var(--sa-secondary);
816 +}
817 +
818 +/* ---------- Toast ---------- */
819 +.sa-toast-container {
820 + position: fixed;
821 + top: 20px;
822 + right: 20px;
823 + z-index: 9999;
824 + display: flex;
825 + flex-direction: column;
826 + gap: 8px;
827 + pointer-events: none;
828 +}
829 +
830 +.sa-toast {
831 + display: flex;
832 + align-items: center;
833 + gap: 10px;
834 + padding: 12px 20px;
835 + background: #fff;
836 + border-radius: var(--sa-radius-md);
837 + box-shadow: var(--sa-shadow-lg);
838 + border-left: 4px solid var(--sa-secondary);
839 + font-size: 0.875rem;
840 + font-weight: 500;
841 + pointer-events: auto;
842 + animation: sa-slide-in-right 0.3s ease-out;
843 + max-width: 380px;
844 +}
845 +
846 +.sa-toast-success { border-left-color: var(--sa-success); }
847 +.sa-toast-error { border-left-color: var(--sa-danger); }
848 +.sa-toast-warning { border-left-color: var(--sa-warning); }
849 +
850 +.sa-toast-dismiss {
851 + animation: sa-fade-out 0.3s ease-in forwards;
852 +}
853 +
854 +/* ---------- Expense List Item ---------- */
855 +.sa-expense-item {
856 + display: flex;
857 + align-items: center;
858 + gap: var(--sa-space-4);
859 + padding: var(--sa-space-4) var(--sa-space-5);
860 + border-bottom: 1px solid var(--sa-gray-100);
861 + transition: background var(--sa-transition-fast);
862 +}
863 +
864 +.sa-expense-item:last-child {
865 + border-bottom: none;
866 +}
867 +
868 +.sa-expense-item:hover {
869 + background: var(--sa-gray-50);
870 +}
871 +
872 +.sa-expense-icon {
873 + width: 40px;
874 + height: 40px;
875 + border-radius: var(--sa-radius-sm);
876 + display: flex;
877 + align-items: center;
878 + justify-content: center;
879 + font-size: 1rem;
880 + flex-shrink: 0;
881 +}
882 +
883 +.sa-expense-details {
884 + flex: 1;
885 + min-width: 0;
886 +}
887 +
888 +.sa-expense-desc {
889 + font-weight: 600;
890 + font-size: 0.938rem;
891 + color: var(--sa-gray-800);
892 + white-space: nowrap;
893 + overflow: hidden;
894 + text-overflow: ellipsis;
895 +}
896 +
897 +.sa-expense-meta {
898 + font-size: 0.8rem;
899 + color: var(--sa-gray-500);
900 + margin-top: 2px;
901 +}
902 +
903 +.sa-expense-amount {
904 + font-weight: 700;
905 + font-size: 1rem;
906 + color: var(--sa-gray-900);
907 + text-align: right;
908 + flex-shrink: 0;
909 +}
910 +
911 +.sa-expense-actions {
912 + display: flex;
913 + gap: 4px;
914 + opacity: 0;
915 + transition: opacity var(--sa-transition-fast);
916 +}
917 +
918 +.sa-expense-item:hover .sa-expense-actions {
919 + opacity: 1;
920 +}
921 +
922 +/* ---------- Split Method Cards ---------- */
923 +.sa-split-methods {
924 + display: grid;
925 + grid-template-columns: repeat(2, 1fr);
926 + gap: 8px;
927 +}
928 +
929 +@media (min-width: 576px) {
930 + .sa-split-methods {
931 + grid-template-columns: repeat(4, 1fr);
932 + }
933 +}
934 +
935 +.sa-split-option {
936 + position: relative;
937 + cursor: pointer;
938 +}
939 +
940 +.sa-split-option input {
941 + position: absolute;
942 + opacity: 0;
943 + width: 0;
944 + height: 0;
945 +}
946 +
947 +.sa-split-option-label {
948 + display: flex;
949 + flex-direction: column;
950 + align-items: center;
951 + gap: 6px;
952 + padding: var(--sa-space-4) var(--sa-space-3);
953 + border: 2px solid var(--sa-gray-200);
954 + border-radius: var(--sa-radius-md);
955 + text-align: center;
956 + transition: all var(--sa-transition-fast);
957 + background: #fff;
958 +}
959 +
960 +.sa-split-option input:checked + .sa-split-option-label {
961 + border-color: var(--sa-secondary);
962 + background: rgba(26, 158, 143, 0.06);
963 + box-shadow: 0 0 0 1px var(--sa-secondary);
964 +}
965 +
966 +.sa-split-option-icon {
967 + font-size: 1.4rem;
968 + color: var(--sa-gray-500);
969 +}
970 +
971 +.sa-split-option input:checked + .sa-split-option-label .sa-split-option-icon {
972 + color: var(--sa-secondary);
973 +}
974 +
975 +.sa-split-option-text {
976 + font-size: 0.75rem;
977 + font-weight: 600;
978 + color: var(--sa-gray-600);
979 +}
980 +
981 +/* ---------- Poll Option Card ---------- */
982 +.sa-poll-option {
983 + border: 2px solid var(--sa-gray-200);
984 + border-radius: var(--sa-radius-md);
985 + padding: var(--sa-space-4) var(--sa-space-5);
986 + margin-bottom: var(--sa-space-3);
987 + transition: all var(--sa-transition-fast);
988 + background: #fff;
989 +}
990 +
991 +.sa-poll-option-voted {
992 + border-color: var(--sa-secondary);
993 + background: rgba(26, 158, 143, 0.03);
994 +}
995 +
996 +.sa-poll-option-winner {
997 + border-color: var(--sa-accent);
998 + background: rgba(244, 166, 35, 0.05);
999 +}
1000 +
1001 +/* ---------- Wishlist Vote Button ---------- */
1002 +.sa-vote-btn {
1003 + display: inline-flex;
1004 + align-items: center;
1005 + gap: 6px;
1006 + padding: 6px 14px;
1007 + border: 1.5px solid var(--sa-gray-300);
1008 + border-radius: var(--sa-radius-full);
1009 + background: #fff;
1010 + color: var(--sa-gray-600);
1011 + font-size: 0.85rem;
1012 + font-weight: 600;
1013 + cursor: pointer;
1014 + transition: all var(--sa-transition-fast);
1015 +}
1016 +
1017 +.sa-vote-btn:hover {
1018 + border-color: var(--sa-primary);
1019 + color: var(--sa-primary);
1020 +}
1021 +
1022 +.sa-vote-btn-active {
1023 + border-color: var(--sa-primary);
1024 + background: rgba(232, 96, 76, 0.08);
1025 + color: var(--sa-primary);
1026 +}
1027 +
1028 +/* ---------- Navbar ---------- */
1029 +.sa-navbar {
1030 + position: sticky;
1031 + top: 0;
1032 + z-index: 1050;
1033 + background: rgba(255, 255, 255, 0.85);
1034 + backdrop-filter: blur(12px);
1035 + -webkit-backdrop-filter: blur(12px);
1036 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
1037 + padding: 0 var(--sa-space-4);
1038 +}
1039 +
1040 +.sa-navbar-brand {
1041 + font-weight: 800;
1042 + font-size: 1.25rem;
1043 + color: var(--sa-primary) !important;
1044 + display: flex;
1045 + align-items: center;
1046 + gap: 8px;
1047 + text-decoration: none;
1048 +}
1049 +
1050 +.sa-navbar-brand:hover {
1051 + color: var(--sa-primary-dark) !important;
1052 +}
1053 +
1054 +.sa-navbar .nav-link {
1055 + font-weight: 500;
1056 + color: var(--sa-gray-600) !important;
1057 + padding: 8px 16px !important;
1058 + border-radius: var(--sa-radius-sm);
1059 + transition: all var(--sa-transition-fast);
1060 +}
1061 +
1062 +.sa-navbar .nav-link:hover,
1063 +.sa-navbar .nav-link.active {
1064 + color: var(--sa-gray-900) !important;
1065 + background: var(--sa-gray-100);
1066 +}
1067 +
1068 +.sa-nav-avatar-btn {
1069 + display: flex;
1070 + align-items: center;
1071 + gap: 8px;
1072 + padding: 4px 12px 4px 4px;
1073 + background: var(--sa-gray-100);
1074 + border-radius: var(--sa-radius-full);
1075 + border: none;
1076 + cursor: pointer;
1077 + font-size: 0.875rem;
1078 + font-weight: 500;
1079 + color: var(--sa-gray-700);
1080 + transition: background var(--sa-transition-fast);
1081 +}
1082 +
1083 +.sa-nav-avatar-btn:hover {
1084 + background: var(--sa-gray-200);
1085 +}
1086 +
1087 +.sa-navbar-auth-btns {
1088 + display: flex;
1089 + gap: 8px;
1090 + align-items: center;
1091 +}
1092 +
1093 +/* ---------- Footer ---------- */
1094 +.sa-footer {
1095 + background: var(--sa-gray-800);
1096 + color: var(--sa-gray-400);
1097 + padding: var(--sa-space-6) 0;
1098 + font-size: 0.85rem;
1099 + margin-top: var(--sa-space-16);
1100 +}
1101 +
1102 +.sa-footer a {
1103 + color: var(--sa-gray-300);
1104 +}
1105 +
1106 +.sa-footer a:hover {
1107 + color: #fff;
1108 +}
1109 +
1110 +/* ---------- Hero Section ---------- */
1111 +.sa-hero {
1112 + text-align: center;
1113 + padding: var(--sa-space-16) var(--sa-space-4);
1114 + background: var(--sa-primary-gradient);
1115 + color: #fff;
1116 + margin: -1rem calc(-1 * (50vw - 50%)) var(--sa-space-12);
1117 + padding-left: calc(50vw - 50%);
1118 + padding-right: calc(50vw - 50%);
1119 +}
1120 +
1121 +.sa-hero h1 {
1122 + color: #fff;
1123 + font-size: 2.75rem;
1124 + font-weight: 800;
1125 + letter-spacing: -0.03em;
1126 + margin-bottom: var(--sa-space-4);
1127 +}
1128 +
1129 +.sa-hero p {
1130 + font-size: 1.2rem;
1131 + opacity: 0.9;
1132 + max-width: 540px;
1133 + margin: 0 auto var(--sa-space-8);
1134 +}
1135 +
1136 +.sa-hero-btns {
1137 + display: flex;
1138 + gap: 12px;
1139 + justify-content: center;
1140 + flex-wrap: wrap;
1141 +}
1142 +
1143 +.sa-hero-btn-white {
1144 + background: #fff;
1145 + color: var(--sa-primary);
1146 + font-weight: 700;
1147 + padding: 12px 28px;
1148 + border-radius: var(--sa-radius-full);
1149 + border: none;
1150 + font-size: 1rem;
1151 + transition: all var(--sa-transition-fast);
1152 + text-decoration: none;
1153 + display: inline-flex;
1154 + align-items: center;
1155 + gap: 8px;
1156 +}
1157 +
1158 +.sa-hero-btn-white:hover {
1159 + transform: translateY(-2px);
1160 + box-shadow: 0 4px 16px rgba(0,0,0,0.15);
1161 + color: var(--sa-primary);
1162 +}
1163 +
1164 +.sa-hero-btn-outline {
1165 + background: transparent;
1166 + color: #fff;
1167 + font-weight: 600;
1168 + padding: 12px 28px;
1169 + border-radius: var(--sa-radius-full);
1170 + border: 2px solid rgba(255,255,255,0.4);
1171 + font-size: 1rem;
1172 + transition: all var(--sa-transition-fast);
1173 + text-decoration: none;
1174 + display: inline-flex;
1175 + align-items: center;
1176 + gap: 8px;
1177 +}
1178 +
1179 +.sa-hero-btn-outline:hover {
1180 + background: rgba(255,255,255,0.15);
1181 + border-color: rgba(255,255,255,0.7);
1182 + color: #fff;
1183 +}
1184 +
1185 +@media (max-width: 767px) {
1186 + .sa-hero h1 { font-size: 2rem; }
1187 + .sa-hero { padding-top: var(--sa-space-10); padding-bottom: var(--sa-space-10); }
1188 +}
1189 +
1190 +/* ---------- Feature Cards ---------- */
1191 +.sa-feature-card {
1192 + text-align: center;
1193 + padding: var(--sa-space-8) var(--sa-space-6);
1194 +}
1195 +
1196 +.sa-feature-icon {
1197 + width: 64px;
1198 + height: 64px;
1199 + border-radius: var(--sa-radius-lg);
1200 + display: flex;
1201 + align-items: center;
1202 + justify-content: center;
1203 + font-size: 1.75rem;
1204 + margin: 0 auto var(--sa-space-4);
1205 +}
1206 +
1207 +.sa-feature-title {
1208 + font-size: 1.1rem;
1209 + font-weight: 700;
1210 + margin-bottom: var(--sa-space-2);
1211 +}
1212 +
1213 +.sa-feature-text {
1214 + font-size: 0.938rem;
1215 + color: var(--sa-gray-500);
1216 + line-height: 1.6;
1217 +}
1218 +
1219 +/* ---------- Step Flow ---------- */
1220 +.sa-steps {
1221 + display: flex;
1222 + gap: var(--sa-space-6);
1223 + align-items: flex-start;
1224 + justify-content: center;
1225 + flex-wrap: wrap;
1226 +}
1227 +
1228 +.sa-step {
1229 + flex: 1;
1230 + min-width: 200px;
1231 + max-width: 280px;
1232 + text-align: center;
1233 +}
1234 +
1235 +.sa-step-number {
1236 + width: 48px;
1237 + height: 48px;
1238 + border-radius: 50%;
1239 + background: var(--sa-secondary-gradient);
1240 + color: #fff;
1241 + font-weight: 800;
1242 + font-size: 1.25rem;
1243 + display: flex;
1244 + align-items: center;
1245 + justify-content: center;
1246 + margin: 0 auto var(--sa-space-4);
1247 +}
1248 +
1249 +.sa-step-title {
1250 + font-weight: 700;
1251 + margin-bottom: var(--sa-space-2);
1252 +}
1253 +
1254 +.sa-step-text {
1255 + font-size: 0.875rem;
1256 + color: var(--sa-gray-500);
1257 +}
1258 +
1259 +/* ---------- Confirmation Card ---------- */
1260 +.sa-confirm-card {
1261 + max-width: 480px;
1262 + margin: var(--sa-space-8) auto;
1263 +}
1264 +
1265 +.sa-confirm-icon {
1266 + width: 64px;
1267 + height: 64px;
1268 + border-radius: 50%;
1269 + display: flex;
1270 + align-items: center;
1271 + justify-content: center;
1272 + font-size: 1.75rem;
1273 + margin: 0 auto var(--sa-space-4);
1274 +}
1275 +
1276 +.sa-confirm-icon-danger {
1277 + background: var(--sa-danger-light);
1278 + color: var(--sa-danger);
1279 +}
1280 +
1281 +.sa-confirm-icon-success {
1282 + background: var(--sa-success-light);
1283 + color: var(--sa-success);
1284 +}
1285 +
1286 +.sa-confirm-icon-warning {
1287 + background: var(--sa-warning-light);
1288 + color: var(--sa-warning);
1289 +}
1290 +
1291 +/* ---------- Big Amount Input ---------- */
1292 +.sa-amount-input {
1293 + font-size: 2.5rem;
1294 + font-weight: 800;
1295 + text-align: center;
1296 + border: none;
1297 + border-bottom: 3px solid var(--sa-gray-200);
1298 + border-radius: 0;
1299 + padding: var(--sa-space-4);
1300 + background: transparent;
1301 + letter-spacing: -0.02em;
1302 +}
1303 +
1304 +.sa-amount-input:focus {
1305 + border-color: var(--sa-secondary);
1306 + box-shadow: none;
1307 + outline: none;
1308 +}
1309 +
1310 +.sa-amount-input::placeholder {
1311 + color: var(--sa-gray-300);
1312 +}
1313 +
1314 +/* ---------- Language Pills ---------- */
1315 +.sa-lang-pills {
1316 + display: flex;
1317 + gap: 4px;
1318 +}
1319 +
1320 +.sa-lang-pill {
1321 + padding: 4px 10px;
1322 + font-size: 0.75rem;
1323 + font-weight: 600;
1324 + border-radius: var(--sa-radius-full);
1325 + border: 1.5px solid var(--sa-gray-200);
1326 + background: transparent;
1327 + color: var(--sa-gray-500);
1328 + cursor: pointer;
1329 + transition: all var(--sa-transition-fast);
1330 + text-decoration: none;
1331 + text-transform: uppercase;
1332 +}
1333 +
1334 +.sa-lang-pill:hover,
1335 +.sa-lang-pill.active {
1336 + border-color: var(--sa-secondary);
1337 + color: var(--sa-secondary);
1338 + background: rgba(26, 158, 143, 0.06);
1339 +}
1340 +
1341 +/* ---------- Animations ---------- */
1342 +@keyframes sa-fade-in {
1343 + from { opacity: 0; }
1344 + to { opacity: 1; }
1345 +}
1346 +
1347 +@keyframes sa-slide-up {
1348 + from { opacity: 0; transform: translateY(20px); }
1349 + to { opacity: 1; transform: translateY(0); }
1350 +}
1351 +
1352 +@keyframes sa-slide-in-right {
1353 + from { opacity: 0; transform: translateX(100px); }
1354 + to { opacity: 1; transform: translateX(0); }
1355 +}
1356 +
1357 +@keyframes sa-fade-out {
1358 + from { opacity: 1; transform: translateX(0); }
1359 + to { opacity: 0; transform: translateX(100px); }
1360 +}
1361 +
1362 +@keyframes sa-scale-in {
1363 + from { opacity: 0; transform: scale(0.9); }
1364 + to { opacity: 1; transform: scale(1); }
1365 +}
1366 +
1367 +@keyframes sa-spin {
1368 + to { transform: rotate(360deg); }
1369 +}
1370 +
1371 +@keyframes sa-pulse {
1372 + 0%, 100% { opacity: 1; }
1373 + 50% { opacity: 0.5; }
1374 +}
1375 +
1376 +@keyframes sa-bounce-in {
1377 + 0% { transform: scale(0); }
1378 + 50% { transform: scale(1.15); }
1379 + 100% { transform: scale(1); }
1380 +}
1381 +
1382 +@keyframes sa-progress-fill {
1383 + from { width: 0; }
1384 +}
1385 +
1386 +.sa-animate-fade-in { animation: sa-fade-in 0.5s ease-out; }
1387 +.sa-animate-slide-up { animation: sa-slide-up 0.5s ease-out both; }
1388 +.sa-animate-scale-in { animation: sa-scale-in 0.4s ease-out both; }
1389 +.sa-animate-bounce-in { animation: sa-bounce-in 0.5s ease-out both; }
1390 +
1391 +/* Stagger delays */
1392 +.sa-stagger-1 { animation-delay: 0.05s; }
1393 +.sa-stagger-2 { animation-delay: 0.1s; }
1394 +.sa-stagger-3 { animation-delay: 0.15s; }
1395 +.sa-stagger-4 { animation-delay: 0.2s; }
1396 +.sa-stagger-5 { animation-delay: 0.25s; }
1397 +.sa-stagger-6 { animation-delay: 0.3s; }
1398 +
1399 +/* Scroll-triggered */
1400 +.sa-animate-on-scroll {
1401 + opacity: 0;
1402 + transform: translateY(20px);
1403 + transition: opacity 0.6s ease-out, transform 0.6s ease-out;
1404 +}
1405 +
1406 +.sa-animate-on-scroll.sa-visible {
1407 + opacity: 1;
1408 + transform: translateY(0);
1409 +}
1410 +
1411 +/* ---------- Utility Classes ---------- */
1412 +.sa-text-gradient {
1413 + background: var(--sa-primary-gradient);
1414 + -webkit-background-clip: text;
1415 + -webkit-text-fill-color: transparent;
1416 + background-clip: text;
1417 +}
1418 +
1419 +.sa-text-primary { color: var(--sa-primary); }
1420 +.sa-text-secondary { color: var(--sa-secondary); }
1421 +.sa-text-accent { color: var(--sa-accent); }
1422 +.sa-text-muted { color: var(--sa-gray-500); }
1423 +
1424 +.sa-bg-soft-primary { background: rgba(232, 96, 76, 0.08); }
1425 +.sa-bg-soft-secondary { background: rgba(26, 158, 143, 0.08); }
1426 +.sa-bg-soft-success { background: var(--sa-success-light); }
1427 +.sa-bg-soft-danger { background: var(--sa-danger-light); }
1428 +.sa-bg-soft-warning { background: var(--sa-warning-light); }
1429 +
1430 +.sa-rounded { border-radius: var(--sa-radius-md); }
1431 +.sa-rounded-lg { border-radius: var(--sa-radius-lg); }
1432 +
1433 +.sa-shadow { box-shadow: var(--sa-shadow-md); }
1434 +.sa-shadow-lg { box-shadow: var(--sa-shadow-lg); }
1435 +
1436 +.sa-divider {
1437 + height: 1px;
1438 + background: var(--sa-gray-100);
1439 + margin: var(--sa-space-4) 0;
1440 +}
1441 +
1442 +.sa-truncate {
1443 + white-space: nowrap;
1444 + overflow: hidden;
1445 + text-overflow: ellipsis;
1446 +}
1447 +
1448 +/* ---------- Category Colors ---------- */
1449 +.sa-category-food { background: #fee2e2; color: #dc2626; }
1450 +.sa-category-accommodation { background: #dbeafe; color: #2563eb; }
1451 +.sa-category-transport { background: #fef3c7; color: #d97706; }
1452 +.sa-category-activities { background: #dcfce7; color: #16a34a; }
1453 +.sa-category-shopping { background: #f3e8ff; color: #7c3aed; }
1454 +.sa-category-default { background: var(--sa-gray-100); color: var(--sa-gray-600); }
1455 +
1456 +/* ---------- Responsive Helpers ---------- */
1457 +@media (max-width: 575px) {
1458 + .sa-hide-mobile { display: none !important; }
1459 + .sa-gradient-header {
1460 + padding-top: var(--sa-space-6);
1461 + padding-bottom: var(--sa-space-6);
1462 + }
1463 + h1 { font-size: 1.5rem; }
1464 + .sa-stat-value { font-size: 1.4rem; }
1465 +}
1466 +
1467 +@media (min-width: 576px) {
1468 + .sa-hide-desktop { display: none !important; }
1469 +}
1470 +
1471 +/* ---------- Dropdown override ---------- */
1472 +.dropdown-menu {
1473 + border: 1px solid var(--sa-gray-100);
1474 + border-radius: var(--sa-radius-md);
1475 + box-shadow: var(--sa-shadow-lg);
1476 + padding: 6px;
1477 +}
1478 +
1479 +.dropdown-item {
1480 + border-radius: var(--sa-radius-sm);
1481 + padding: 8px 14px;
1482 + font-size: 0.875rem;
1483 + font-weight: 500;
1484 + transition: background var(--sa-transition-fast);
1485 +}
1486 +
1487 +.dropdown-item:hover {
1488 + background: var(--sa-gray-100);
1489 +}
1490 +
1491 +/* ---------- Table Override ---------- */
1492 +.sa-table {
1493 + width: 100%;
1494 + border-collapse: separate;
1495 + border-spacing: 0;
1496 +}
1497 +
1498 +.sa-table thead th {
1499 + font-size: 0.75rem;
1500 + font-weight: 600;
1501 + text-transform: uppercase;
1502 + letter-spacing: 0.06em;
1503 + color: var(--sa-gray-500);
1504 + border-bottom: 2px solid var(--sa-gray-100);
1505 + padding: var(--sa-space-3) var(--sa-space-4);
1506 +}
1507 +
1508 +.sa-table tbody td {
1509 + padding: var(--sa-space-3) var(--sa-space-4);
1510 + border-bottom: 1px solid var(--sa-gray-100);
1511 + font-size: 0.938rem;
1512 +}
1513 +
1514 +.sa-table tbody tr:hover {
1515 + background: var(--sa-gray-50);
1516 +}
1517 +
1518 +.sa-table tbody tr:last-child td {
1519 + border-bottom: none;
1520 +}
1521 +
1522 +/* ---------- Trip Card ---------- */
1523 +.sa-trip-card {
1524 + overflow: hidden;
1525 +}
1526 +
1527 +.sa-trip-card-gradient {
1528 + height: 6px;
1529 +}
1530 +
1531 +/* Generate variety with nth-child */
1532 +.sa-trip-card:nth-child(6n+1) .sa-trip-card-gradient { background: var(--sa-primary-gradient); }
1533 +.sa-trip-card:nth-child(6n+2) .sa-trip-card-gradient { background: var(--sa-secondary-gradient); }
1534 +.sa-trip-card:nth-child(6n+3) .sa-trip-card-gradient { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
1535 +.sa-trip-card:nth-child(6n+4) .sa-trip-card-gradient { background: linear-gradient(135deg, var(--sa-accent), #f97316); }
1536 +.sa-trip-card:nth-child(6n+5) .sa-trip-card-gradient { background: linear-gradient(135deg, #ec4899, #f43f5e); }
1537 +.sa-trip-card:nth-child(6n+6) .sa-trip-card-gradient { background: linear-gradient(135deg, #14b8a6, #06b6d4); }
1538 +
1539 +/* ---------- Invitation / Accept page ---------- */
1540 +.sa-invite-page {
1541 + min-height: 80vh;
1542 + display: flex;
1543 + align-items: center;
1544 + justify-content: center;
1545 +}
1546 +
1547 +.sa-invite-card {
1548 + max-width: 440px;
1549 + width: 100%;
1550 + text-align: center;
1551 +}
1552 +
1553 +.sa-invite-icon {
1554 + font-size: 3rem;
1555 + margin-bottom: var(--sa-space-4);
1556 +}
1557 +
1558 +/* Copy link input */
1559 +.sa-copy-group {
1560 + display: flex;
1561 + gap: 0;
1562 +}
1563 +
1564 +.sa-copy-input {
1565 + flex: 1;
1566 + border-radius: var(--sa-radius-sm) 0 0 var(--sa-radius-sm);
1567 + font-family: var(--sa-font-mono);
1568 + font-size: 0.85rem;
1569 +}
1570 +
1571 +.sa-copy-btn {
1572 + border-radius: 0 var(--sa-radius-sm) var(--sa-radius-sm) 0;
1573 + white-space: nowrap;
1574 +}
1575 +
1576 +/* ---------- Participant Chip ---------- */
1577 +.sa-participant-chip {
1578 + display: inline-flex;
1579 + align-items: center;
1580 + gap: 8px;
1581 + padding: 4px 12px 4px 4px;
1582 + background: var(--sa-gray-100);
1583 + border-radius: var(--sa-radius-full);
1584 + font-size: 0.85rem;
1585 + font-weight: 500;
1586 + cursor: pointer;
1587 + transition: all var(--sa-transition-fast);
1588 + border: 2px solid transparent;
1589 +}
1590 +
1591 +.sa-participant-chip.active {
1592 + border-color: var(--sa-secondary);
1593 + background: rgba(26, 158, 143, 0.08);
1594 +}
1595 +
1596 +.sa-participant-chip:hover {
1597 + background: var(--sa-gray-200);
1598 +}
1599 +
1600 +/* ---------- Print ---------- */
1601 +@media print {
1602 + .sa-navbar, .sa-footer, .sa-fab, .sa-toast-container { display: none !important; }
1603 + .sa-card, .sa-card-static { box-shadow: none; border: 1px solid #ddd; }
1604 + body { background: #fff; }
1605 +}
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/favicon.ico +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/site.js +4 −0
@@ -0,0 +1,4 @@
1 +// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 +// for details on configuring this project to bundle and minify static web assets.
3 +
4 +// Write your JavaScript code.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/splitapp.js +323 −0
@@ -0,0 +1,323 @@
1 +/* ============================================================
2 + SplitApp Interactive Utilities
3 + Vanilla JS enhancements for premium UX
4 + ============================================================ */
5 +
6 +const SplitApp = {
7 + /* ---------- Toast Notifications ---------- */
8 + toast(message, type = 'success', duration = 4000) {
9 + const container = document.getElementById('sa-toast-container');
10 + if (!container) return;
11 +
12 + const toast = document.createElement('div');
13 + toast.className = `sa-toast sa-toast-${type}`;
14 +
15 + const icons = {
16 + success: 'bi-check-circle-fill',
17 + error: 'bi-exclamation-circle-fill',
18 + warning: 'bi-exclamation-triangle-fill',
19 + info: 'bi-info-circle-fill'
20 + };
21 +
22 + toast.innerHTML = `
23 + <i class="bi ${icons[type] || icons.success}" style="font-size:1.1rem"></i>
24 + <span>${message}</span>
25 + `;
26 +
27 + container.appendChild(toast);
28 +
29 + setTimeout(() => {
30 + toast.classList.add('sa-toast-dismiss');
31 + setTimeout(() => toast.remove(), 300);
32 + }, duration);
33 + },
34 +
35 + /* ---------- Copy to Clipboard ---------- */
36 + async copyToClipboard(text, button) {
37 + try {
38 + await navigator.clipboard.writeText(text);
39 + if (button) {
40 + const original = button.innerHTML;
41 + button.innerHTML = '<i class="bi bi-check2"></i> Copied!';
42 + button.classList.add('sa-btn-success');
43 + setTimeout(() => {
44 + button.innerHTML = original;
45 + button.classList.remove('sa-btn-success');
46 + }, 2000);
47 + }
48 + SplitApp.toast('Copied to clipboard!', 'success', 2000);
49 + } catch {
50 + // Fallback
51 + const ta = document.createElement('textarea');
52 + ta.value = text;
53 + ta.style.position = 'fixed';
54 + ta.style.opacity = '0';
55 + document.body.appendChild(ta);
56 + ta.select();
57 + document.execCommand('copy');
58 + document.body.removeChild(ta);
59 + if (button) {
60 + const original = button.innerHTML;
61 + button.innerHTML = '<i class="bi bi-check2"></i> Copied!';
62 + setTimeout(() => { button.innerHTML = original; }, 2000);
63 + }
64 + }
65 + },
66 +
67 + /* ---------- Form Loading States ---------- */
68 + initFormLoading() {
69 + document.querySelectorAll('form[data-sa-loading]').forEach(form => {
70 + form.addEventListener('submit', (e) => {
71 + // Check if jQuery validation exists and if form is invalid
72 + if (typeof $ !== 'undefined' && $(form).valid && !$(form).valid()) {
73 + return;
74 + }
75 + const btn = form.querySelector('button[type="submit"], input[type="submit"]');
76 + if (btn && !btn.disabled) {
77 + btn.disabled = true;
78 + btn.classList.add('sa-btn-loading');
79 + const text = btn.textContent;
80 + btn.dataset.originalText = text;
81 + }
82 + });
83 + });
84 + },
85 +
86 + /* ---------- Scroll Animations ---------- */
87 + initScrollAnimations() {
88 + const observer = new IntersectionObserver((entries) => {
89 + entries.forEach(entry => {
90 + if (entry.isIntersecting) {
91 + entry.target.classList.add('sa-visible');
92 + observer.unobserve(entry.target);
93 + }
94 + });
95 + }, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
96 +
97 + document.querySelectorAll('.sa-animate-on-scroll').forEach(el => {
98 + observer.observe(el);
99 + });
100 + },
101 +
102 + /* ---------- Animated Progress Bars ---------- */
103 + initProgressBars() {
104 + const observer = new IntersectionObserver((entries) => {
105 + entries.forEach(entry => {
106 + if (entry.isIntersecting) {
107 + const bar = entry.target;
108 + const target = bar.dataset.width;
109 + if (target) {
110 + requestAnimationFrame(() => {
111 + bar.style.width = target;
112 + });
113 + }
114 + observer.unobserve(bar);
115 + }
116 + });
117 + }, { threshold: 0.2 });
118 +
119 + document.querySelectorAll('.sa-progress-bar[data-width]').forEach(bar => {
120 + bar.style.width = '0%';
121 + observer.observe(bar);
122 + });
123 + },
124 +
125 + /* ---------- Expense Split Calculator ---------- */
126 + initSplitCalculator() {
127 + const methodInputs = document.querySelectorAll('input[name="SplitMethod"]');
128 + if (!methodInputs.length) return;
129 +
130 + const amountInput = document.getElementById('expenseAmount');
131 + const section = document.getElementById('participantSection');
132 + const amountCols = document.querySelectorAll('.split-amount-col');
133 + const pctCols = document.querySelectorAll('.split-pct-col');
134 + const checks = document.querySelectorAll('.participant-check');
135 + const previews = document.querySelectorAll('.split-preview');
136 +
137 + function getMethod() {
138 + const checked = document.querySelector('input[name="SplitMethod"]:checked');
139 + return checked ? checked.value : '0';
140 + }
141 +
142 + function updateUI() {
143 + const method = getMethod();
144 +
145 + // Animate section visibility
146 + if (method === '0') {
147 + if (section) section.style.display = 'none';
148 + } else {
149 + if (section) {
150 + section.style.display = 'block';
151 + section.classList.add('sa-animate-fade-in');
152 + }
153 + }
154 +
155 + amountCols.forEach(c => c.style.display = method === '2' ? 'block' : 'none');
156 + pctCols.forEach(c => c.style.display = method === '3' ? 'block' : 'none');
157 +
158 + if (method === '2' || method === '3') {
159 + checks.forEach(c => { c.checked = true; });
160 + }
161 +
162 + updatePreview();
163 + }
164 +
165 + function updatePreview() {
166 + const method = getMethod();
167 + const amount = parseFloat(amountInput?.value) || 0;
168 + const checkedBoxes = document.querySelectorAll('.participant-check:checked');
169 +
170 + previews.forEach(p => { p.textContent = ''; p.className = 'sa-badge sa-badge-neutral split-preview'; });
171 +
172 + if (method === '0' || method === '1') {
173 + const count = method === '0' ? checks.length : checkedBoxes.length;
174 + if (count > 0 && amount > 0) {
175 + const each = (amount / count).toFixed(2);
176 + const targets = method === '0' ? checks : checkedBoxes;
177 + targets.forEach(c => {
178 + const idx = c.dataset.index;
179 + if (previews[idx]) {
180 + previews[idx].textContent = `€${each}`;
181 + previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
182 + }
183 + });
184 + }
185 + }
186 +
187 + if (method === '2') {
188 + const amountInputs = document.querySelectorAll('.split-amount');
189 + let total = 0;
190 + amountInputs.forEach(input => { total += parseFloat(input.value) || 0; });
191 + const remaining = amount - total;
192 +
193 + // Update remaining indicator
194 + const indicator = document.getElementById('splitRemaining');
195 + if (indicator) {
196 + indicator.textContent = remaining.toFixed(2);
197 + indicator.className = Math.abs(remaining) < 0.01 ? 'sa-amount sa-amount-positive' : 'sa-amount sa-amount-negative';
198 + }
199 + }
200 +
201 + if (method === '3') {
202 + const pctInputs = document.querySelectorAll('.split-pct');
203 + let totalPct = 0;
204 + pctInputs.forEach(input => { totalPct += parseFloat(input.value) || 0; });
205 +
206 + const indicator = document.getElementById('splitPctTotal');
207 + if (indicator) {
208 + indicator.textContent = totalPct.toFixed(1) + '%';
209 + indicator.className = Math.abs(totalPct - 100) < 0.1 ? 'sa-amount sa-amount-positive' : 'sa-amount sa-amount-negative';
210 + }
211 + }
212 + }
213 +
214 + methodInputs.forEach(input => input.addEventListener('change', updateUI));
215 + if (amountInput) amountInput.addEventListener('input', updatePreview);
216 + checks.forEach(c => c.addEventListener('change', updatePreview));
217 + document.querySelectorAll('.split-amount, .split-pct').forEach(input => {
218 + input.addEventListener('input', updatePreview);
219 + });
220 + },
221 +
222 + /* ---------- Dynamic Poll Options ---------- */
223 + initDynamicPollOptions() {
224 + const container = document.getElementById('pollOptionsContainer');
225 + const addBtn = document.getElementById('addPollOption');
226 + if (!container || !addBtn) return;
227 +
228 + let count = container.querySelectorAll('.poll-option-row').length;
229 +
230 + addBtn.addEventListener('click', () => {
231 + if (count >= 10) {
232 + SplitApp.toast('Maximum 10 options allowed', 'warning');
233 + return;
234 + }
235 + count++;
236 + const row = document.createElement('div');
237 + row.className = 'poll-option-row d-flex gap-2 mb-2 sa-animate-slide-up';
238 + row.innerHTML = `
239 + <input type="text" name="Options" class="form-control" placeholder="Option ${count}" required />
240 + <button type="button" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm remove-option" title="Remove">
241 + <i class="bi bi-x-lg"></i>
242 + </button>
243 + `;
244 + container.appendChild(row);
245 +
246 + row.querySelector('.remove-option').addEventListener('click', () => {
247 + if (container.querySelectorAll('.poll-option-row').length > 2) {
248 + row.style.opacity = '0';
249 + row.style.transform = 'translateX(-20px)';
250 + row.style.transition = 'all 0.2s ease';
251 + setTimeout(() => row.remove(), 200);
252 + count--;
253 + }
254 + });
255 + });
256 +
257 + // Wire up existing remove buttons
258 + container.querySelectorAll('.remove-option').forEach(btn => {
259 + btn.addEventListener('click', () => {
260 + const row = btn.closest('.poll-option-row');
261 + if (container.querySelectorAll('.poll-option-row').length > 2) {
262 + row.remove();
263 + count--;
264 + }
265 + });
266 + });
267 + },
268 +
269 + /* ---------- Icon Picker ---------- */
270 + initIconPicker() {
271 + const picker = document.querySelector('.sa-icon-picker');
272 + const hiddenInput = document.getElementById('iconNameInput');
273 + if (!picker || !hiddenInput) return;
274 +
275 + picker.querySelectorAll('.sa-icon-option').forEach(opt => {
276 + opt.addEventListener('click', () => {
277 + picker.querySelectorAll('.sa-icon-option').forEach(o => o.classList.remove('active'));
278 + opt.classList.add('active');
279 + hiddenInput.value = opt.dataset.icon;
280 + });
281 + });
282 + },
283 +
284 + /* ---------- Confirm Delete ---------- */
285 + initDeleteConfirm() {
286 + document.querySelectorAll('[data-sa-confirm]').forEach(btn => {
287 + btn.addEventListener('click', (e) => {
288 + if (!confirm(btn.dataset.saConfirm || 'Are you sure?')) {
289 + e.preventDefault();
290 + }
291 + });
292 + });
293 + },
294 +
295 + /* ---------- TempData Toast Reader ---------- */
296 + readTempDataToasts() {
297 + const el = document.getElementById('sa-tempdata-messages');
298 + if (!el) return;
299 +
300 + const success = el.dataset.success;
301 + const error = el.dataset.error;
302 + const warning = el.dataset.warning;
303 +
304 + if (success) SplitApp.toast(success, 'success');
305 + if (error) SplitApp.toast(error, 'error');
306 + if (warning) SplitApp.toast(warning, 'warning');
307 + },
308 +
309 + /* ---------- Initialize Everything ---------- */
310 + init() {
311 + this.initScrollAnimations();
312 + this.initProgressBars();
313 + this.initFormLoading();
314 + this.initSplitCalculator();
315 + this.initDynamicPollOptions();
316 + this.initIconPicker();
317 + this.initDeleteConfirm();
318 + this.readTempDataToasts();
319 + }
320 +};
321 +
322 +// Auto-init on DOM ready
323 +document.addEventListener('DOMContentLoaded', () => SplitApp.init());
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
@@ -0,0 +1,22 @@
1 +The MIT License (MIT)
2 +
3 +Copyright (c) 2011-2021 Twitter, Inc.
4 +Copyright (c) 2011-2021 The Bootstrap Authors
5 +
6 +Permission is hereby granted, free of charge, to any person obtaining a copy
7 +of this software and associated documentation files (the "Software"), to deal
8 +in the Software without restriction, including without limitation the rights
9 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 +copies of the Software, and to permit persons to whom the Software is
11 +furnished to do so, subject to the following conditions:
12 +
13 +The above copyright notice and this permission notice shall be included in
14 +all copies or substantial portions of the Software.
15 +
16 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 +THE SOFTWARE.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
@@ -0,0 +1,23 @@
1 +The MIT License (MIT)
2 +
3 +Copyright (c) .NET Foundation and Contributors
4 +
5 +All rights reserved.
6 +
7 +Permission is hereby granted, free of charge, to any person obtaining a copy
8 +of this software and associated documentation files (the "Software"), to deal
9 +in the Software without restriction, including without limitation the rights
10 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 +copies of the Software, and to permit persons to whom the Software is
12 +furnished to do so, subject to the following conditions:
13 +
14 +The above copyright notice and this permission notice shall be included in all
15 +copies or substantial portions of the Software.
16 +
17 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 +SOFTWARE.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
@@ -0,0 +1,22 @@
1 +The MIT License (MIT)
2 +=====================
3 +
4 +Copyright Jörn Zaefferer
5 +
6 +Permission is hereby granted, free of charge, to any person obtaining a copy
7 +of this software and associated documentation files (the "Software"), to deal
8 +in the Software without restriction, including without limitation the rights
9 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 +copies of the Software, and to permit persons to whom the Software is
11 +furnished to do so, subject to the following conditions:
12 +
13 +The above copyright notice and this permission notice shall be included in
14 +all copies or substantial portions of the Software.
15 +
16 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 +THE SOFTWARE.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
@@ -0,0 +1,21 @@
1 +
2 +Copyright OpenJS Foundation and other contributors, https://openjsf.org/
3 +
4 +Permission is hereby granted, free of charge, to any person obtaining
5 +a copy of this software and associated documentation files (the
6 +"Software"), to deal in the Software without restriction, including
7 +without limitation the rights to use, copy, modify, merge, publish,
8 +distribute, sublicense, and/or sell copies of the Software, and to
9 +permit persons to whom the Software is furnished to do so, subject to
10 +the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be
13 +included in all copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18 +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19 +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20 +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21 +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
No newline at end of file
added SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/CurrencyConverterTests.cs +61 −0
@@ -0,0 +1,61 @@
1 +using SplitApp.Modules.Expenses.Application;
2 +
3 +namespace SplitApp.Modules.Expenses.Tests;
4 +
5 +public class CurrencyConverterTests
6 +{
7 + [Fact]
8 + public void Convert_SameCurrency_ReturnsAmountUnchanged()
9 + {
10 + Assert.Equal(100m, CurrencyConverter.Convert(100m, "EUR", "EUR"));
11 + }
12 +
13 + [Theory]
14 + [InlineData(100, "USD", "EUR", 92.0)]
15 + [InlineData(100, "EUR", "USD", 108.70)]
16 + [InlineData(100, "GBP", "EUR", 116.0)]
17 + public void Convert_KnownCurrencies_ReturnsCorrectExchange(decimal amount, string from, string to, decimal expected)
18 + {
19 + Assert.Equal(expected, CurrencyConverter.Convert(amount, from, to));
20 + }
21 +
22 + [Fact]
23 + public void Convert_UnknownCurrency_FallsBackToOneToOne()
24 + {
25 + Assert.Equal(100m, CurrencyConverter.Convert(100m, "XYZ", "EUR"));
26 + Assert.Equal(100m, CurrencyConverter.Convert(100m, "EUR", "XYZ"));
27 + }
28 +
29 + [Fact]
30 + public void Convert_ZeroAmount_ReturnsZero()
31 + {
32 + Assert.Equal(0m, CurrencyConverter.Convert(0m, "USD", "EUR"));
33 + Assert.Equal(0m, CurrencyConverter.Convert(0m, "EUR", "EUR"));
34 + }
35 +
36 + [Theory]
37 + [InlineData(-100, "USD", "EUR", -92.0)]
38 + [InlineData(-50, "EUR", "USD", -54.35)]
39 + public void Convert_NegativeAmount_ConvertsLikeRefund(decimal amount, string from, string to, decimal expected)
40 + {
41 + Assert.Equal(expected, CurrencyConverter.Convert(amount, from, to));
42 + }
43 +
44 + [Fact]
45 + public void Convert_ResultIsAlwaysRoundedToTwoDecimals()
46 + {
47 + var result = CurrencyConverter.Convert(33.337m, "USD", "EUR");
48 + var fractionalDigits = decimal.GetBits(result)[3] >> 16 & 0xFF;
49 + Assert.True(fractionalDigits <= 2, $"Expected ≤2 decimal places, got {fractionalDigits} (value {result})");
50 + }
51 +
52 + [Fact]
53 + public void Convert_RoundTrip_StaysWithinOneCentTolerance()
54 + {
55 + var original = 100m;
56 + var toUsd = CurrencyConverter.Convert(original, "EUR", "USD");
57 + var backToEur = CurrencyConverter.Convert(toUsd, "USD", "EUR");
58 + Assert.True(Math.Abs(original - backToEur) <= 0.02m,
59 + $"Round-trip lost too much precision: {original} → {toUsd} → {backToEur}");
60 + }
61 +}
added SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/SplitApp.Modules.Expenses.Tests.csproj +25 −0
@@ -0,0 +1,25 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + <IsPackable>false</IsPackable>
8 + </PropertyGroup>
9 +
10 + <ItemGroup>
11 + <PackageReference Include="coverlet.collector" Version="6.0.4" />
12 + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13 + <PackageReference Include="xunit" Version="2.9.3" />
14 + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15 + </ItemGroup>
16 +
17 + <ItemGroup>
18 + <Using Include="Xunit" />
19 + </ItemGroup>
20 +
21 + <ItemGroup>
22 + <ProjectReference Include="..\..\src\Modules\Expenses\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj" />
23 + </ItemGroup>
24 +
25 +</Project>
No newline at end of file
added SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/LangStrTests.cs +86 −0
@@ -0,0 +1,86 @@
1 +using SplitApp.Shared.Kernel.Localization;
2 +
3 +namespace SplitApp.Modules.Trips.Tests;
4 +
5 +public class LangStrTests
6 +{
7 + [Fact]
8 + public void Constructor_WithDefaultCulture_StoresValueUnderDefaultKey()
9 + {
10 + var s = new LangStr("Hello", "en");
11 + Assert.Equal("Hello", s.Translate("en"));
12 + }
13 +
14 + [Fact]
15 + public void Translate_WithUnknownCulture_FallsBackToDefault()
16 + {
17 + var s = new LangStr();
18 + s["en"] = "Hello";
19 + Assert.Equal("Hello", s.Translate("fr"));
20 + }
21 +
22 + [Fact]
23 + public void Translate_WithRegionalCulture_FallsBackToNeutralCulture()
24 + {
25 + var s = new LangStr();
26 + s["et"] = "Tere";
27 + Assert.Equal("Tere", s.Translate("et-EE"));
28 + }
29 +
30 + [Theory]
31 + [InlineData("en", "Hello")]
32 + [InlineData("et", "Tere")]
33 + [InlineData("de", "Hallo")]
34 + public void Translate_ReturnsCorrectValueForKnownCulture(string culture, string expected)
35 + {
36 + var s = new LangStr();
37 + s["en"] = "Hello";
38 + s["et"] = "Tere";
39 + s["de"] = "Hallo";
40 + Assert.Equal(expected, s.Translate(culture));
41 + }
42 +
43 + [Fact]
44 + public void ImplicitOperator_FromString_CreatesLangStrWithCurrentCulture()
45 + {
46 + LangStr s = "Test";
47 + Assert.Equal("Test", s.Translate());
48 + }
49 +
50 + [Fact]
51 + public void Translate_EmptyDictionary_ReturnsNull()
52 + {
53 + var s = new LangStr();
54 + Assert.Null(s.Translate("en"));
55 + }
56 +
57 + [Fact]
58 + public void ToString_EmptyDictionary_ReturnsFourQuestionMarks()
59 + {
60 + var s = new LangStr();
61 + Assert.Equal("????", s.ToString());
62 + }
63 +
64 + [Fact]
65 + public void Constructor_EmptyCulture_ThrowsApplicationException()
66 + {
67 + Assert.Throws<ApplicationException>(() => new LangStr("value", ""));
68 + }
69 +
70 + [Fact]
71 + public void SetTranslation_OverwritesExistingCultureValue()
72 + {
73 + var s = new LangStr();
74 + s["et"] = "Tere";
75 + s.SetTranslation("Tervist", "et");
76 + Assert.Equal("Tervist", s.Translate("et"));
77 + }
78 +
79 + [Fact]
80 + public void ImplicitOperatorToString_OnNullLangStr_ReturnsLiteralNullString()
81 + {
82 + LangStr? s = null;
83 + string asString = s!;
84 + Assert.Equal("null", asString);
85 + }
86 +}
added SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/SplitApp.Modules.Trips.Tests.csproj +25 −0
@@ -0,0 +1,25 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + <IsPackable>false</IsPackable>
8 + </PropertyGroup>
9 +
10 + <ItemGroup>
11 + <PackageReference Include="coverlet.collector" Version="6.0.4" />
12 + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13 + <PackageReference Include="xunit" Version="2.9.3" />
14 + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15 + </ItemGroup>
16 +
17 + <ItemGroup>
18 + <Using Include="Xunit" />
19 + </ItemGroup>
20 +
21 + <ItemGroup>
22 + <ProjectReference Include="..\..\src\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
23 + </ItemGroup>
24 +
25 +</Project>
No newline at end of file
added SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/IdentityHelpersTests.cs +105 −0
@@ -0,0 +1,105 @@
1 +using System.IdentityModel.Tokens.Jwt;
2 +using System.Security.Claims;
3 +using SplitApp.Shared.Kernel.Auth;
4 +
5 +namespace SplitApp.Modules.Users.Tests;
6 +
7 +public class IdentityHelpersTests
8 +{
9 + private const string Key = "this-is-a-long-enough-test-signing-key-for-hs256";
10 + private const string Issuer = "splitapp-test";
11 + private const string Audience = "splitapp-test-audience";
12 +
13 + [Fact]
14 + public void GenerateJwt_ProducesTokenContainingAllClaims()
15 + {
16 + var claims = new[]
17 + {
18 + new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
19 + new Claim(ClaimTypes.Email, "alice@example.com"),
20 + };
21 +
22 + var jwt = IdentityHelpers.GenerateJwt(claims, Key, Issuer, Audience, expiresInSeconds: 60);
23 +
24 + Assert.False(string.IsNullOrWhiteSpace(jwt));
25 +
26 + var parsed = new JwtSecurityTokenHandler().ReadJwtToken(jwt);
27 + Assert.Equal(Issuer, parsed.Issuer);
28 + Assert.Contains(parsed.Audiences, a => a == Audience);
29 + Assert.Contains(parsed.Claims, c => c.Type == ClaimTypes.Email && c.Value == "alice@example.com");
30 + }
31 +
32 + [Fact]
33 + public void ValidateJWT_ReturnsTrue_ForTokenSignedWithSameKey()
34 + {
35 + var jwt = IdentityHelpers.GenerateJwt(
36 + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) },
37 + Key, Issuer, Audience, expiresInSeconds: 60);
38 +
39 + Assert.True(IdentityHelpers.ValidateJWT(jwt, Key, Issuer, Audience));
40 + }
41 +
42 + [Fact]
43 + public void ValidateJWT_ReturnsFalse_ForWrongSigningKey()
44 + {
45 + var jwt = IdentityHelpers.GenerateJwt(
46 + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) },
47 + Key, Issuer, Audience, expiresInSeconds: 60);
48 +
49 + Assert.False(IdentityHelpers.ValidateJWT(jwt, "different-signing-key-different-from-the-original-one", Issuer, Audience));
50 + }
51 +
52 + [Fact]
53 + public void ValidateJWT_IgnoresExpiration_ForRefreshScenario()
54 + {
55 + var jwt = IdentityHelpers.GenerateJwt(
56 + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) },
57 + Key, Issuer, Audience, expiresInSeconds: -60);
58 +
59 + Assert.True(IdentityHelpers.ValidateJWT(jwt, Key, Issuer, Audience));
60 + }
61 +
62 + [Fact]
63 + public void ValidateJWT_ReturnsFalse_ForWrongIssuer()
64 + {
65 + var jwt = IdentityHelpers.GenerateJwt(
66 + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) },
67 + Key, Issuer, Audience, expiresInSeconds: 60);
68 +
69 + Assert.False(IdentityHelpers.ValidateJWT(jwt, Key, "different-issuer", Audience));
70 + }
71 +
72 + [Fact]
73 + public void ValidateJWT_ReturnsFalse_ForWrongAudience()
74 + {
75 + var jwt = IdentityHelpers.GenerateJwt(
76 + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) },
77 + Key, Issuer, Audience, expiresInSeconds: 60);
78 +
79 + Assert.False(IdentityHelpers.ValidateJWT(jwt, Key, Issuer, "different-audience"));
80 + }
81 +
82 + [Fact]
83 + public void ValidateJWT_ReturnsFalse_ForMalformedToken()
84 + {
85 + Assert.False(IdentityHelpers.ValidateJWT("not-a-real-jwt-string", Key, Issuer, Audience));
86 + Assert.False(IdentityHelpers.ValidateJWT("a.b.c", Key, Issuer, Audience));
87 + }
88 +
89 + [Fact]
90 + public void GenerateJwt_PreservesCustomClaimType_InProducedToken()
91 + {
92 + var claims = new[]
93 + {
94 + new Claim(ClaimTypes.NameIdentifier, "user-1"),
95 + new Claim("trip_count", "3"),
96 + new Claim(ClaimTypes.Role, "admin"),
97 + };
98 +
99 + var jwt = IdentityHelpers.GenerateJwt(claims, Key, Issuer, Audience, expiresInSeconds: 60);
100 + var parsed = new JwtSecurityTokenHandler().ReadJwtToken(jwt);
101 +
102 + Assert.Contains(parsed.Claims, c => c.Type == "trip_count" && c.Value == "3");
103 + Assert.Contains(parsed.Claims, c => c.Value == "admin");
104 + }
105 +}
added SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/SplitApp.Modules.Users.Tests.csproj +25 −0
@@ -0,0 +1,25 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + <IsPackable>false</IsPackable>
8 + </PropertyGroup>
9 +
10 + <ItemGroup>
11 + <PackageReference Include="coverlet.collector" Version="6.0.4" />
12 + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13 + <PackageReference Include="xunit" Version="2.9.3" />
14 + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15 + </ItemGroup>
16 +
17 + <ItemGroup>
18 + <Using Include="Xunit" />
19 + </ItemGroup>
20 +
21 + <ItemGroup>
22 + <ProjectReference Include="..\..\src\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
23 + </ItemGroup>
24 +
25 +</Project>
No newline at end of file
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/CrossModuleNavigationTests.cs +90 −0
@@ -0,0 +1,90 @@
1 +using System.IO;
2 +using System.Linq;
3 +using System.Text.RegularExpressions;
4 +
5 +namespace SplitApp.WebApp.IntegrationTests.Architecture;
6 +
7 +/// <summary>
8 +/// Cross-module entity navigations are allowed only when annotated <c>[NotMapped]</c>.
9 +/// EF still treats every module's schema as isolated (cross-module navs are never
10 +/// loaded via Include) — the WebApp facade hydrates them in-memory after fetching
11 +/// from the appropriate module's DbContext. A plain (mapped) navigation across
12 +/// modules would let EF cross schemas and is therefore forbidden.
13 +/// </summary>
14 +public class CrossModuleNavigationTests
15 +{
16 + private static string SolutionRoot()
17 + {
18 + var dir = new DirectoryInfo(AppContext.BaseDirectory);
19 + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln")))
20 + {
21 + dir = dir.Parent;
22 + }
23 + Assert.NotNull(dir);
24 + return dir!.FullName;
25 + }
26 +
27 + [Fact]
28 + public void NoDomainEntityHasMappedNavigationToAnotherModulesEntityType()
29 + {
30 + var modulesDir = Path.Combine(SolutionRoot(), "src", "Modules");
31 + var moduleNames = Directory.GetDirectories(modulesDir).Select(Path.GetFileName).ToList();
32 +
33 + var entitiesByModule = moduleNames.ToDictionary(
34 + m => m!,
35 + m => Directory
36 + .GetFiles(Path.Combine(modulesDir, m!, $"SplitApp.Modules.{m}.Domain", "Entities"),
37 + "*.cs", SearchOption.TopDirectoryOnly)
38 + .Select(Path.GetFileNameWithoutExtension)
39 + .Where(n => n != null)
40 + .Cast<string>()
41 + .ToHashSet());
42 +
43 + var failures = new List<string>();
44 +
45 + foreach (var module in moduleNames)
46 + {
47 + var foreignEntities = entitiesByModule
48 + .Where(kv => kv.Key != module)
49 + .SelectMany(kv => kv.Value)
50 + .ToHashSet();
51 +
52 + var entityFiles = Directory.GetFiles(
53 + Path.Combine(modulesDir, module!, $"SplitApp.Modules.{module}.Domain", "Entities"),
54 + "*.cs",
55 + SearchOption.TopDirectoryOnly);
56 +
57 + foreach (var file in entityFiles)
58 + {
59 + var lines = File.ReadAllLines(file);
60 + for (var i = 0; i < lines.Length; i++)
61 + {
62 + var line = Regex.Replace(lines[i], "//.*", "").TrimStart();
63 + if (!line.StartsWith("public ")) continue;
64 +
65 + foreach (var foreign in foreignEntities)
66 + {
67 + // Look for "public Currency? Foo {", "public ICollection<Currency>? Foo {", etc.
68 + var pattern = $@"public\s+(?:I[A-Za-z]+<\s*)?{Regex.Escape(foreign)}\??\s*>?\s*\??\s+\w+\s*\{{";
69 + if (!Regex.IsMatch(line, pattern)) continue;
70 +
71 + // Allow if [NotMapped] is on this line OR on any of the previous (attribute) lines
72 + var hasNotMapped = false;
73 + for (var k = i; k >= 0 && k >= i - 3; k--)
74 + {
75 + if (lines[k].Contains("[NotMapped]")) { hasNotMapped = true; break; }
76 + if (k < i && !lines[k].TrimStart().StartsWith("[") && lines[k].TrimStart().Length > 0) break;
77 + }
78 +
79 + if (!hasNotMapped)
80 + {
81 + failures.Add($"{Path.GetFileName(file)} (module {module}) line {i + 1}: mapped navigation to foreign entity '{foreign}'");
82 + }
83 + }
84 + }
85 + }
86 + }
87 +
88 + Assert.Empty(failures);
89 + }
90 +}
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/DbContextSchemaIsolationTests.cs +70 −0
@@ -0,0 +1,70 @@
1 +using System.IO;
2 +using System.Linq;
3 +using System.Text.RegularExpressions;
4 +
5 +namespace SplitApp.WebApp.IntegrationTests.Architecture;
6 +
7 +/// <summary>
8 +/// Each module's DbContext must only expose <c>DbSet&lt;T&gt;</c> for entities that live in
9 +/// its own Domain project (plus a small framework allowlist). This guards the rule that
10 +/// modules never share tables and never query each other's storage directly.
11 +/// </summary>
12 +public class DbContextSchemaIsolationTests
13 +{
14 + private static readonly string[] FrameworkAllowlist =
15 + {
16 + "DataProtectionKey",
17 + };
18 +
19 + private static string SolutionRoot()
20 + {
21 + var dir = new DirectoryInfo(AppContext.BaseDirectory);
22 + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln")))
23 + {
24 + dir = dir.Parent;
25 + }
26 + Assert.NotNull(dir);
27 + return dir!.FullName;
28 + }
29 +
30 + public static IEnumerable<object[]> Modules()
31 + {
32 + yield return new object[] { "Users", "UsersDbContext.cs" };
33 + yield return new object[] { "Trips", "TripsDbContext.cs" };
34 + yield return new object[] { "Expenses", "ExpensesDbContext.cs" };
35 + }
36 +
37 + [Theory]
38 + [MemberData(nameof(Modules))]
39 + public void DbContextOnlyExposesDbSetsForOwnDomainEntities(string module, string dbContextFileName)
40 + {
41 + var moduleRoot = Path.Combine(SolutionRoot(), "src", "Modules", module);
42 + var dbContextFile = Directory
43 + .GetFiles(moduleRoot, dbContextFileName, SearchOption.AllDirectories)
44 + .Single();
45 +
46 + var domainEntitiesDir = Path.Combine(moduleRoot, $"SplitApp.Modules.{module}.Domain", "Entities");
47 + Assert.True(Directory.Exists(domainEntitiesDir), $"Missing entities dir: {domainEntitiesDir}");
48 +
49 + var ownEntityNames = Directory
50 + .GetFiles(domainEntitiesDir, "*.cs", SearchOption.TopDirectoryOnly)
51 + .Select(Path.GetFileNameWithoutExtension)
52 + .ToHashSet(StringComparer.Ordinal);
53 +
54 + var src = File.ReadAllText(dbContextFile);
55 + var dbSetTypes = Regex.Matches(src, @"DbSet<(\w+)>")
56 + .Select(m => m.Groups[1].Value)
57 + .Distinct()
58 + .ToList();
59 +
60 + Assert.NotEmpty(dbSetTypes);
61 +
62 + var foreign = dbSetTypes
63 + .Where(t => !ownEntityNames!.Contains(t!) && !FrameworkAllowlist.Contains(t))
64 + .ToList();
65 +
66 + Assert.True(
67 + foreign.Count == 0,
68 + $"{dbContextFileName} exposes DbSet<> for non-{module}-domain types: {string.Join(", ", foreign)}");
69 + }
70 +}
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs +87 −0
@@ -0,0 +1,87 @@
1 +using System.IO;
2 +using System.Linq;
3 +using System.Xml.Linq;
4 +
5 +namespace SplitApp.WebApp.IntegrationTests.Architecture;
6 +
7 +/// <summary>
8 +/// Compile-time invariant: no module project may reference another module's project.
9 +/// Modules can only depend on Shared.Kernel and Shared.Contracts.
10 +/// </summary>
11 +public class ModuleBoundaryTests
12 +{
13 + private static string SolutionRoot()
14 + {
15 + var dir = new DirectoryInfo(AppContext.BaseDirectory);
16 + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln")))
17 + {
18 + dir = dir.Parent;
19 + }
20 + Assert.NotNull(dir);
21 + return dir!.FullName;
22 + }
23 +
24 + [Fact]
25 + public void NoModuleReferencesAnotherModuleProject()
26 + {
27 + var modulesDir = Path.Combine(SolutionRoot(), "src", "Modules");
28 + Assert.True(Directory.Exists(modulesDir), $"Modules dir not found at {modulesDir}");
29 +
30 + var moduleNames = Directory.GetDirectories(modulesDir).Select(Path.GetFileName).ToList();
31 + Assert.Equal(3, moduleNames.Count);
32 +
33 + var csprojFiles = Directory.GetFiles(modulesDir, "*.csproj", SearchOption.AllDirectories);
34 + var failures = new List<string>();
35 +
36 + foreach (var csproj in csprojFiles)
37 + {
38 + var owningModule = moduleNames.First(m => csproj.Contains($"Modules{Path.DirectorySeparatorChar}{m}{Path.DirectorySeparatorChar}"));
39 + var doc = XDocument.Load(csproj);
40 + var refs = doc.Descendants("ProjectReference")
41 + .Select(r => (string?)r.Attribute("Include"))
42 + .Where(r => r != null)
43 + .ToList();
44 +
45 + foreach (var refPath in refs)
46 + {
47 + foreach (var otherModule in moduleNames.Where(m => m != owningModule))
48 + {
49 + if (refPath!.Contains($"Modules{Path.DirectorySeparatorChar}{otherModule}{Path.DirectorySeparatorChar}")
50 + || refPath.Contains($"Modules\\{otherModule}\\")
51 + || refPath.Contains($"Modules/{otherModule}/"))
52 + {
53 + failures.Add($"{Path.GetFileName(csproj)} (module {owningModule}) references {refPath} (module {otherModule})");
54 + }
55 + }
56 + }
57 + }
58 +
59 + Assert.Empty(failures);
60 + }
61 +
62 + [Fact]
63 + public void SharedProjectsDoNotReferenceModules()
64 + {
65 + var sharedDir = Path.Combine(SolutionRoot(), "src", "Shared");
66 + var csprojFiles = Directory.GetFiles(sharedDir, "*.csproj", SearchOption.AllDirectories);
67 +
68 + var failures = new List<string>();
69 + foreach (var csproj in csprojFiles)
70 + {
71 + var doc = XDocument.Load(csproj);
72 + var refs = doc.Descendants("ProjectReference")
73 + .Select(r => (string?)r.Attribute("Include"))
74 + .Where(r => r != null);
75 +
76 + foreach (var refPath in refs)
77 + {
78 + if (refPath!.Contains("Modules"))
79 + {
80 + failures.Add($"{Path.GetFileName(csproj)} references {refPath}");
81 + }
82 + }
83 + }
84 +
85 + Assert.Empty(failures);
86 + }
87 +}
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs +59 −0
@@ -0,0 +1,59 @@
1 +using System.Net;
2 +using Microsoft.AspNetCore.Hosting;
3 +using Microsoft.AspNetCore.Mvc.Testing;
4 +
5 +namespace SplitApp.WebApp.IntegrationTests;
6 +
7 +/// <summary>
8 +/// Boots the full WebApp host in the "Testing" environment (which skips the
9 +/// per-module Migrate calls) and verifies the public landing endpoints serve.
10 +/// Proves DI composition across all three modules works end-to-end.
11 +/// </summary>
12 +public class HostBootSmokeTests : IClassFixture<WebApplicationFactory<Program>>
13 +{
14 + private readonly WebApplicationFactory<Program> _factory;
15 +
16 + public HostBootSmokeTests(WebApplicationFactory<Program> factory)
17 + {
18 + _factory = factory.WithWebHostBuilder(b =>
19 + {
20 + b.UseEnvironment("Testing");
21 + b.UseSetting("ConnectionStrings:DefaultConnection", "Host=ignored;Database=ignored");
22 + b.UseSetting("JWT:Issuer", "splitapp-test");
23 + b.UseSetting("JWT:Audience", "splitapp-test");
24 + b.UseSetting("JWT:Key", "this-is-a-long-enough-test-signing-key-for-hs256");
25 + });
26 + }
27 +
28 + [Fact]
29 + public async Task Get_Root_ReturnsOk()
30 + {
31 + var client = _factory.CreateClient();
32 + var response = await client.GetAsync("/");
33 + Assert.Equal(HttpStatusCode.OK, response.StatusCode);
34 + var body = await response.Content.ReadAsStringAsync();
35 + Assert.Contains("SplitApp", body);
36 + }
37 +
38 + [Fact]
39 + public async Task Get_HomeIndex_RendersLandingPage()
40 + {
41 + var client = _factory.CreateClient();
42 + var response = await client.GetAsync("/Home/Index");
43 + Assert.Equal(HttpStatusCode.OK, response.StatusCode);
44 + var body = await response.Content.ReadAsStringAsync();
45 + // Phase 2 home view content
46 + Assert.Contains("SplitApp", body);
47 + }
48 +
49 + [Fact]
50 + public async Task Get_TripsApi_RequiresAuth()
51 + {
52 + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
53 + {
54 + AllowAutoRedirect = false,
55 + });
56 + var response = await client.GetAsync("/api/v1/trips");
57 + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
58 + }
59 +}
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostFeatureTests.cs +85 −0
@@ -0,0 +1,85 @@
1 +using System.Net;
2 +using Microsoft.AspNetCore.Hosting;
3 +using Microsoft.AspNetCore.Mvc.Testing;
4 +
5 +namespace SplitApp.WebApp.IntegrationTests;
6 +
7 +/// <summary>
8 +/// End-to-end host checks that exercise the cross-cutting requirements:
9 +/// Swagger generation, Admin area protection, JWT requirement on a second
10 +/// module's controller, and request-localization wiring.
11 +/// </summary>
12 +public class HostFeatureTests : IClassFixture<WebApplicationFactory<Program>>
13 +{
14 + private readonly WebApplicationFactory<Program> _factory;
15 +
16 + public HostFeatureTests(WebApplicationFactory<Program> factory)
17 + {
18 + _factory = factory.WithWebHostBuilder(b =>
19 + {
20 + b.UseEnvironment("Testing");
21 + b.UseSetting("ConnectionStrings:DefaultConnection", "Host=ignored;Database=ignored");
22 + b.UseSetting("JWT:Issuer", "splitapp-test");
23 + b.UseSetting("JWT:Audience", "splitapp-test");
24 + b.UseSetting("JWT:Key", "this-is-a-long-enough-test-signing-key-for-hs256");
25 + });
26 + }
27 +
28 + [Fact]
29 + public async Task Get_SwaggerV1Json_ReturnsOpenApiDocument()
30 + {
31 + var client = _factory.CreateClient();
32 + var response = await client.GetAsync("/swagger/v1/swagger.json");
33 + Assert.Equal(HttpStatusCode.OK, response.StatusCode);
34 +
35 + var body = await response.Content.ReadAsStringAsync();
36 + Assert.Contains("\"openapi\"", body);
37 + Assert.Contains("/api/v1/Trips", body);
38 + }
39 +
40 + [Fact]
41 + public async Task Get_SwaggerUi_ReturnsOk()
42 + {
43 + var client = _factory.CreateClient();
44 + var response = await client.GetAsync("/swagger/index.html");
45 + Assert.Equal(HttpStatusCode.OK, response.StatusCode);
46 + }
47 +
48 + [Fact]
49 + public async Task Get_AdminArea_RedirectsUnauthenticatedUserToLogin()
50 + {
51 + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
52 + {
53 + AllowAutoRedirect = false,
54 + });
55 + var response = await client.GetAsync("/Admin/Trips");
56 +
57 + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
58 + Assert.NotNull(response.Headers.Location);
59 + Assert.Contains("Login", response.Headers.Location!.OriginalString, StringComparison.OrdinalIgnoreCase);
60 + }
61 +
62 + [Fact]
63 + public async Task Get_ExpensesApi_RequiresAuth()
64 + {
65 + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
66 + {
67 + AllowAutoRedirect = false,
68 + });
69 + var response = await client.GetAsync($"/api/v1/expenses/trip/{Guid.NewGuid()}");
70 + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
71 + }
72 +
73 + [Fact]
74 + public async Task Get_Home_WithEtCultureQuery_ReturnsOk()
75 + {
76 + var client = _factory.CreateClient();
77 + var response = await client.GetAsync("/?culture=et&ui-culture=et");
78 + Assert.Equal(HttpStatusCode.OK, response.StatusCode);
79 +
80 + // RequestLocalization should accept "et" as a supported culture
81 + // without throwing or falling back to a 4xx/5xx.
82 + var body = await response.Content.ReadAsStringAsync();
83 + Assert.Contains("SplitApp", body);
84 + }
85 +}
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/SplitApp.WebApp.IntegrationTests.csproj +29 −0
@@ -0,0 +1,29 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + <IsPackable>false</IsPackable>
8 + </PropertyGroup>
9 +
10 + <ItemGroup>
11 + <PackageReference Include="coverlet.collector" Version="6.0.4" />
12 + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13 + <PackageReference Include="xunit" Version="2.9.3" />
14 + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15 + </ItemGroup>
16 +
17 + <ItemGroup>
18 + <Using Include="Xunit" />
19 + </ItemGroup>
20 +
21 + <ItemGroup>
22 + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
23 + </ItemGroup>
24 +
25 + <ItemGroup>
26 + <ProjectReference Include="..\..\src\SplitApp.WebApp\SplitApp.WebApp.csproj" />
27 + </ItemGroup>
28 +
29 +</Project>
No newline at end of file
added architecture.md +221 −0
@@ -0,0 +1,221 @@
1 +# Architecture — SplitApp Phase 3 (Modular Monolith)
2 +
3 +This document is the architectural deep-dive for the Phase 3 refactor. The whole product lives under [`SplitApp.Modular/`](SplitApp.Modular/): one deployable, three internally isolated modules (**Users**, **Trips**, **Expenses**), MediatR for cross-module communication, schema-per-module Postgres isolation.
4 +
5 +For the original course context, see also [`modularmonolith.md`](modularmonolith.md) and [`phase3.md`](phase3.md). The module-level README at [`SplitApp.Modular/README.md`](SplitApp.Modular/README.md) and the dedicated [`SplitApp.Modular/docs/ARCHITECTURE.md`](SplitApp.Modular/docs/ARCHITECTURE.md) are the canonical references; this file is the high-level overview.
6 +
7 +---
8 +
9 +## 1. The picture
10 +
11 +```
12 + ┌────────────────────────────────────────────────┐
13 + │ SplitApp.WebApp (host) │
14 + │ Program.cs · Controllers · Areas/Admin │
15 + │ Application/{Services, DTO, Mappers, │
16 + │ Persistence} │
17 + └────────────────────────────────────────────────┘
18 + │ │ │
19 + ▼ ▼ ▼
20 + ┌──────────┐ ┌──────────┐ ┌──────────┐
21 + │ Users │ │ Trips │ │ Expenses │
22 + │ Domain │ │ Domain │ │ Domain │
23 + │ App │ │ App │ │ App │
24 + │ Infra │ │ Infra │ │ Infra │
25 + │ Api │ │ Api │ │ Api │
26 + │ schema: │ │ schema: │ │ schema: │
27 + │ users │ │ trips │ │ expenses │
28 + └──────────┘ └──────────┘ └──────────┘
29 + ▲ ▲ ▲
30 + └─MediatR───┴─MediatR───┘
31 + ┌──────────────────────┐ ┌──────────────────────┐
32 + │ Shared.Contracts │ │ Shared.Kernel │
33 + │ IRequest/INotification│ │ BaseEntity, LangStr │
34 + └──────────────────────┘ └──────────────────────┘
35 +```
36 +
37 +Each module = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` for REST). Each module owns its own `DbContext` scoped to its own Postgres schema. Cross-module function calls go through MediatR only — no direct `<ProjectReference>` between modules' `Application` / `Infrastructure` / `Api` layers.
38 +
39 +---
40 +
41 +## 2. Module layout
42 +
43 +```
44 +SplitApp.Modular/
45 +├── SplitApp.sln
46 +├── Directory.Build.props
47 +├── src/
48 +│ ├── SplitApp.WebApp/ ← composition root, host, admin Area
49 +│ │ ├── Program.cs ← AddXxxModule(...) wiring
50 +│ │ ├── Application/ ← lifted phase-2 BLL
51 +│ │ │ ├── Services/ (+ Admin/, Identity/)
52 +│ │ │ ├── DTO/ ← BllDtos
53 +│ │ │ ├── Mappers/ ← Domain ↔ BllDto factory mappers
54 +│ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts
55 +│ │ │ └── Persistence/CrossModuleNavigationLoader.cs
56 +│ │ ├── Areas/Admin/, Areas/Identity/, Controllers/, Views/, Resources/
57 +│ ├── Shared/
58 +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
59 +│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification
60 +│ └── Modules/
61 +│ ├── Users/
62 +│ │ ├── SplitApp.Modules.Users.Domain/ ← AppUser, AppRole, AppRefreshToken
63 +│ │ ├── SplitApp.Modules.Users.Application/ ← IIdentityService + JWT/refresh, MediatR handlers
64 +│ │ ├── SplitApp.Modules.Users.Infrastructure/ ← UsersDbContext (schema "users"), repos, AddUsersModule
65 +│ │ └── SplitApp.Modules.Users.Api/ ← /api/v1/identity/...
66 +│ ├── Trips/ ← same 4-project layout, schema "trips"
67 +│ └── Expenses/ ← same 4-project layout, schema "expenses"
68 +└── tests/
69 + ├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ ← per-module unit tests
70 + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke
71 +```
72 +
73 +---
74 +
75 +## 3. Reference rules (compiler-enforced + arch-test verified)
76 +
77 +| Source | Allowed targets | Notes |
78 +|--------|----------------|-------|
79 +| `Modules/X/Application` | same-module `Domain` + `Shared.Kernel` + `Shared.Contracts` | |
80 +| `Modules/X/Infrastructure` | same-module `Domain` + `Application` + `Shared.Kernel` | |
81 +| `Modules/X/Api` | same-module `Application` + `Shared.Kernel` + `Shared.Contracts` | |
82 +| `Shared.*` | none of the modules | |
83 +| `WebApp` | all 3 modules' `Api` + `Infrastructure` + `Shared.*` | composition root |
84 +
85 +**Domain-level caveat:** to keep view-rendering parity from phase 2 (mappers reading `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.), entity classes still declare cross-module navigation properties — annotated `[NotMapped]` so EF never crosses Postgres schemas. To make those property *types* compile, three Domain-to-Domain `<ProjectReference>`s exist:
86 +
87 +```
88 +Modules/Trips/SplitApp.Modules.Trips.Domain
89 + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
90 + → Modules/Expenses/SplitApp.Modules.Expenses.Domain (for Currency refs)
91 +Modules/Expenses/SplitApp.Modules.Expenses.Domain
92 + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
93 +```
94 +
95 +This bends the strict "no direct references between modules" rule from `phase3.md` at the **Domain** level. **`Application`, `Infrastructure`, `Api` projects remain isolated** and use MediatR for actual function calls — only entity *types* are shared. Schema isolation, MediatR-only inter-module function calls, and per-module DbContext ownership are all preserved at runtime.
96 +
97 +`CrossModuleNavigationTests` enforces the `[NotMapped]` rule: a *mapped* navigation across modules (where EF would actually try to traverse) fails the build.
98 +
99 +---
100 +
101 +## 4. Inter-module communication (MediatR)
102 +
103 +Cross-module calls go through MediatR. Contracts live in `SplitApp.Shared.Contracts/<Module>/{Queries|Events|Commands}/` and are records implementing `IRequest<T>` (sync queries/commands) or `INotification` (fan-out events). Handlers live in the **owning** module's `Application` or `Infrastructure` layer.
104 +
105 +| Contract | Owner | Notes |
106 +|----------|-------|-------|
107 +| `GetUserByIdQuery : IRequest<UserDto?>` | Users | Used by Trips/Expenses for display-name lookup |
108 +| `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Batch lookup |
109 +| `UserDeletedEvent : INotification` | Users | Trips + Expenses subscribe to clean up rows |
110 +| `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module trip lookup |
111 +| `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | |
112 +| `IsTripParticipantQuery : IRequest<bool>` | Trips | IDOR guard in `ExpensesController` |
113 +| `TripDeletedEvent : INotification` | Trips | Expenses subscribes to delete dependent expenses/settlements |
114 +| `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | Per-currency totals for a trip |
115 +| `GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>>` | Expenses | Per-budget-category spent totals |
116 +| `ExpenseSettledEvent : INotification` | Expenses | Reserved for future use |
117 +| `SettlementPlanCompletedEvent : INotification` | Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed |
118 +
119 +---
120 +
121 +## 5. Schema isolation
122 +
123 +Each module owns its own `DbContext`:
124 +
125 +- `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users`
126 +- `TripsDbContext : DbContext` → schema `trips`
127 +- `ExpensesDbContext : DbContext` → schema `expenses`
128 +
129 +All three connect to the same physical Postgres database via the same `ConnectionStrings:DefaultConnection`. **Cross-module SQL joins are forbidden** — composition happens at the application layer through MediatR or the WebApp facade's `CrossModuleNavigationLoader`.
130 +
131 +Cross-module entity references are bare `Guid` fields with **no** EF foreign-key constraints (e.g. `Trip.CreatedById : Guid` references `users.AspNetUsers.Id` only conceptually). Referential integrity is maintained by:
132 +
133 +- Up-front MediatR validation queries (e.g. `IsTripParticipantQuery` before persisting an expense split)
134 +- Domain-event cleanup on delete (`UserDeletedEvent`, `TripDeletedEvent`)
135 +
136 +---
137 +
138 +## 6. Composition root and lifted phase 2 BLL
139 +
140 +`SplitApp.WebApp` is the only project that sees all three modules. It hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, 10 REST API controllers, Identity Razor Register page) and lifts the entire phase 2 BLL layer into [`SplitApp.WebApp/Application/`](SplitApp.Modular/src/SplitApp.WebApp/Application/) under three buckets:
141 +
142 +| Bucket | Contents |
143 +|---|---|
144 +| `Application/DTO` | Phase-2 BLL DTOs (`TripBllDto`, `ExpenseBllDto`, …) — POCOs that views model-bind to |
145 +| `Application/Mappers` | Entity ↔ BLL DTO factory mappers |
146 +| `Application/Services` (+ `Services/Admin`, `Services/Identity`) | Phase-2 BLL services lifted unchanged; consume `IAppUnitOfWork` |
147 +| `Application/Persistence/AppUnitOfWork.cs` | Composition-root facade aggregating `UsersDbContext`, `TripsDbContext`, `ExpensesDbContext` behind phase-2's `IAppUnitOfWork` interface — each repository routes to the appropriate module's DbContext |
148 +| `Application/Persistence/CrossModuleNavigationLoader.cs` | Hydrates `[NotMapped]` cross-module nav properties (`Trip.DefaultCurrency`, `TripParticipant.User`, `Expense.PaidByUser`, …) after entity load by querying the owning module's DbContext separately. EF never crosses schemas. |
149 +
150 +The lifted BLL services keep working unchanged: `_uow.Trips.GetByIdAsync(...)` behaves like phase 2. In reality `AppUnitOfWork.Trips` routes the query to `TripsDbContext`, and `CrossModuleHydration` fills cross-module navs (`Trip.CreatedBy`, `Trip.DefaultCurrency`) afterwards via separate single-shot batched queries against the foreign module's DbContext.
151 +
152 +---
153 +
154 +## 7. Phase 3 → phase 2 mapping (for context)
155 +
156 +| Phase 2 project | Phase 3 destination |
157 +|---|---|
158 +| `Base.Domain` (`BaseEntity`, `LangStr`) | `SplitApp.Shared.Kernel` |
159 +| `Base.Contracts` (`IBaseEntity`, `IBaseRepository`, `IUnitOfWork`) | `SplitApp.Shared.Kernel` |
160 +| `Base.Helpers` (`IdentityHelpers`) | `SplitApp.Shared.Kernel.Auth` |
161 +| `App.Domain.Identity.*` | `SplitApp.Modules.Users.Domain.Entities` |
162 +| `App.Domain.{Trip, TripParticipant, …}` | `SplitApp.Modules.Trips.Domain.Entities` |
163 +| `App.Domain.{Expense, SettlementPlan, …, Currency}` | `SplitApp.Modules.Expenses.Domain.Entities` |
164 +| `App.DAL.EF.AppDbContext` | Split into 3 `XxxDbContext` per module |
165 +| `App.BLL.Services.Identity.*` | `SplitApp.Modules.Users.Application.Services` |
166 +| `App.BLL.Services.*` (Trip, Expense, Settlement, …) | Lifted into `SplitApp.WebApp/Application/Services` (composition-root facade over the 3 module UoWs) |
167 +| `App.BLL.DTO.*`, `App.BLL.Mappers.*` | Lifted into `SplitApp.WebApp/Application/{DTO, Mappers}` |
168 +| `WebApp.ApiControllers.Identity.*` | `SplitApp.Modules.Users.Api.Controllers` |
169 +| `WebApp.ApiControllers.{TripsController, …}` | `SplitApp.Modules.Trips.Api.Controllers` |
170 +| `WebApp.ApiControllers.{ExpensesController, CurrenciesController, SettlementsController, SplitPresetsController}` | `SplitApp.Modules.Expenses.Api.Controllers` |
171 +| `WebApp/Controllers/*` (MVC client) + `WebApp/Areas/Admin/*` + `WebApp/Areas/Identity/*` + `WebApp/Views/*` | `SplitApp.Modular/src/SplitApp.WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` (preserved structurally; namespace re-rooted to `SplitApp.WebApp.*`) |
172 +
173 +---
174 +
175 +## 8. Architecture tests (run on `dotnet test`)
176 +
177 +`tests/SplitApp.WebApp.IntegrationTests/Architecture/`:
178 +
179 +1. **`ModuleBoundaryTests`** — no module's `Application`/`Infrastructure`/`Api` project has a `<ProjectReference>` to another module's project; no `Shared.*` project references a module.
180 +2. **`DbContextSchemaIsolationTests`** — each `DbContext` only exposes `DbSet<T>` for entities that live in its own `Domain` project (plus a tiny framework allowlist).
181 +3. **`CrossModuleNavigationTests`** — cross-module navigation properties are allowed only when annotated `[NotMapped]`. A plain mapped nav across modules (which would let EF cross schemas) fails the build.
182 +4. **`HostBootSmokeTests`** — `WebApplicationFactory<Program>` boots the full host in `Testing` env (skipping per-module migrations) and verifies `/`, `/Home/Index`, and a 401 on unauthenticated API hits.
183 +
184 +A failing architecture test means a developer just violated the modular-monolith invariant.
185 +
186 +**Total test count: 25** — Expenses (5) + Trips (7) + Users (4) + IntegrationTests (9). All passing on the current build.
187 +
188 +---
189 +
190 +## 9. Deployment
191 +
192 +**Production deployment:** https://travel.rasmusj.com/
193 +
194 +The repo's root `Dockerfile` + `docker-compose.yml` build and run phase 3 locally:
195 +
196 +```bash
197 +docker compose up --build
198 +```
199 +
200 +| Service | Container | Port | Notes |
201 +|---|---|---|---|
202 +| `phase3` | `phase3` | http://localhost:90 | Web app (host port `90` → container port `8080`) |
203 +| `db` | `phase3-db` | (internal only) | PostgreSQL 16, schemas `users` / `trips` / `expenses` — not exposed to host |
204 +
205 +Per-module migrations run automatically on host startup. Sample data is seeded if `DataInitialization:SeedData=true`.
206 +
207 +For per-module migration commands (`dotnet ef migrations add ...`) and the full URL map, see [`SplitApp.Modular/docs/ARCHITECTURE.md`](SplitApp.Modular/docs/ARCHITECTURE.md) and [`SplitApp.Modular/README.md`](SplitApp.Modular/README.md).
208 +
209 +---
210 +
211 +## 10. Why a modular monolith?
212 +
213 +| Approach | Problem |
214 +|---|---|
215 +| Classic monolith | Everything references everything — one change cascades |
216 +| Microservices | Distributed-systems pain — network, serialization, eventual consistency, deployment complexity |
217 +| **Modular monolith** | **Microservice-style boundaries + monolith deployment simplicity** |
218 +
219 +Future extraction path, if needed: *Classic monolith → Modular monolith → Microservices*. Each module already has its own schema, its own contracts, its own `DbContext`. Extracting a module into a separate service later means replacing in-process MediatR calls with HTTP/gRPC and domain events with a message broker — the code structure barely changes, only the transport layer.
220 +
221 +See [`modularmonolith.md`](modularmonolith.md) for the course material on the pattern.
added arhitektuur.md +230 −0
@@ -0,0 +1,230 @@
1 +# SplitApp — Modulaarne Monoliit (Phase 3)
2 +
3 +## 1. Üldine pilt
4 +
5 +```
6 + ┌────────────────────────────────────────────────────┐
7 + │ SplitApp.WebApp (Composition Root) │
8 + │ Program.cs · MVC Controllers · Areas/Admin │
9 + │ Areas/Identity · Views · ViewModels │
10 + │ Application/{Services, DTO, Mappers, Persistence}│
11 + │ (lifted phase-2 BLL — kasutab IAppUnitOfWork) │
12 + └────────────────────────────────────────────────────┘
13 + │ │ │
14 + ▼ ▼ ▼
15 + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
16 + │ Users │ │ Trips │ │ Expenses │
17 + │ │ │ │ │ │
18 + │ Domain │ │ Domain │ │ Domain │
19 + │ Application│ │ Application│ │ Application│
20 + │ Infra │ │ Infra │ │ Infra │
21 + │ Api │ │ Api │ │ Api │
22 + │ │ │ │ │ │
23 + │ schema: │ │ schema: │ │ schema: │
24 + │ users │ │ trips │ │ expenses │
25 + └─────────────┘ └─────────────┘ └─────────────┘
26 + │ ▲ │ ▲ │ ▲
27 + │ │ MediatR │ │ MediatR │ │ MediatR
28 + └──┴─────────────┴──┴─────────────┴──┘
29 + ┌─────────────────────────┐
30 + │ Shared.Contracts │
31 + │ IRequest / INotification│
32 + │ (Queries + Events) │
33 + └─────────────────────────┘
34 + ┌─────────────────────────┐
35 + │ Shared.Kernel │
36 + │ BaseEntity · LangStr │
37 + │ IdentityHelpers (JWT) │
38 + └─────────────────────────┘
39 +```
40 +
41 +**Põhimõte:** üks deployment, kolm sisemiselt isoleeritud moodulit. Iga moodul omab oma domeeni, andmeid (Postgres schema) ja teenuseid. Moodulid suhtlevad **ainult MediatR-i kaudu** — mitte ühtegi otseviidet teise mooduli sisemusele.
42 +
43 +---
44 +
45 +## 2. Lahenduse struktuur (`SplitApp.Modular/`)
46 +
47 +```
48 +SplitApp.sln
49 +├── src/
50 +│ ├── SplitApp.WebApp/ ← composition root, host, MVC + admin
51 +│ │ ├── Program.cs ← DI wiring, AddXxxModule(...)
52 +│ │ ├── Application/ ← Phase 2 BLL liigutatud siia
53 +│ │ │ ├── Services/ (+ Admin/, Identity/) ← TripService, ExpenseService, ...
54 +│ │ │ ├── DTO/ ← TripBllDto, ExpenseBllDto, ...
55 +│ │ │ ├── Mappers/ ← Domain↔BllDto factory mapperid
56 +│ │ │ └── Persistence/ ← AppUnitOfWork (3 mooduli DbContexti agregaator)
57 +│ │ ├── Areas/Admin/ ← admin UX, ViewModels, eraldi layout
58 +│ │ ├── Areas/Identity/ ← Razor Pages (Register)
59 +│ │ ├── Controllers/ ← klient-MVC kontrollerid
60 +│ │ └── Views/ ← klient-vaated
61 +│ ├── Shared/
62 +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepository, IUnitOfWork, LangStr, IdentityHelpers
63 +│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification
64 +│ └── Modules/
65 +│ ├── Users/
66 +│ │ ├── SplitApp.Modules.Users.Domain/ ← AppUser, AppRole, AppRefreshToken
67 +│ │ ├── SplitApp.Modules.Users.Application/ ← IIdentityService, MediatR handlerid
68 +│ │ ├── SplitApp.Modules.Users.Infrastructure/ ← UsersDbContext, repod, AddUsersModule
69 +│ │ └── SplitApp.Modules.Users.Api/ ← /api/v1/identity/...
70 +│ ├── Trips/ ← sama 4-projekti struktuur, schema "trips"
71 +│ └── Expenses/ ← sama 4-projekti struktuur, schema "expenses"
72 +└── tests/
73 + ├── SplitApp.Modules.Users.Tests/
74 + ├── SplitApp.Modules.Trips.Tests/
75 + ├── SplitApp.Modules.Expenses.Tests/
76 + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke
77 +```
78 +
79 +---
80 +
81 +## 3. Viidete reeglid (compiler-enforced + arch-test verified)
82 +
83 +| Allikas | Lubatud sihtmärgid | Märkused |
84 +|---------|-------------------|----------|
85 +| `Modules/X/<Layer>` (Application/Infrastructure/Api) | sama mooduli teised projektid + `Shared.Kernel` + `Shared.Contracts` | **Mitte teise mooduli projektidele** |
86 +| `Shared.*` | mitte ühelegi moodulile | |
87 +| `WebApp` | kõik kolm moodulit (Api + Infrastructure) + Shared | composition root |
88 +
89 +**Erand Domain tasandil:** Phase 2 vaate-renderdamise paarsuse hoidmiseks (`Trip.CreatedBy.Email`, `Expense.PaidByUser.FirstName` jne) on entiteedid säilitanud cross-module nav-property'd, kuid annotatsiooniga `[NotMapped]`, et EF kunagi ei ületaks Postgres schema piiri. Vaata [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) `[NotMapped]` lõiku.
90 +
91 +Architecture-testid jälgivad neid invariante (`tests/SplitApp.WebApp.IntegrationTests/Architecture/`):
92 +- `ModuleBoundaryTests` — ükski mooduli `Application`/`Infrastructure`/`Api` ei viita teisele moodulile
93 +- `DbContextSchemaIsolationTests` — iga DbContext sisaldab DbSet-e ainult oma mooduli entiteetidele
94 +- `CrossModuleNavigationTests` — cross-module nav-property on lubatud ainult `[NotMapped]`-iga
95 +- `HostBootSmokeTests` — `WebApplicationFactory<Program>` käivitab täis-host'i
96 +
97 +---
98 +
99 +## 4. Moodulite-vahene suhtlus (MediatR)
100 +
101 +Kõik kõned üle mooduli piiri lähevad läbi MediatR. Lepingud (`IRequest<T>` või `INotification`) elavad `Shared.Contracts/<Moodul>/{Queries|Events|Commands}/`. Handlerid omanik-mooduli `Application` või `Infrastructure` kihis.
102 +
103 +Saadetakse hetkel:
104 +
105 +| Leping | Omanik | Otstarve |
106 +|--------|--------|----------|
107 +| `GetUserByIdQuery : IRequest<UserDto?>` | Users | Trips/Expenses kasutavad nime kuvamiseks |
108 +| `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Partii-päring |
109 +| `UserDeletedEvent : INotification` | Users | Trips + Expenses tellivad — eemaldavad seotud kirjed |
110 +| `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module reisi-otsing |
111 +| `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | |
112 +| `IsTripParticipantQuery : IRequest<bool>` | Trips | IDOR-i kaitse Expenses controller-is |
113 +| `TripDeletedEvent : INotification` | Trips | Expenses tellib — kustutab kulud + arveldused |
114 +| `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | |
115 +| `GetBudgetCategorySpentQuery : IRequest<...>` | Expenses | Eelarvekategooria kulutused |
116 +| `SettlementPlanCompletedEvent : INotification` | Expenses | Trips tellib — märgib reisi "Settled" kui kõik makstud |
117 +
118 +---
119 +
120 +## 5. Andmeisolatsioon
121 +
122 +Iga moodul omab oma `DbContext`-i ja Postgres schema:
123 +
124 +- `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users`
125 +- `TripsDbContext : DbContext` → schema `trips`
126 +- `ExpensesDbContext : DbContext` → schema `expenses`
127 +
128 +Kõik kolm ühenduvad **samasse** Postgres andmebaasi (sama `ConnectionStrings:DefaultConnection`). Schemad — mitte eraldi DB-d — annavad isolatsiooni. **Cross-module SQL JOIN-id on keelatud**; cross-module andmed komponeerib WebApp facade läbi MediatR ja `CrossModuleNavigationLoader`-i.
129 +
130 +Cross-module entiteedi-viited on lihtsad `Guid` väljad **ilma** EF foreign-key piiranguta (nt `Trip.CreatedById : Guid` viitab kontseptuaalselt `users.AspNetUsers.Id`-le, aga mitte `FOREIGN KEY` kaudu). Andmete terviklikkus tagatakse:
131 +- Eel-MediatR valideerimispäringutega (nt `IsTripParticipantQuery` enne expense split-i salvestamist)
132 +- Domeeni-sündmustega kustutamisel (`UserDeletedEvent`, `TripDeletedEvent`)
133 +
134 +---
135 +
136 +## 6. Sõltuvuste graaf (mooduli sees — Clean Architecture)
137 +
138 +Iga moodul on iseseisev mini-Clean-Architecture:
139 +
140 +```
141 + Shared.Kernel (BaseEntity, contracts)
142 + ▲
143 + │
144 + Module.Domain ◄─── (entiteedid, enumid)
145 + ▲
146 + ┌──────┴──────┐
147 + │ │
148 + Module.Application │
149 + ▲ │
150 + │ │
151 + Module.Infrastructure (DbContext, repod, EF migrations)
152 + ▲
153 + │
154 + Module.Api (REST controllers, DTO-d)
155 + ▲
156 + │
157 + WebApp (composition root)
158 +```
159 +
160 +Iga mooduli `Infrastructure` registreerib enda `AddXxxModule(IConfiguration)` extension-meetodi. `WebApp/Program.cs` kutsub kõik kolm:
161 +
162 +```csharp
163 +builder.Services.AddUsersModule(builder.Configuration);
164 +builder.Services.AddTripsModule(builder.Configuration);
165 +builder.Services.AddExpensesModule(builder.Configuration);
166 +```
167 +
168 +---
169 +
170 +## 7. Phase 3 ↔ Phase 2 vastendus
171 +
172 +| Phase 2 projekt (kustutatud) | Phase 3 sihtmärk |
173 +|---|---|
174 +| `Base.Domain`, `Base.Contracts` | `Shared.Kernel` |
175 +| `Base.Helpers` (IdentityHelpers) | `Shared.Kernel.Auth` |
176 +| `App.Domain.Identity.*` | `Modules/Users/SplitApp.Modules.Users.Domain/Entities/` |
177 +| `App.Domain.{Trip, TripParticipant, ...}` | `Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/` |
178 +| `App.Domain.{Expense, SettlementPlan, ..., Currency}` | `Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/` |
179 +| `App.DAL.EF.AppDbContext` | jagatud 3-ks: `UsersDbContext`, `TripsDbContext`, `ExpensesDbContext` |
180 +| `App.BLL.Services.Identity.*` | `Modules/Users/.../Application/Services/` |
181 +| `App.BLL.Services.*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/` (composition-root facade kõigi 3 mooduli UoW peal) |
182 +| `App.BLL.{DTO, Mappers}` | `WebApp/Application/{DTO, Mappers}` |
183 +| `App.Resources/Domain/*.resx` | (üks ühtne) `WebApp/Resources/Views/Shared.{resx,et.resx}` |
184 +| `WebApp.ApiControllers.Identity.*` | `Modules/Users/.../Api/Controllers/` |
185 +| `WebApp.ApiControllers.{Trips, ...}` | `Modules/Trips/.../Api/Controllers/` |
186 +| `WebApp.ApiControllers.{Expenses, Currencies, Settlements, SplitPresets}` | `Modules/Expenses/.../Api/Controllers/` |
187 +| `WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` | `SplitApp.WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` (struktuur sama; namespace re-rooted `SplitApp.WebApp.*`) |
188 +
189 +---
190 +
191 +## 8. Käivitamine
192 +
193 +**Tootmine (deployd):** https://travel.rasmusj.com/
194 +
195 +**Lokaalselt:**
196 +
197 +```bash
198 +# Repo juurest
199 +docker compose up --build
200 +```
201 +
202 +Tõuseb üles:
203 +- `phase3` → http://localhost:90 (host port `90` → container port `8080`)
204 +- `phase3-db` (PostgreSQL 16) — ainult Docker sisevõrgus, host port pole avatud
205 +
206 +Iga mooduli migratsioonid jooksevad automaatselt host-i käivitamisel (vt `*ModuleExtensions.UseXxxModule()`).
207 +
208 +```bash
209 +# Testid (25 testi 4 projektis)
210 +cd SplitApp.Modular
211 +dotnet test
212 +```
213 +
214 +---
215 +
216 +## 9. Miks modulaarne monoliit
217 +
218 +| Lähenemine | Probleem |
219 +|------------|----------|
220 +| Klassikaline monoliit | "Kõik viitab kõigele" — üks muudatus → kaskaad-mõju mujal |
221 +| Mikroteenused | Hajusüsteemide põrgu — võrk, serialiseerimine, eventual consistency |
222 +| **Modulaarne monoliit** | **Selged piirid (nagu mikroteenustel) + lihtne deployment (nagu monoliidil)** |
223 +
224 +Vaata pikemat juttu kursuse [modularmonolith.md](modularmonolith.md) failist või [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md)-ist.
225 +
226 +---
227 +
228 +## Kokkuvõte
229 +
230 +SplitApp Phase 3 on **modulaarne monoliit** kolme isoleeritud mooduliga (Users, Trips, Expenses). Iga moodul on iseseisev mini-Clean-Architecture oma `Domain`/`Application`/`Infrastructure`/`Api` projektidega ja oma Postgres schema. Cross-module suhtlus käib eranditult **MediatR-i** kaudu (`IRequest`/`INotification`), mitte otsesete `<ProjectReference>`-ite kaudu. Architecture-testid lukustavad need invariandid CI-ajal.
added docker-compose.yml +32 −0
@@ -0,0 +1,32 @@
1 +services:
2 + db:
3 + image: postgres:16
4 + container_name: phase3-db
5 + restart: unless-stopped
6 + environment:
7 + POSTGRES_DB: splitapp
8 + POSTGRES_USER: postgres
9 + POSTGRES_PASSWORD: postgres
10 + volumes:
11 + - phase3-pgdata:/var/lib/postgresql/data
12 +
13 + phase3:
14 + build:
15 + context: .
16 + dockerfile: Dockerfile
17 + container_name: phase3
18 + restart: unless-stopped
19 + ports:
20 + - "90:8080"
21 + environment:
22 + - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=splitapp;Username=postgres;Password=postgres
23 + - JWT__Key=phase3_modular_monolith_signing_key_at_least_32_chars_long_for_hs256
24 + - JWT__Issuer=itcollege.taltech.ee
25 + - JWT__Audience=itcollege.taltech.ee
26 + - JWT__ExpiresInSeconds=1800
27 + - ASPNETCORE_URLS=http://+:8080
28 + depends_on:
29 + - db
30 +
31 +volumes:
32 + phase3-pgdata:
added docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0

Line changes are not available for this file.

added docs/grouptravel.png +0 −0

Line changes are not available for this file.

added explanation.md +313 −0
@@ -0,0 +1,313 @@
1 +# SplitApp — Reisikulude haldamise rakendus (Phase 3 — Modulaarne Monoliit)
2 +
3 +## Ülevaade
4 +
5 +SplitApp on ASP.NET Core 10.0 veebirakendus grupireisi kulude jagamiseks ja haldamiseks. Kasutajad loovad reise, kutsuvad sõpru, lisavad kulusid paindliku jagamisega, haldavad eelarvet, peavad küsitlusi, soovinimekirju ja arveldavad võlgu optimeeritud algoritmiga.
6 +
7 +Phase 3 refaktorib Phase 2 Clean/Onion monoliidi **modulaarseks monoliidiks**: üks deployable, kolm sisemiselt isoleeritud moodulit (Users, Trips, Expenses), MediatR moodulite vaheliseks suhtluseks, schema-per-moodul Postgres-i isolatsioon.
8 +
9 +Projekt on tehtud TalTech kursuse "Web Applications with C#" **Personal Project — Phase 3** raames.
10 +
11 +---
12 +
13 +## 0. Phase 3 nõuete täitmine
14 +
15 +`phase3.md` ütleb:
16 +> *Implement your project in aspnet.core in modular monolith architecture (make copy of phase2, new repo). Split out into at least 3 modules (users, 2 of your own). Use mediator for communication between modules. No direct references between modules.*
17 +
18 +| # | Nõue | Staatus | Asukoht |
19 +|---|------|---------|---------|
20 +| 1 | ASP.NET Core modulaarne monoliit | ✅ | Kogu [`SplitApp.Modular/`](SplitApp.Modular/) — üks `WebApp` host, 3 moodulit |
21 +| 2 | Vähemalt 3 moodulit (users + 2 omad) | ✅ | **Users**, **Trips**, **Expenses** [`SplitApp.Modular/src/Modules/`](SplitApp.Modular/src/Modules/) |
22 +| 3 | MediatR moodulite-vaheliseks suhtluseks | ✅ | 11 lepingut [`Shared.Contracts/`](SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/) — `GetUserByIdQuery`, `TripDeletedEvent`, `IsTripParticipantQuery`, `SettlementPlanCompletedEvent` jne |
23 +| 4 | Mitte mingeid otseseid viiteid moodulite vahel | ⚠️ Application/Infrastructure/Api kihil ✅, Domain kihil **kõrvalekalle** | Vaata [§3](#3-mooduli-piirid--viidete-reeglid) |
24 +
25 +Phase 2 nõuded on kõik säilitatud Phase 3-s:
26 +- 19 entiteeti (3 Users + 9 Trips + 7 Expenses)
27 +- REST API + versioneerimine + Swagger
28 +- JWT auth (`Shared.Kernel.Auth.IdentityHelpers`)
29 +- Klient-MVC + Admin Area + Identity Razor Pages
30 +- i18n UI (resx) + i18n DB (LangStr)
31 +- IDOR (kasutaja näeb ainult oma andmeid REST-is — `IsTripParticipantQuery` MediatR-i kaudu)
32 +- CI/CD deploy (Dockerfile + docker-compose)
33 +- Repositories + UoW + Services + BLL + Mappers (lifted phase 2 BLL `WebApp/Application/`-i)
34 +
35 +---
36 +
37 +## 1. Arhitektuur — Modulaarne Monoliit
38 +
39 +```
40 + ┌────────────────────────────────────────────────────┐
41 + │ SplitApp.WebApp (Composition Root) │
42 + │ Program.cs · Controllers · Areas/Admin · Views │
43 + │ Application/{Services, DTO, Mappers, Persistence}│
44 + └────────────────────────────────────────────────────┘
45 + │ │ │
46 + ▼ ▼ ▼
47 + ┌─────────┐ ┌─────────┐ ┌─────────┐
48 + │ Users │ │ Trips │ │ Expenses│
49 + │ Domain │ │ Domain │ │ Domain │
50 + │ App │ │ App │ │ App │
51 + │ Infra │ │ Infra │ │ Infra │
52 + │ Api │ │ Api │ │ Api │
53 + │ schema: │ │ schema: │ │ schema: │
54 + │ users │ │ trips │ │ expenses│
55 + └─────────┘ └─────────┘ └─────────┘
56 + ▲ ▲ ▲
57 + └─MediatR───┴─MediatR───┘
58 + ┌──────────────────────┐ ┌──────────────────────┐
59 + │ Shared.Contracts │ │ Shared.Kernel │
60 + │ IRequest/INotification│ │ BaseEntity, LangStr │
61 + └──────────────────────┘ └──────────────────────┘
62 +```
63 +
64 +**Põhimõte:** üks deployable, kolm isoleeritud moodulit. Iga moodul = mini-Clean-Architecture (Domain/Application/Infrastructure/Api). Cross-module kõned eranditult MediatR-iga.
65 +
66 +### Kursuse loengu võtmelaused (modular monolith — `modularmonolith.md`)
67 +
68 +> *"Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext."*
69 +
70 +> *"Communication goes through: contracts (interfaces in shared project) and domain events (in-process, loose coupling)."*
71 +
72 +> *"Each module has its own DbContext scoped to its tables — modules don't share database contexts."*
73 +
74 +Meie projekt järgib seda:
75 +- `Modules/Users/Application` ei viita `Modules/Trips/*`-le ega `Modules/Expenses/*`-le
76 +- Kui `Trips` vajab kasutaja-nime, saadab ta `GetUserByIdQuery` MediatR-i kaudu — Users module's handler vastab
77 +- Iga moodul omab oma `DbContext`-i ja Postgres schema (cross-module SQL JOIN-id keelatud)
78 +
79 +---
80 +
81 +## 2. Lahenduse struktuur
82 +
83 +```
84 +SplitApp.Modular/
85 +├── SplitApp.sln
86 +├── Directory.Build.props
87 +└── src/
88 + ├── SplitApp.WebApp/ ← composition root, host
89 + │ ├── Program.cs ← DI wiring, AddXxxModule(...)
90 + │ ├── Application/ ← Phase 2 BLL liigutatud
91 + │ │ ├── Services/ (+ Admin/, Identity/) ← TripService, ExpenseService, ...
92 + │ │ ├── DTO/ ← TripBllDto, ExpenseBllDto, ...
93 + │ │ ├── Mappers/ ← Domain↔BllDto factory mapperid
94 + │ │ ├── Persistence/AppUnitOfWork.cs ← agregeerib 3 mooduli DbContext-id
95 + │ │ ├── Persistence/CrossModuleNavigationLoader.cs ← hüdreerib [NotMapped] cross-navsid
96 + │ │ └── Contracts/IAppUnitOfWork.cs ← Phase 2 stiilis facade
97 + │ ├── Areas/Admin/ ← admin UX
98 + │ ├── Areas/Identity/ ← Razor Register
99 + │ ├── Controllers/ ← klient MVC
100 + │ ├── Views/ ← klient vaated
101 + │ └── Resources/ ← i18n .resx
102 + ├── Shared/
103 + │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
104 + │ └── SplitApp.Shared.Contracts/ ← MediatR contracts
105 + └── Modules/
106 + ├── Users/ ← AppUser, AppRole, AppRefreshToken; JWT issuance
107 + ├── Trips/ ← Trip, TripParticipant, TripPoll, TripWishlistItem, BudgetCategory, ...
108 + └── Expenses/ ← Expense, ExpenseSplit, SettlementPlan, SettlementPayment, Currency, SplitPreset, ...
109 +```
110 +
111 +`tests/` sisaldab:
112 +- 3 mooduli unit-teste (CurrencyConverter, LangStr, IdentityHelpers)
113 +- `WebApp.IntegrationTests/Architecture/` — `ModuleBoundaryTests`, `DbContextSchemaIsolationTests`, `CrossModuleNavigationTests`
114 +- `WebApp.IntegrationTests/HostBootSmokeTests` + `HostFeatureTests` — `WebApplicationFactory<Program>` HTTP-smoke + ristlõikeliste nõuete testid
115 +
116 +Kokku **44 testi** — kõik green.
117 +
118 +---
119 +
120 +## 3. Mooduli piirid — viidete reeglid
121 +
122 +Compiler-enforced + verifitseeritud architecture-testidega.
123 +
124 +| Allikas | Lubatud sihtmärgid | Märkus |
125 +|---------|-------------------|--------|
126 +| `Modules/X/Application` | sama mooduli `Domain` + `Shared.Kernel` + `Shared.Contracts` | |
127 +| `Modules/X/Infrastructure` | sama mooduli `Domain` + `Application` + `Shared.Kernel` | |
128 +| `Modules/X/Api` | sama mooduli `Application` + `Shared.Kernel` + `Shared.Contracts` | |
129 +| `Shared.*` | mitte ühelegi moodulile | |
130 +| `WebApp` | kõik 3 mooduli `Api` + `Infrastructure` + `Shared.*` | composition root |
131 +
132 +**Kõrvalekalle Domain tasandil:** entiteedi-klassidel on cross-module nav-property'd (`Trip.CreatedBy`, `Expense.PaidByUser`, `Trip.DefaultCurrency` jms), kõik `[NotMapped]`-iga märgitud. Et tüübid (`AppUser`, `Currency`, `Expense`) kompileeruksid, on Domain-csproj-idel viited:
133 +
134 +```
135 +Modules/Trips/Domain.csproj → Modules/Users/Domain + Modules/Expenses/Domain
136 +Modules/Expenses/Domain.csproj → Modules/Users/Domain
137 +```
138 +
139 +**Miks see olemas on?** Iga moodul käib ainult oma DbContext-i kaudu — ehk `ExpensesDbContext` ei tea midagi `users` skeemist. Aga UI peab näitama "kulu 80€ — maksis Alice Johnson". WebApp-i fassaad ([`AppUnitOfWork`](SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs)) lahendab selle nii: tõmbab kõigepealt `Expense`-id Expenses-DB-st, siis päring Users-DB-st õigete `AppUser`-ite järgi, ja **käsitsi** C#-koodis paneb `expense.PaidByUser = user`. Et see omistamine oleks **tüübikindel** (kompilaator kontrollib, IDE pakub autocomplete'i, vead leitakse build-i ajal — mitte runtime'is), peab `Expense`-klass teadma `AppUser` tüüpi → siit Domain-csproj-viide.
140 +
141 +`[NotMapped]` tagab samal ajal, et **andmebaas jääb sellest täiesti puutumata** — EF ei tee veergu, ei tee JOIN-i, ei näe seda välja. Schema-isolatsioon säilib täielikult.
142 +
143 +**Kompromiss:** Application/Infrastructure/Api jäävad puhtaks ja kasutavad MediatR-i. Schema-isolatsioon + suhtluse-isolatsioon runtime-tasemel on 100% säilitatud. Ainult entiteedi-*tüübid* on jagatud — see on **C# tüübisüsteemi mugavus mälus-ühendamise jaoks**, mitte funktsionaalne sõltuvus.
144 +
145 +`CrossModuleNavigationTests` kindlustab: kui keegi proovib teha *mapped* (mitte-`[NotMapped]`) cross-module nav-i, siis test kukub.
146 +
147 +---
148 +
149 +## 4. Moodulite-vaheline suhtlus (MediatR)
150 +
151 +Lepingud elavad `Shared.Contracts/<Moodul>/{Queries|Events|Commands}/`. Iga leping on `record` mis implementeerib kas:
152 +- `IRequest<T>` — sünkroonne päring/käsk (üks vastus)
153 +- `INotification` — fan-out sündmus (mitu tellijat)
154 +
155 +Saadetakse hetkel 11 lepingut:
156 +
157 +| Leping | Omanik | Otstarve |
158 +|--------|--------|----------|
159 +| `GetUserByIdQuery → UserDto?` | Users | Trips/Expenses kasutavad nime kuvamiseks |
160 +| `GetUsersByIdsQuery → IReadOnlyList<UserDto>` | Users | Partii-päring |
161 +| `UserDeletedEvent` (notification) | Users | Trips + Expenses tellivad — eemaldavad seotud kirjed |
162 +| `GetTripByIdQuery → TripSummaryDto?` | Trips | Cross-module reisi-otsing |
163 +| `GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto>` | Trips | |
164 +| `IsTripParticipantQuery → bool` | Trips | **IDOR-i kaitse**: ExpensesController kasutab seda enne expense-i salvestamist |
165 +| `TripDeletedEvent` (notification) | Trips | Expenses tellib — kustutab kulud + arveldused |
166 +| `GetTripExpenseTotalsQuery → TripExpenseTotalsDto` | Expenses | |
167 +| `GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal>` | Expenses | Eelarve-kategooria kulutused |
168 +| `ExpenseSettledEvent` (notification) | Expenses | Reserveeritud tulevikuks |
169 +| `SettlementPlanCompletedEvent` (notification) | Expenses | Trips tellib — kui kõik maksed kinnitatud, märgib reisi "Settled" |
170 +
171 +**Näide voost:** kasutaja loob expense-i (`POST /api/v1/expenses`):
172 +1. `ExpensesController` saadab `IsTripParticipantQuery(tripId, userId)` → MediatR
173 +2. Trips mooduli `IsTripParticipantHandler` kontrollib `TripsDbContext`-st — tagastab `bool`
174 +3. Kui `false` → 403 Forbidden (IDOR-i kaitse)
175 +4. Kui `true` → expense salvestub `ExpensesDbContext`-i (schema `expenses`)
176 +
177 +---
178 +
179 +## 5. Andmeisolatsioon
180 +
181 +Üks Postgres andmebaas, kolm schemat. Kolm `DbContext`-i ühenduvad sama `ConnectionStrings:DefaultConnection`-i kaudu, aga igaüks kasutab `b.HasDefaultSchema("...")`-d:
182 +
183 +| DbContext | Schema | Sisu |
184 +|-----------|--------|------|
185 +| `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` | `users` | AspNetUsers, AspNetRoles, RefreshTokens, DataProtectionKeys |
186 +| `TripsDbContext : DbContext` | `trips` | Trips, TripParticipants, TripInvitations, TripPolls, TripWishlistItems, BudgetCategories |
187 +| `ExpensesDbContext : DbContext` | `expenses` | Expenses, ExpenseSplits, SettlementPlans, SettlementPayments, Currencies, SplitPresets |
188 +
189 +**Cross-module SQL JOIN-id on keelatud.** EF kunagi ei lähe schema piirist üle, sest cross-module nav-property'd on `[NotMapped]`. Cross-module andmete komponeerimine toimub:
190 +1. WebApp facade `AppUnitOfWork`-is — repod hüdreerivad cross-module navsid C#-is pärast põhipäringut (vt `CrossModuleHydration.HydrateUsersAsync`, `HydrateTripsAsync`)
191 +2. Või MediatR-i kaudu — `ExpensesController` küsib Users-mooduli käest `GetUsersByIdsQuery`-iga
192 +
193 +**Andmete terviklikkus** (cross-module FK puudub) tagatakse:
194 +- Eel-MediatR valideerimispäringutega (`IsTripParticipantQuery`)
195 +- Domeeni-sündmustega kustutamisel (`UserDeletedEvent`, `TripDeletedEvent`)
196 +
197 +---
198 +
199 +## 6. Phase 2 BLL "lifted" struktuur WebApp-is
200 +
201 +Phase 3 ei loo uut BLL-i; selle asemel **liigutab Phase 2 BLL koodi `WebApp/Application/`** alla. See on praktiline kompromiss, mis hoiab Phase 2 100% paarsust UX-iga ja säästab refaktori-aega.
202 +
203 +| Phase 2 projekt | Phase 3 sihtmärk |
204 +|---|---|
205 +| `App.BLL/Services/*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/*` |
206 +| `App.BLL/Services/Admin/*` (12 admin-teenust) | `WebApp/Application/Services/Admin/*` |
207 +| `App.BLL/Services/Identity/*` | `Modules/Users/Application/Services/` (siiski liigutatud Users-moodulisse) |
208 +| `App.BLL/DTO/*` | `WebApp/Application/DTO/*` |
209 +| `App.BLL/Mappers/*BllDtoFactory.cs` | `WebApp/Application/Mappers/*` |
210 +| `App.Domain/Contracts/IAppUnitOfWork.cs` | `WebApp/Application/Contracts/IAppUnitOfWork.cs` |
211 +| `App.DAL.EF.AppUnitOfWork` | `WebApp/Application/Persistence/AppUnitOfWork.cs` (3 DbContext-i agregaator) |
212 +| `App.DAL.EF.Repositories.*` | inline `WebApp/Application/Persistence/AppUnitOfWork.cs` (TripRepo, ExpenseRepo jne) |
213 +
214 +Lifted BLL teenused töötavad endiselt `IAppUnitOfWork`-i kaudu. Teenuse vaatest pole midagi muutunud — `_uow.Trips.GetByIdAsync(...)` käitub samamoodi nagu Phase 2-s. Tegelikkuses suunab `AppUnitOfWork.Trips` päringud `TripsDbContext`-le, ja `CrossModuleHydration` täidab cross-module nav-id (`Trip.CreatedBy` jms) tagantjärele eraldi päringuga.
215 +
216 +---
217 +
218 +## 7. Käivitamine
219 +
220 +**Tootmine (deployd):** https://travel.rasmusj.com/
221 +
222 +**Lokaalselt** repo juurest:
223 +
224 +```bash
225 +docker compose up --build
226 +```
227 +
228 +Tõuseb üles:
229 +- `phase3` → http://localhost:90 (host port `90` → container port `8080`)
230 +- `phase3-db` (PostgreSQL 16) — ainult Docker sisevõrgus, host port pole avatud
231 +
232 +Iga mooduli migratsioonid jooksevad automaatselt host-i käivitumisel (vt `*ModuleExtensions.UseXxxModule(...)`-it).
233 +
234 +### Testid
235 +
236 +```bash
237 +cd SplitApp.Modular
238 +dotnet test
239 +```
240 +
241 +Roheliseks läheb **44 testi**:
242 +
243 +| Projekt | Testid | Mida katavad |
244 +|---|---:|---|
245 +| `SplitApp.Modules.Users.Tests` | 8 | `IdentityHelpers` — JWT genereerimine + valideerimine + tagasilükkamine vale issuer/audience/võtme/malformed-tokeni puhul |
246 +| `SplitApp.Modules.Trips.Tests` | 12 | `LangStr` — mitme-keele tõlke teisendus + fallback + tühi/null/error piirjuhud |
247 +| `SplitApp.Modules.Expenses.Tests` | 10 | `CurrencyConverter` — kursi-teisendused + null/negatiivsed summad + ümardamine + round-trip täpsus |
248 +| `SplitApp.WebApp.IntegrationTests` | 14 | Arhitektuuri invariandid (mooduli piirid, schema-isolatsioon, `[NotMapped]` reegel) + `WebApplicationFactory` HTTP-smoke (Home/, Swagger UI + v1 doc, Admin auth-redirect, REST API JWT-nõue, lokaliseerimine `?culture=et`) |
249 +
250 +**Unit-testid** ei vaja andmebaasi — käivituvad millisekundites ja testivad puhast loogikat (kursi-teisendus, JWT-token, LangStr). **Integration-testid** käivitavad reaalse WebApp hosti mälus (`WebApplicationFactory<Program>` + `"Testing"` keskkond, kus migratsioonid skiipitakse) ja teevad HTTP-päringuid — see kinnitab, et iga ristlõikeline nõue (Swagger, JWT, Admin kaitse, i18n) on päriselt töökorras, mitte ainult konfiguratsioonis olemas.
251 +
252 +---
253 +
254 +## 8. URL kaart
255 +
256 +| URL | Otstarve |
257 +|-----|----------|
258 +| `/` | Avalehe MVC |
259 +| `/Trips`, `/Trips/Create`, `/Trips/Details/{id}`, ... | Reisi CRUD |
260 +| `/Members?tripId={id}` ja `/Members/AcceptInvitation/{token}` | Osalejad + kutse |
261 +| `/Expenses?tripId={id}` (Create/Edit/Delete) | Kulud reisi kohta |
262 +| `/Budget?tripId={id}` (CreateCategory/EditCategory/DeleteCategory) | Eelarvekategooriad |
263 +| `/Settlement?tripId={id}` | Bilanss + arveldusplaanid |
264 +| `/PollsClient?tripId={id}` (Create/Details) | Reisi küsitlused |
265 +| `/WishlistClient?tripId={id}` | Soovinimekiri |
266 +| `/Identity/Account/Register` | Cookie-põhine registreerumine |
267 +| `/Admin/Dashboard` | Admin avaleht (`admin` roll) |
268 +| `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD |
269 +| `/swagger` | Swagger UI |
270 +
271 +### REST API
272 +
273 +| Moodul | Endpoint-id |
274 +|--------|-------------|
275 +| Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` |
276 +| Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` |
277 +| Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` |
278 +
279 +---
280 +
281 +## 9. Architecture-testid (mooduli piiride lukustamine)
282 +
283 +`tests/SplitApp.WebApp.IntegrationTests/Architecture/`:
284 +
285 +1. **`ModuleBoundaryTests`** — ükski mooduli `Application`/`Infrastructure`/`Api` `<ProjectReference>` ei viita teisele moodulile; `Shared.*` ei viita ühelegi moodulile.
286 +2. **`DbContextSchemaIsolationTests`** — iga `DbContext` sisaldab `DbSet<T>`-e ainult oma mooduli `Domain` projektist.
287 +3. **`CrossModuleNavigationTests`** — cross-module nav-property on lubatud ainult `[NotMapped]`-iga.
288 +4. **`HostBootSmokeTests`** — `WebApplicationFactory<Program>` käivitab täis-host'i `Testing` keskkonnas (skiipib migratsioonid), `/`, `/Home/Index` ja autentimata API-päring tagastab 401.
289 +5. **`HostFeatureTests`** — ristlõikeliste nõuete HTTP-tasandil kontroll: Swagger v1 doc + UI serveeritakse, `/Admin/*` suunab autentimata kasutaja Identity login-lehele, `/api/v1/expenses/*` nõuab JWT-d (teine moodul, sama reegel), `?culture=et` ei riku request-localization ahelat.
290 +
291 +Kui mõni neist langeb, on keegi rikkunud modulaarmonoliidi invariandi või ristlõikelise nõude.
292 +
293 +---
294 +
295 +## 10. Miks modulaarne monoliit?
296 +
297 +| Lähenemine | Probleem |
298 +|------------|----------|
299 +| Klassikaline monoliit | Kõik viitab kõigele — üks muudatus → kaskaad-mõju |
300 +| Mikroteenused | Hajusüsteemide põrgu — võrk, serialiseerimine, eventual consistency, deployment-keerukus |
301 +| **Modulaarne monoliit** | **Selged piirid (nagu mikroteenustel) + lihtne deployment (nagu monoliidil)** |
302 +
303 +Ekstraheerimise tee tulevikus, kui peaks vaja minema:
304 +- *Klassikaline monoliit → Modulaarne monoliit → Mikroteenused*
305 +- Iga moodul juba omab oma schema, oma lepingud, oma `DbContext`-i. Mooduli eraldamine teenuseks tähendab in-process MediatR-kõnete asendamist HTTP/gRPC-ga ning sündmuste viimist message brokeri peale. Koodi struktuur ei muutu — ainult transport-kiht.
306 +
307 +Vaata kursuse [modularmonolith.md](modularmonolith.md) faili pikemaks aruteluks.
308 +
309 +---
310 +
311 +## Kokkuvõte
312 +
313 +**SplitApp Phase 3 on modulaarne monoliit kolme isoleeritud mooduliga (Users, Trips, Expenses).** Iga moodul on iseseisev mini-Clean-Architecture oma `Domain`/`Application`/`Infrastructure`/`Api` projektidega ja oma Postgres schema. Cross-module suhtlus käib ainult **MediatR**-i kaudu (`IRequest`/`INotification`), mitte otseste `<ProjectReference>`-ite kaudu. Architecture-testid lukustavad need invariandid CI ajal. Kõigil Phase 2 nõuetel (REST API + versioning + Swagger, JWT, MVC + Admin Area, i18n, IDOR, Repos/UoW/Services/BLL/Mappers, CI/CD, testid) on Phase 3-s täielik kate.
added modularmonolith.md +328 −0
@@ -0,0 +1,328 @@
1 +Modular Monolith
2 +One deployable, but internally split into self-contained modules. Each module owns its domain, data, and services. Modules communicate through well-defined interfaces — not by reaching into each other's guts.
3 +
4 +Think of it as: Clean Architecture applied per feature/domain, all living in one process.
5 +
6 +Why
7 +
8 +Approach Problem
9 +Classic monolith Everything references everything. One change -> cascade everywhere
10 +Microservices Distributed systems hell. Network, serialization, eventual consistency. Overkill for most teams
11 +Modular monolith Clean boundaries like microservices, deployment simplicity of a monolith. Split later if you actually need to
12 +Structure
13 +
14 +MyApp.sln
15 +├── MyApp.Web // Composition root, routing, DI wiring
16 +│
17 +├── Modules/
18 +│ ├── MyApp.Modules.Persons/
19 +│ │ ├── Domain/ // Entities, interfaces
20 +│ │ ├── Application/ // Services, DTOs
21 +│ │ ├── Infrastructure/ // EF configs, repos
22 +│ │ └── Api/ // Controllers or endpoints
23 +│ │
24 +│ ├── MyApp.Modules.Orders/
25 +│ │ ├── Domain/
26 +│ │ ├── Application/
27 +│ │ ├── Infrastructure/
28 +│ │ └── Api/
29 +│ │
30 +│ └── MyApp.Modules.Notifications/
31 +│ ├── Domain/
32 +│ ├── Application/
33 +│ ├── Infrastructure/
34 +│ └── Api/
35 +│
36 +├── MyApp.Shared.Contracts/ // Cross-module interfaces, shared DTOs
37 +└── MyApp.Shared.Infrastructure/ // Common utilities, base classes
38 +
39 +Each module is a mini Clean Architecture. Each module has its own DbContext scoped to its tables — modules don't share database contexts.
40 +
41 +The golden rule
42 +
43 +Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext.
44 +
45 +Communication goes through:
46 +
47 +Contracts (interfaces in shared project):
48 +Domain events (in-process, loose coupling):
49 +// Shared
50 +public record PersonDeletedEvent(int PersonId);
51 +
52 +// Persons module publishes (replaced with messaging in microservices)
53 +await _mediator.Publish(new PersonDeletedEvent(id));
54 +
55 +// Orders module handles
56 +public class PersonDeletedHandler : INotificationHandler<PersonDeletedEvent>
57 +{
58 + public async Task Handle(PersonDeletedEvent e, CancellationToken ct)
59 + {
60 + // cancel pending orders, clean up references
61 + }
62 +}
63 +
64 +Separate DbContexts per module
65 +
66 +This is what enforces the boundary at the data level:
67 +
68 +// Persons module — only sees Person tables
69 +public class PersonDbContext : DbContext
70 +{
71 + public DbSet<Person> Persons => Set<Person>();
72 + public DbSet<Address> Addresses => Set<Address>();
73 +
74 + protected override void OnModelCreating(ModelBuilder b)
75 + {
76 + b.HasDefaultSchema("persons"); // schema isolation
77 + }
78 +}
79 +
80 +// Orders module — only sees Order tables
81 +public class OrderDbContext : DbContext
82 +{
83 + public DbSet<Order> Orders => Set<Order>();
84 + public DbSet<OrderLine> OrderLines => Set<OrderLine>();
85 +
86 + protected override void OnModelCreating(ModelBuilder b)
87 + {
88 + b.HasDefaultSchema("orders");
89 + }
90 +}
91 +
92 +Same database server, different schemas. No cross-module joins. If Orders needs person data, it goes through IPersonModuleApi, not a SQL join.
93 +
94 +Coupling vs Cohesion — the two metrics that matter
95 +
96 +Coupling = how much modules depend on each other's internals.
97 +
98 +High Coupling Low Coupling
99 +How Direct references, shared DB context, calling internal methods Contracts, events, shared DTOs only
100 +Change impact Touch one module -> break three others Touch one module -> others don't notice
101 +Testing Need to spin up half the app Test module in isolation
102 +Cohesion = how related the stuff inside a module is.
103 +
104 +High Cohesion Low Cohesion
105 +How Everything in the module serves one domain concept Grab-bag of unrelated utilities
106 +Example PersonService + PersonRepository + PersonValidator UtilityService with email + tax + image resize
107 +Symptom Module name describes exactly what's inside Module name is vague ("Helpers", "Common", "Utils")
108 +The goal: low coupling between modules, high cohesion within modules.
109 +
110 +"How do I know if my module boundaries are right?" — the answer is: look at how often a change in one module forces a change in another. If it's frequent, your boundary is in the wrong place. The communication patterns (contracts, events) should cross boundaries rarely, not on every request.
111 +
112 +The migration path — why this matters practically
113 +
114 +Classic Monolith -> Modular Monolith -> Microservices (if you ever actually need to)
115 +
116 +Each module already has its own schema, its own contracts, its own DbContext. Extracting a module into a separate service later means replacing in-process IPersonModuleApi calls with HTTP/gRPC calls, and domain events with a message broker. The code structure barely changes — only the transport layer does.
117 +
118 +Most teams never need that last step. The modular monolith gives you 90% of the organizational benefits of microservices with none of the distributed systems pain.
119 +
120 +MyApp — Single Deployment
121 +
122 +Shared Contracts
123 +
124 +Notifications Module
125 +
126 +Orders Module
127 +
128 +Persons Module
129 +
130 +Web / Composition Root
131 +
132 +uses contract
133 +
134 +implements
135 +
136 +subscribes to
137 +
138 +publishes
139 +
140 +IPersonModuleApi
141 +
142 +Api
143 +
144 +Application
145 +
146 +Domain
147 +
148 +Infrastructure
149 +
150 +persons schema
151 +
152 +Api
153 +
154 +Application
155 +
156 +Domain
157 +
158 +Infrastructure
159 +
160 +orders schema
161 +
162 +Api
163 +
164 +Application
165 +
166 +Domain
167 +
168 +Infrastructure
169 +
170 +notifications schema
171 +
172 +Routing
173 +
174 +Domain Events
175 +
176 +IOrderModuleApi
177 +
178 +DI Wiring
179 +
180 +OK Low Coupling (modular monolith)
181 +
182 +publishes event
183 +
184 +subscribes
185 +
186 +calls contract
187 +
188 +implements
189 +
190 +subscribes
191 +
192 +Person Module
193 +
194 +Event Bus
195 +
196 +Order Module
197 +
198 +IPersonModuleApi
199 +
200 +Notification Module
201 +
202 +BAD High Coupling (classic monolith)
203 +
204 +direct DB access
205 +
206 +references
207 +
208 +direct DB access
209 +
210 +references
211 +
212 +calls internal method
213 +
214 +references everything
215 +
216 +Person Service
217 +
218 +Order Tables
219 +
220 +Order Entities
221 +
222 +Order Service
223 +
224 +Person Tables
225 +
226 +Person Entities
227 +
228 +Notification Service
229 +
230 +BAD Low Cohesion (grab bag service)
231 +
232 +UtilityService
233 +
234 +SendEmail
235 +
236 +CalculateTax
237 +
238 +ResizeImage
239 +
240 +ValidatePerson
241 +
242 +GenerateReport
243 +
244 +OK High Cohesion (inside a module)
245 +
246 +PersonService
247 +
248 +PersonRepository
249 +
250 +PersonValidator
251 +
252 +PersonMapper
253 +
254 +Person Entity
255 +
256 +Eventual Consistency
257 +In a monolith with one database, you get immediate consistency — save data, read it back, it's there. One transaction, one commit, done.
258 +
259 +The moment you split into modules, services, or separate databases — that guarantee breaks. You get eventual consistency instead: changes propagate, but not instantly. There's a window where different parts of the system see different data.
260 +
261 +The classic example
262 +
263 +User places an order. Three things need to happen:
264 +
265 +Save the order (Orders module)
266 +Reserve inventory (Inventory module)
267 +Send confirmation email (Notifications module)
268 +Immediate consistency approach — distributed transaction:
269 +
270 +BEGIN TRANSACTION
271 + Insert order → Orders DB
272 + Decrement stock → Inventory DB
273 + Queue email → Notifications DB
274 +COMMIT
275 +
276 +This is a distributed transaction (2PC). All three succeed or all three roll back. Sounds clean. In practice: slow, fragile, doesn't scale, most modern databases and message brokers don't even support it properly.
277 +
278 +Eventual consistency approach — events:
279 +
280 +1. Orders module saves order → commits to its own DB
281 +2. Orders module publishes OrderPlacedEvent
282 +3. Inventory module handles event → reserves stock in its own DB
283 +4. Notifications module handles event → sends email
284 +
285 +Each step is a local transaction — fast, reliable. But between steps 1 and 3, the order exists without reserved inventory. That's the consistency window.
286 +
287 +What can go wrong
288 +
289 +Timeline:
290 + T0: Order saved → Orders DB has the order
291 + T1: Event published → on the bus
292 + T2: Inventory handler → starts processing
293 + ------- consistency window -------
294 + T3: Stock reserved → Inventory DB updated
295 +
296 + Between T0 and T3, the system is "inconsistent":
297 + - Orders says: "order exists"
298 + - Inventory says: "stock not reserved yet"
299 +
300 +If someone queries inventory at T1, they see stale data. If the inventory handler crashes at T2, the order exists but stock is never reserved.
301 +
302 +In the modular monolith context
303 +
304 +In-process with MediatR, eventual consistency happens between SaveChangesAsync calls in different modules. The window is small (milliseconds), but it exists.
305 +
306 +// Persons module
307 +await _personRepo.DeleteAsync(id);
308 +await _personUow.SaveChangesAsync(); // committed to persons schema
309 +await _mediator.Publish(new PersonDeletedEvent(id));
310 +
311 +// Orders module handler — runs after, separate DbContext
312 +public async Task Handle(PersonDeletedEvent e, CancellationToken ct)
313 +{
314 + await _orderRepo.CancelOrdersForPerson(e.PersonId);
315 + await _orderUow.SaveChangesAsync(); // committed to orders schema
316 + // if this fails, person is deleted but orders still exist — inconsistent
317 +}
318 +
319 +Why not just use one database transaction
320 +
321 +Scenario One transaction works? Notes
322 +Monolith, one DbContext Yes Just use SaveChangesAsync()
323 +Modular monolith, same DB server Maybe TransactionScope works but couples modules
324 +Modular monolith, separate DBs No Need events + outbox
325 +Microservices No Need events + outbox + compensations
326 +Third-party APIs (email, payment) No Can't roll back an email
327 +The pragmatic answer: use immediate consistency as long as you can. One DbContext, one SaveChangesAsync. Only go eventual when the architecture forces it — separate modules, separate databases, external services. Don't adopt eventual consistency for the aesthetics.
328 +Look into saga pattern for compensating transactions.
No newline at end of file