profileShare

rasmusjy / splitapp-backend-clean-onion

Read-only snapshot

No repository description.

main default branch 429 files Expires Sep 13, 2026, 9:06 AM
README.md 18,929 bytes

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:
    1. Domain entity (App.Domain/Trip.cs) — POCO, EF-friendly, owns business validation
    2. BLL DTO (App.BLL/DTO/TripBllDto.cs) — internal application boundary
    3. Public DTO (App.DTO/v1/TripDto.cs) — versioned external API contract
  • 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.csproj does not reference App.DAL.EF — dependency inversion via Domain contracts
  • App.BLL.csproj does not reference App.DTO — public DTO mapping is App.DTO's responsibility (App.DTO references App.BLL, not the other way around)
  • WebApp.csproj references App.DAL.EF only for Program.cs composition-root wiring (builder.Services.AddDalServices(...)); no controller uses DAL or DbContext directly
  • Every repository interface (IAppUnitOfWork, ITripRepository, IRefreshTokenRepository, IUserRepository, …) is defined in App.Domain/Contracts/ and implemented in App.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

  • AccountController no longer injects AppDbContext — replaced direct EF calls with new App.BLL.Services.Identity.IIdentityService (Register, Login, RefreshToken, Logout) backed by IRefreshTokenRepository and IUserRepository in the UoW.
  • AdminStatsService no longer depends on Microsoft.AspNetCore.*UserManager replaced with IUserRepository; IStringLocalizer removed (service now emits MessageKey + MessageArgs, controller resolves localization at projection time).
  • The only Microsoft.AspNetCore.Identity import remaining in App.BLL is in IdentityService (auth abstraction by design — AppUser already inherits IdentityUser in 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 enters Finalizing; two-sided confirmation flow (debtor marks paid → creditor confirms) — trip auto-advances to Settled only once every payment has been confirmed by its recipient. While in Finalizing, 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 via LangStr JSON 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; 0 ViewData/ViewBag usage
  • Generic wrappersAdminDetailsViewModel<T> and AdminDeleteViewModel<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 via TripParticipantRepository.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 userId and 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, or Archived); settlement plan actions (Mark Paid / Confirm Receipt) are available during Finalizing and Settled; 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 deps
  • Base.DomainBase.Contracts
  • Base.Helpers — JWT (System.IdentityModel.Tokens.Jwt)
  • App.DomainBase.Domain, Base.Contracts, App.Resources (for Display attributes)
  • App.DAL.EFApp.Domain, Base.Contracts (implements Domain contracts)
  • App.BLLApp.Domain, Base.Helpers (does NOT reference App.DAL.EF or App.DTO — Clean inversion)
  • App.DTOApp.Domain, App.BLL (Public DTO layer maps from BLL DTOs)
  • WebAppApp.BLL, App.DTO, App.Resources, App.DAL.EF (DAL ref only for Program.cs AddDalServices(...); 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 → IAppUnitOfWorkIRepository<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.