SplitApp — Trip Expense Management
URL: https://travel.rasmusj.com/ Front: https://travel.rasmusj.com/ 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, manage budgets, run polls, maintain a wishlist, and settle debts via an optimized algorithm.
Built as a Personal Project — Phase 2 for the TalTech "Web Applications with C#" course (Phase 1 + full Clean/Onion architecture compliance with mandatory Repositories, UoW, Services, BLL DTOs, and Mappers).
Architecture at a glance
Clean / Onion Architecture with strict 3-tier DTOs. Dependencies point inward toward App.Domain. Interfaces live in the Domain layer (App.Domain/Contracts/), and App.DAL.EF is a plugin that implements them. App.BLL (application services) depends only on Domain abstractions and exposes BLL DTOs at its boundary — controllers never see Domain entities. App.DTO (Public DTOs) sits at the outer edge and maps from BLL DTOs to versioned API contracts.
WebApp (MVC + API + Admin)
│ uses only App.BLL services + App.DTO public mappers
│ ── controllers see ONLY BLL DTOs and Public DTOs, NEVER Domain entities
▼
App.DTO (v1 Public DTOs + Mappers/ — BLL DTO ↔ Public DTO)
│ references App.BLL (so it can map BllDto → public DTO)
▼
App.BLL (Services: Trip, Expense, Settlement, Invitation, Poll, BudgetCategory,
│ Wishlist, SplitPreset, Identity, + 12 Admin services)
│ (DTO/ — TripBllDto, ExpenseBllDto, AppUserBllDto, …)
│ (Mappers/ — Domain ↔ BLL DTO factory mappers)
│ depends on App.Domain contracts only
▼
App.Domain — POCO entities + Contracts/ (IAppUnitOfWork + 12 repository interfaces)
▲ ▲
│ implements │
│ │
App.DAL.EF (AppDbContext, AppUnitOfWork, Repositories, Migrations)
Plugin — sits OUTSIDE Domain; Program.cs wires it via AddDalServices()
Key Phase 2 properties:
- Three DTO tiers as required by the BLL lecture:
- Domain entity (
App.Domain/Trip.cs) — POCO, EF-friendly, owns business validation - BLL DTO (
App.BLL/DTO/TripBllDto.cs) — internal application boundary - Public DTO (
App.DTO/v1/TripDto.cs) — versioned external API contract
- Domain entity (
- Two mapping layers at each boundary:
App.BLL/Mappers/*BllDtoFactory.cs— Domain ↔ BLL DTO (factory pattern as recommended by lecture)App.DTO/Mappers/*Mapper.cs— BLL DTO ↔ Public DTO
App.BLL.csprojdoes not referenceApp.DAL.EF— dependency inversion via Domain contractsApp.BLL.csprojdoes not referenceApp.DTO— public DTO mapping is App.DTO's responsibility (App.DTO references App.BLL, not the other way around)WebApp.csprojreferencesApp.DAL.EFonly forProgram.cscomposition-root wiring (builder.Services.AddDalServices(...)); no controller uses DAL or DbContext directly- Every repository interface (
IAppUnitOfWork,ITripRepository,IRefreshTokenRepository,IUserRepository, …) is defined inApp.Domain/Contracts/and implemented inApp.DAL.EF/
See explanation.md for a full architectural walkthrough, flow examples, and defense cheat sheet.
Phase 2 changes — what was added on top of Phase 1
Onion strictness — 2 critical violations fixed
AccountControllerno longer injectsAppDbContext— replaced direct EF calls with newApp.BLL.Services.Identity.IIdentityService(Register, Login, RefreshToken, Logout) backed byIRefreshTokenRepositoryandIUserRepositoryin the UoW.AdminStatsServiceno longer depends onMicrosoft.AspNetCore.*—UserManagerreplaced withIUserRepository;IStringLocalizerremoved (service now emitsMessageKey + MessageArgs, controller resolves localization at projection time).- The only
Microsoft.AspNetCore.Identityimport remaining inApp.BLLis inIdentityService(auth abstraction by design —AppUseralready inheritsIdentityUserin Domain).
BLL DTO + Factory Mapper layer (Lecture: "Controllers should never see domain entities")
Added across all 8 client services and 12 admin services:
| App.BLL/DTO/ (15 BLL DTOs) | App.BLL/Mappers/ (9 Factories) |
|---|---|
| TripBllDto, TripParticipantBllDto, AppUserBllDto | TripBllDtoFactory |
| ExpenseBllDto, ExpenseSplitBllDto | ExpenseBllDtoFactory |
| SettlementPlanBllDto, SettlementPaymentBllDto | SettlementBllDtoFactory |
| TripInvitationBllDto | InvitationBllDtoFactory |
| TripPollBllDto, TripPollOptionBllDto | PollBllDtoFactory |
| TripWishlistItemBllDto | WishlistBllDtoFactory |
| BudgetCategoryBllDto | BudgetCategoryBllDtoFactory |
| CurrencyBllDto | CurrencyBllDtoFactory |
| SplitPresetBllDto, SplitPresetMemberBllDto | SplitPresetBllDtoFactory |
| BalanceBllDto | (settlement helper) |
Every service interface now uses BLL DTOs at its public surface (Task<TripBllDto> CreateTripAsync(TripBllDto dto, Guid userId)). All ~30 controllers (API + MVC + Admin) and Razor views were updated to use BLL DTO types instead of Domain entities.
Full Admin UX completed
New views and controller actions added in WebApp/Areas/Admin/:
| Area | Added |
|---|---|
SplitPresets |
Create.cshtml + Create action + AdminSplitPresetFormViewModel + CreateAsync service method |
Invitations |
Create.cshtml, Edit.cshtml + actions + AdminInvitationFormViewModel + CreateAsync/UpdateAsync service methods |
SettlementPayments |
Create.cshtml, Edit.cshtml + actions + AdminSettlementPaymentFormViewModel + CreateAsync/UpdateAsync service methods |
Users |
Details.cshtml, Edit.cshtml, Delete.cshtml + actions + AdminUserDetailsViewModel + AdminUserEditViewModel |
All 13 admin controllers now have full CRUD coverage with 0 ViewBag/ViewData usage — strict ViewModel-only views as required.
Feature overview
- Trips — create, manage, and archive group trips with Organizer / Participant roles
- Expenses — four split methods:
EqualAll,EqualSubset,ExactAmounts,Percentages - Split presets — reusable splitting templates
- Budgets — per-trip categories with real-time progress tracking
- Invitations — token-based invite links (Pending → Accepted / Declined / Expired / Revoked)
- Settlement — real-time balance tracking; trip lifecycle is
Active → Finalizing → Settled: organizer clicks Finalize Trip to lock expenses and generate an optimized settlement plan (greedy algorithm minimizing payment count), trip entersFinalizing; two-sided confirmation flow (debtor marks paid → creditor confirms) — trip auto-advances toSettledonly once every payment has been confirmed by its recipient. While inFinalizing, the organizer can still Reopen the trip (blocked once any payment is confirmed). - Wishlist — places, activities, restaurants with voting and priority
- Polls — group decision-making with single/multi-vote support
- Multi-currency — EUR, USD, GBP, SEK, NOK (hardcoded rates)
- Localization — English + Estonian (UI via
.resx; dynamic system data viaLangStrJSON in DB) - Auth — JWT Bearer for API (+ refresh token rotation), Cookie auth for MVC, role-based authorization (system roles + trip roles), IDOR protection
Tech Stack
- Runtime: ASP.NET Core 10.0 (MVC + REST API)
- Database: PostgreSQL 16 via Npgsql EF Core provider
- Identity: ASP.NET Identity with JWT Bearer + refresh token rotation, wrapped in
IIdentityService - API docs: Swagger / OpenAPI (with versioning and JWT auth integrated)
- Deployment: Docker + docker-compose, GitLab CI auto-deploy on
main
Phase 2 assignment requirements — mapping
| Requirement | Status | Where to see it |
|---|---|---|
| CLEAN/ONION architecture | ✅ | Inverted dependencies, Domain-owned interfaces, BLL DTO + Factory pattern |
| Domain design: min 10 meaningful entities | ✅ 16 entities | App.Domain/ |
| REST API: controllers + versioning + public DTOs | ✅ | WebApp/ApiControllers/, /api/v1/, App.DTO/v1/ |
| Swagger | ✅ | /swagger, ConfigureSwaggerOptions.cs |
| Auth (JWT + refresh tokens) | ✅ | IIdentityService in BLL, AccountController thin wrapper |
| Client UX (MVC) | ✅ | WebApp/Controllers/*Controller.cs (uses BLL DTOs) |
| Admin UX (MVC, Area, ViewModels, no ViewBag/ViewData) | ✅ Full CRUD on all 13 controllers | WebApp/Areas/Admin/, AdminViewModels.cs |
| Full Admin UX | ✅ | All entities have Index / Details / Create / Edit / Delete (where meaningful) |
| UI translations (i18n, .resx) | ✅ EN + ET | App.Resources/ |
| DB translations (LangStr) | ✅ | Currency.Name, BudgetCategory.Name use LangStr |
| IDOR protection | ✅ | _uow.TripParticipants.IsParticipantAsync() / IsOrganizerAsync() checks centralized in BLL services |
| Repositories, UoW, Services, BLL, Mappers — mandatory | ✅ All present | App.Domain/Contracts/, App.DAL.EF/Repositories/, App.BLL/Services/, App.BLL/Mappers/, App.DTO/Mappers/ |
| CI/CD deploy (app + DB) | ✅ | .gitlab-ci.yml, Dockerfile, docker-compose.yml |
| Test coverage | ⏳ deferred to next iteration | — |
Getting Started
Prerequisites
- .NET 10.0 SDK
- PostgreSQL 16 (or Docker)
Run with Docker (recommended)
docker compose up --build
The app listens on http://localhost:84 (host port 84 → container port 8080). Migrations and seed data are applied automatically on startup. PostgreSQL data persists in a named volume (pgdata).
For a clean reset (drop DB volume + reseed):
docker compose down -v && docker compose up --build
Run locally (without Docker)
cd SplitApp
dotnet restore
dotnet ef database update --project App.DAL.EF --startup-project WebApp
dotnet run --project WebApp
On first launch, seed data creates: default users, roles, currencies, and 4 example trips with expenses, polls, wishlist items.
Default seed users
The demo accounts are user@, alice@, bob@, charlie@ and diana@taltech.ee,
all with the password Kala.12345. That is in the source on purpose: this is a
demo, the data is invented, and anyone reading the code is meant to be able to
sign in and look around.
The administrator is not seeded at all unless SEED_ADMIN_PASSWORD is set, and
there is no default. See DEPLOY.md.
REST API
Versioned under /api/v1/. All protected endpoints require a JWT Bearer token.
| Controller | Endpoints | Auth |
|---|---|---|
AccountController (Identity) |
register, login, refreshtoken, logout | partial (login/register public) — backed by IIdentityService |
TripsController |
trip CRUD, participant info | JWT + participant/organizer check |
ExpensesController |
expense CRUD with splits | JWT + participant check |
BudgetCategoriesController |
per-trip budget categories | JWT + participant check |
InvitationsController |
create, info, accept, decline, revoke | JWT + organizer check |
WishlistController |
wishlist CRUD, voting, completion | JWT + participant check |
PollsController |
poll CRUD, voting, closing | JWT + participant check |
SettlementsController |
balances, calculation, mark-paid, confirm | JWT + participant check |
SplitPresetsController |
split preset CRUD | JWT + organizer check |
CurrenciesController |
currency reference data | JWT |
Swagger UI exposes the Bearer-auth flow — log in, paste the JWT, and all protected endpoints become callable from the browser.
MVC Client UX
Standard MVC controllers — functional, focused on proving the domain logic works through the BLL DTO layer:
- Home (public), Trips (CRUD + details), Expenses (CRUD + 4 split methods), Budget (categories + progress), Members (invite links), Settlement (balances + payments), PollsClient, WishlistClient
All views use BLL DTO types (@model App.BLL.DTO.TripBllDto) — no Domain entity leaks into Razor.
Admin Panel
Admin-only area at /Admin, protected by [Authorize(Roles = "admin")]. Designed, not pure scaffold:
- Custom sidebar layout (
Areas/Admin/Views/Shared/_Layout.cshtml) with Bootstrap Icons - Admin.css — dedicated styling (sidebar, metric cards, status badges, timeline feed, empty states)
- Dashboard with custom statistics: Top Active Trips, Biggest Expenses, User activity (7d/30d), Top Active Users, chronological Activity Feed
- 13 admin controllers with full CRUD coverage (Trips, Expenses, BudgetCategories, Currencies, Polls, Wishlist, Invitations, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Users, Dashboard)
- Strict ViewModels — every view uses typed ViewModel inheriting
AdminPageViewModel; 0ViewData/ViewBagusage - Generic wrappers —
AdminDetailsViewModel<T>andAdminDeleteViewModel<T>parameterized over BLL DTO types — keep domain entities out of views - User management — Details / Edit (FirstName + LastName) / EditRoles / Delete actions
Authorization Model
- System roles (ASP.NET Identity):
admin,user— enforced via[Authorize(Roles = "admin")] - Trip roles (domain):
Organizer,Participant— enforced viaTripParticipantRepository.IsOrganizerAsync()/IsParticipantAsync() - IDOR protection — every trip-scoped operation verifies the caller is a participant; check is centralized in BLL services (each query/mutation method takes
Guid userIdand validates it internally), so controllers cannot accidentally bypass the check - Trip lifecycle enforcement — expenses cannot be created/edited/deleted while trip is outside
Active(i.e.Finalizing,Settled, orArchived); settlement plan actions (Mark Paid / Confirm Receipt) are available duringFinalizingandSettled; payer-only Mark Paid and payee-only Confirm buttons are enforced both in the BLL guards (MarkPaidGuardedAsync/ConfirmPaymentGuardedAsync) and in the views (button hidden for other users) - Creator-based access — wishlist items editable/deletable only by creator; expenses editable/deletable by creator or trip organizer
Project Structure
SplitApp/
├── Base.Contracts/ Generic interfaces (IBaseEntity, IBaseRepository, IUnitOfWork)
├── Base.Domain/ BaseEntity, LangStr
├── Base.Helpers/ JWT generation/validation helpers
├── App.Domain/ 16 domain entities + 8 enums
│ └── Contracts/ IAppUnitOfWork + 12 repository interfaces
│ (ITripRepository, IRefreshTokenRepository, IUserRepository, …)
├── App.DAL.EF/ EF Core DbContext, UnitOfWork + repository implementations,
│ migrations, ServiceCollectionExtensions.AddDalServices()
├── App.BLL/ Application services — depends only on App.Domain
│ ├── DTO/ BLL DTOs — internal application boundary (TripBllDto, ExpenseBllDto,
│ │ AppUserBllDto, … 15 DTOs)
│ ├── Mappers/ Domain ↔ BLL DTO factory mappers (9 factory classes)
│ └── Services/
│ ├── Identity/ IIdentityService + IdentityService (Register/Login/Refresh/Logout)
│ ├── *.cs Core services (Trip, Expense, Settlement, Invitation, Poll,
│ │ BudgetCategory, Wishlist, SplitPreset)
│ └── Admin/ 12 admin services + AdminStatsService + AdminDashboardData
├── App.DTO/ Public API DTOs (versioned) + Mappers/
│ (BLL DTO ↔ Public DTO — TripMapper, ExpenseMapper, … 9 mappers)
├── App.Resources/ .resx localization files (EN + ET)
├── WebApp/ MVC views, API controllers, admin area, Program.cs composition root
│ ├── ApiControllers/ REST API (use BLL DTO + Public DTO mappers)
│ ├── Controllers/ MVC client (use BLL DTO + ViewModels)
│ ├── Areas/Admin/ Admin area (Controllers, Views, Models)
│ └── Models/ MVC ViewModels
├── Dockerfile
├── docker-compose.yml
└── SplitApp.sln
Dependency graph (Phase 2):
Base.Contracts— no depsBase.Domain→Base.ContractsBase.Helpers— JWT (System.IdentityModel.Tokens.Jwt)App.Domain→Base.Domain,Base.Contracts,App.Resources(for Display attributes)App.DAL.EF→App.Domain,Base.Contracts(implements Domain contracts)App.BLL→App.Domain,Base.Helpers(does NOT reference App.DAL.EF or App.DTO — Clean inversion)App.DTO→App.Domain,App.BLL(Public DTO layer maps from BLL DTOs)WebApp→App.BLL,App.DTO,App.Resources,App.DAL.EF(DAL ref only forProgram.csAddDalServices(...); no controller uses DAL)
Defense cheat sheet (Phase 2 architecture questions)
| Question | Answer / file |
|---|---|
| Where do interfaces live? | App.Domain/Contracts/ — Domain owns the interfaces (Onion) |
| Why doesn't BLL reference DAL? | Dependency inversion via Domain contracts; see App.BLL.csproj |
| Show me the 3 DTO tiers | Domain Trip → BLL TripBllDto → Public TripDto |
| How do you map between layers? | Factory pattern: App.BLL.Mappers.TripBllDtoFactory (Domain↔BLL DTO) and App.DTO.Mappers.TripMapper (BLL DTO↔Public DTO) |
| How does a controller talk to the database? | Controller → BLL service interface → IAppUnitOfWork → IRepository<T> → DbContext (4-layer indirection, all abstractions) |
| Why no DbContext in controllers? | IIdentityService is the example — Identity flow moved entirely to BLL |
| Why no ViewBag/ViewData? | Every view has a typed ViewModel; verified by grep across WebApp/Views/ and WebApp/Areas/Admin/Views/ |
| How do you protect against IDOR? | Every trip-scoped service method validates userId against TripParticipants.IsParticipantAsync / IsOrganizerAsync before returning data |
| What's in the BLL DTO that's not in Domain entity? | Computed flat fields like UserFullName, TripName, VoteCount, SpentAmount — view-friendly, framework-agnostic |
| What's in the Public DTO that's not in BLL DTO? | String-based enums (versionable), flat denormalized fields (DefaultCurrencyCode instead of nested), no nav collections in list views |
License
Course project — TalTech "Web Applications with C#".
See explanation.md for architectural decisions, the settlement algorithm, and layer-by-layer walkthrough.