diff --git a/.gitignore b/.gitignore index a6c640d..e4e6b31 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ coverage/ paket-files/ src/.vs/* +.vscode/ +packages/ .fake src/BikeTracking.Frontend/test-results/ diff --git a/.specify/extensions/git/git-config.yml b/.specify/extensions/git/git-config.yml index 8c414ba..0a4fd0f 100644 --- a/.specify/extensions/git/git-config.yml +++ b/.specify/extensions/git/git-config.yml @@ -19,10 +19,10 @@ auto_commit: enabled: false message: "[Spec Kit] Save progress before planning" before_tasks: - enabled: false + enabled: true message: "[Spec Kit] Save progress before task generation" before_implement: - enabled: false + enabled: true message: "[Spec Kit] Save progress before implementation" before_checklist: enabled: false @@ -46,10 +46,10 @@ auto_commit: enabled: false message: "[Spec Kit] Add implementation plan" after_tasks: - enabled: false + enabled: true message: "[Spec Kit] Add tasks" after_implement: - enabled: false + enabled: true message: "[Spec Kit] Implementation progress" after_checklist: enabled: false diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..b0aa5b0 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/015-bike-expense-tracking" +} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index f4ec317..86f0d93 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -86,7 +86,7 @@ Red-Green-Refactor cycle is **non-negotiable** and follows a strict, gate-contro 5. **Run After Each Change**: Tests are run after each meaningful implementation change to track incremental progress toward green. 6. **All Tests Pass**: Implementation is complete only when all tests pass. No merge occurs until the full test suite is green. 7. **Consider Refactoring**: Once tests are green, evaluate the implementation for clarity, duplication, and simplicity. Refactor while keeping tests green. Refactoring is optional but explicitly encouraged at this stage. -8. **Commit At Each TDD Gate**: Commits are mandatory at each TDD gate transition with clear gate intent in the message. Required checkpoints: (a) red baseline committed after failing tests are written and user confirms failures, (b) green implementation committed when approved tests pass, (c) refactor committed separately when refactoring is performed. +8. **Commit At Each TDD Gate**: Git Commits are mandatory at each TDD gate transition with clear gate intent in the message. Required checkpoints: (a) red baseline committed after failing tests are written and user confirms failures, (b) green implementation committed when approved tests pass, (c) refactor committed separately when refactoring is performed. TDD commit messages must include gate and spec/task context (for example: "TDD-RED: spec-006 ride history edit conflict tests" or "TDD-GREEN: spec-006 make edit totals refresh pass"). @@ -285,7 +285,7 @@ All development follows Trunk-Based Development with git worktrees for parallel 1. Create a GitHub issue describing the work 2. Create a short-lived feature branch from `main` (e.g., `feature/issue-42-record-ride`) 3. Use `git worktree add` to work on the branch in a separate directory when parallel work is needed -4. Commit frequently with meaningful messages using `semantic commits or conventional commits` format; push to remote regularly +4. Git Commit frequently with meaningful messages using `semantic commits or conventional commits` format; push to remote regularly 5. Open a PR referencing the GitHub issue (e.g., "Closes #42") as soon as the first commit is ready (draft PR for work-in-progress) 6. Keep the branch up-to-date with `main` via rebase 7. Once CI passes and review feedback is addressed, the owner completes the PR diff --git a/README.md b/README.md index 5dd00d0..e8cf40f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Local-first Bike Tracking application built with .NET Aspire orchestration, .NET - Duplicate-name rejection using trimmed, case-insensitive normalization - User identification with progressive retry delay (up to 30 seconds) - User registration outbox with background retry until successful publication +- Manual bike expense entry with date, amount, optional note, and optional receipt +- Expense history with date filtering, inline edit, soft delete, and receipt management +- Dashboard expense summary with manual totals, oil-change savings, and net position ## Project Structure @@ -162,6 +165,60 @@ For local-first deployment to end-user machines, the default persistence model i - Before schema upgrades, create a safety backup copy of the SQLite file. - Use SQL Server LocalDB or SQL Server Express only when local multi-user requirements exceed the single-user SQLite profile. +## Bike Expense Tracking + +The expense tracking slice adds a full local-first workflow for bike ownership costs. + +- Record manual expenses with required date and amount. +- Attach an optional receipt in JPEG, PNG, WEBP, or PDF format up to 5 MB. +- Browse expense history with date filters, inline edit, delete, and receipt replacement/removal. +- View dashboard totals that combine manual expenses with automatic oil-change savings. + +Receipt upload failures are handled as non-fatal storage errors when recording a new expense. If receipt storage fails because of a non-writable path, permission issue, or disk/storage error, the expense is still saved and the UI shows that the receipt was not attached. + +## Receipt Storage + +Receipt files are stored in a `receipts/` folder next to the configured SQLite database file. + +- Default development database: `src/BikeTracking.Api/biketracking.local.db` +- Default development receipt root: `src/BikeTracking.Api/receipts/` +- If `ConnectionStrings:BikeTracking` points to a different SQLite path, the receipt root moves with it. + +The storage rule is: + +- Database: `/path/to/biketracking.local.db` +- Receipts: `/path/to/receipts/` + +For packaged installs, configure the SQLite database in a user-writable app-data directory and allow the app to create the sibling `receipts/` directory there. + +Suggested packaged-install locations: + +- Windows: `%LocalAppData%/CommuteBikeTracker/biketracking.local.db` and `%LocalAppData%/CommuteBikeTracker/receipts/` +- macOS: `~/Library/Application Support/CommuteBikeTracker/biketracking.local.db` and `~/Library/Application Support/CommuteBikeTracker/receipts/` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/CommuteBikeTracker/biketracking.local.db` and `${XDG_DATA_HOME:-~/.local/share}/CommuteBikeTracker/receipts/` + +When deploying outside development, prefer setting the database path explicitly with configuration such as `ConnectionStrings__BikeTracking` so both the database and receipt root land in the intended writable directory. + +## Backup And Restore + +Back up the SQLite database file and the sibling `receipts/` directory together. Keeping only one of them can leave expense records pointing at missing attachments or leave orphaned receipt files with no matching expense rows. + +Recommended backup workflow: + +1. Stop the application. +2. Copy `biketracking.local.db`. +3. Copy the entire `receipts/` directory from the same parent folder. +4. Store both copies together with the same timestamp. + +Recommended restore workflow: + +1. Stop the application. +2. Restore `biketracking.local.db` to the configured data directory. +3. Restore the matching `receipts/` directory to the same parent folder. +4. Start the application and verify expense history plus a few receipt downloads. + +If the application is upgraded and migrations are about to run, make the backup before first startup on the new version. + ## Automated Tests @@ -172,6 +229,10 @@ frontend end-to-end tests: `npm run test:e2e` (Playwright) backend tests: `dotnet test` from repo root (xUnit) +formatting: `dotnet csharpier format .` + +frontend lint: `cd src/BikeTracking.Frontend && npm run lint` + These are ran in the .github\workflows\ci.yml pipeline on every PR diff --git a/specs/015-bike-expense-tracking/checklists/requirements.md b/specs/015-bike-expense-tracking/checklists/requirements.md new file mode 100644 index 0000000..9dc8110 --- /dev/null +++ b/specs/015-bike-expense-tracking/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Bike Expense Tracking + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-17 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous (all 5 ambiguities resolved in clarification session) +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Automatic oil-change savings calculation rule is explicitly defined: `floor(total_ride_miles / 3000) × oil_change_price` +- Receipt attachment constraints (file size, accepted formats) are called out as required but specific values left to planning/implementation phase +- Expense note maximum length is called out as required but specific value left to planning/implementation phase (consistent with ride notes spec 014 which uses 500 characters) +- Dependency on spec 009 (user settings: oil change price) and spec 012 (dashboard stats) is captured via key entities and FR-018 diff --git a/specs/015-bike-expense-tracking/contracts/api-contracts.md b/specs/015-bike-expense-tracking/contracts/api-contracts.md new file mode 100644 index 0000000..8e0fba2 --- /dev/null +++ b/specs/015-bike-expense-tracking/contracts/api-contracts.md @@ -0,0 +1,233 @@ +# API Contracts: Bike Expense Tracking (Spec 015) + +**Date**: 2026-04-17 +**Base path**: `/api/expenses` +**Authentication**: All endpoints require bearer token (`RequireAuthorization()`) + +--- + +## Endpoints + +### POST /api/expenses +Record a new expense. Accepts `multipart/form-data` to support optional receipt upload. + +**Request** (`multipart/form-data`): +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `expenseDate` | `string` (ISO 8601 date) | Yes | Valid date | +| `amount` | `string` (decimal) | Yes | > 0, max 2 dp | +| `notes` | `string` | No | Max 500 chars | +| `receipt` | `file` | No | JPEG/PNG/WEBP/PDF, ≤ 5 MB | + +**Response 201**: +```json +{ + "expenseId": 42, + "riderId": 7, + "savedAtUtc": "2026-04-17T14:00:00Z", + "receiptAttached": true +} +``` + +**Response 400** — validation failure: +```json +{ + "errors": { + "amount": ["Expense amount must be greater than zero"], + "expenseDate": ["Expense date is required"] + } +} +``` + +**Response 422** — receipt constraint violation: +```json +{ + "error": "Receipt must be JPEG, PNG, WEBP, or PDF and no larger than 5 MB" +} +``` + +--- + +### GET /api/expenses +Get expense history for the authenticated rider. Returns all non-deleted expenses. + +**Query params**: +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `startDate` | `string` (ISO 8601) | No | Inclusive lower bound | +| `endDate` | `string` (ISO 8601) | No | Inclusive upper bound | + +**Response 200**: +```json +{ + "expenses": [ + { + "expenseId": 42, + "expenseDate": "2026-04-15", + "amount": 49.99, + "notes": "New chain", + "hasReceipt": true, + "version": 1, + "createdAtUtc": "2026-04-17T14:00:00Z" + } + ], + "totalAmount": 49.99, + "expenseCount": 1, + "generatedAtUtc": "2026-04-17T14:01:00Z" +} +``` + +--- + +### PUT /api/expenses/{id} +Edit an existing expense (JSON body; no receipt — use separate receipt endpoint). + +**Path param**: `id` — expense ID +**Request** (`application/json`): +```json +{ + "expenseDate": "2026-04-15", + "amount": 52.50, + "notes": "New chain + lube", + "expectedVersion": 1 +} +``` + +| Field | Required | Constraints | +|-------|----------|-------------| +| `expenseDate` | Yes | Valid date | +| `amount` | Yes | > 0 | +| `notes` | No | Max 500 chars | +| `expectedVersion` | Yes | ≥ 1, optimistic concurrency | + +**Response 200** — success: +```json +{ + "expenseId": 42, + "savedAtUtc": "2026-04-17T14:05:00Z", + "newVersion": 2 +} +``` + +**Response 409** — version conflict: +```json +{ + "error": "This expense was updated by another session. Please refresh and try again." +} +``` + +--- + +### DELETE /api/expenses/{id} +Delete (tombstone) an expense. Receipt file removed from storage. + +**Path param**: `id` — expense ID +**Request body**: none +**Response 204** — deleted +**Response 404** — not found or belongs to a different rider +**Response 409** — already deleted + +--- + +### PUT /api/expenses/{id}/receipt +Replace or upload a receipt for an existing expense. + +**Path param**: `id` — expense ID +**Request** (`multipart/form-data`): +| Field | Required | Constraints | +|-------|----------|-------------| +| `receipt` | Yes | JPEG/PNG/WEBP/PDF, ≤ 5 MB | + +**Response 200** — success +**Response 422** — file constraint violation + +--- + +### DELETE /api/expenses/{id}/receipt +Remove the receipt from an existing expense without deleting the expense itself. + +**Path param**: `id` — expense ID +**Response 204** — receipt removed +**Response 404** — expense not found or no receipt attached + +--- + +### GET /api/expenses/{id}/receipt +Download/view the receipt image for an expense. + +**Path param**: `id` — expense ID +**Response 200** — file stream with appropriate `Content-Type` +**Response 404** — expense or receipt not found +**Security**: Server validates that the authenticated rider owns this expense before serving the file. Path is never derived from user input. + +--- + +## Dashboard Contract Extension + +`GET /api/dashboard` response — `DashboardTotals` gains new `expenseSummary` field: + +```json +{ + "totals": { + "currentMonthMiles": { ... }, + "yearToDateMiles": { ... }, + "allTimeMiles": { ... }, + "moneySaved": { ... }, + "expenseSummary": { + "totalManualExpenses": 149.97, + "oilChangeSavings": 89.99, + "netExpenses": 59.98, + "oilChangeIntervalCount": 1 + } + }, + ... +} +``` + +When `oilChangeSavings` is `null` (oil change price not configured), `netExpenses` is also `null` and `oilChangeIntervalCount` reflects the interval count that would apply once a price is set. + +--- + +## Frontend Service Contract + +New TypeScript types in `src/services/expenses-api.ts`: + +```typescript +interface RecordExpenseRequest { + expenseDate: string // YYYY-MM-DD + amount: string // decimal string + notes?: string + receipt?: File +} + +interface ExpenseRow { + expenseId: number + expenseDate: string // YYYY-MM-DD + amount: number + notes: string | null + hasReceipt: boolean + version: number + createdAtUtc: string +} + +interface ExpenseHistoryResponse { + expenses: ExpenseRow[] + totalAmount: number + expenseCount: number + generatedAtUtc: string +} + +interface EditExpenseRequest { + expenseDate: string + amount: number + notes?: string + expectedVersion: number +} + +interface DashboardExpenseSummary { + totalManualExpenses: number + oilChangeSavings: number | null + netExpenses: number | null + oilChangeIntervalCount: number +} +``` diff --git a/specs/015-bike-expense-tracking/data-model.md b/specs/015-bike-expense-tracking/data-model.md new file mode 100644 index 0000000..38c792c --- /dev/null +++ b/specs/015-bike-expense-tracking/data-model.md @@ -0,0 +1,201 @@ +# Data Model: Bike Expense Tracking (Spec 015) + +**Date**: 2026-04-17 +**Branch**: `015-bike-expense-tracking` + +--- + +## New Entities + +### ExpenseEntity (EF Core / SQLite) + +Maps to `Expenses` table. Represents the current projected state of a single expense entry. + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| `Id` | `long` | No | PK, auto-increment | +| `RiderId` | `long` | No | FK → Users.UserId (cascade delete) | +| `ExpenseDate` | `DateTime` | No | Date of the expense (local) | +| `Amount` | `decimal(10,2)` | No | CHECK > 0 | +| `Notes` | `string` | Yes | MaxLength(500) | +| `ReceiptPath` | `string` | Yes | MaxLength(500); relative path within receipts root | +| `IsDeleted` | `bool` | No | Default false; tombstone flag | +| `Version` | `int` | No | Default 1; optimistic concurrency token | +| `CreatedAtUtc` | `DateTime` | No | Timestamp of first insert | +| `UpdatedAtUtc` | `DateTime` | No | Timestamp of last update | + +**Indexes**: +- `IX_Expenses_RiderId_ExpenseDate_Desc` — (RiderId ASC, ExpenseDate DESC) for efficient history queries +- `IX_Expenses_RiderId_IsDeleted` — (RiderId, IsDeleted) for quick active-expense queries + +**Check constraints**: +- `CK_Expenses_Amount_Positive` — `CAST("Amount" AS REAL) > 0` + +--- + +## Modified Entities + +### DashboardTotals (DashboardContracts.cs) + +Extended with new `ExpenseSummary` field: + +```csharp +// Existing record — add one property: +public sealed record DashboardTotals( + DashboardMileageMetric CurrentMonthMiles, + DashboardMileageMetric YearToDateMiles, + DashboardMileageMetric AllTimeMiles, + DashboardMoneySaved MoneySaved, + DashboardExpenseSummary ExpenseSummary // NEW +); + +// New record: +public sealed record DashboardExpenseSummary( + decimal TotalManualExpenses, + decimal? OilChangeSavings, // null if oil change price not set in settings + decimal? NetExpenses, // null if oil change price not set + int OilChangeIntervalCount // floor(lifetime miles / 3000) +); +``` + +--- + +## F# Domain Types + +### ExpenseEvents.fs (new file in BikeTracking.Domain.FSharp) + +```fsharp +module BikeTracking.Domain.Expenses + +open System + +type ExpenseRecordedData = { + ExpenseId : int64 + RiderId : int64 + ExpenseDate : DateTime + Amount : decimal // validated > 0 + Notes : string option + ReceiptPath : string option + RecordedAt : DateTime +} + +type ExpenseEditedData = { + ExpenseId : int64 + RiderId : int64 + ExpenseDate : DateTime + Amount : decimal + Notes : string option + ReceiptPath : string option + ExpectedVersion : int + EditedAt : DateTime +} + +type ExpenseDeletedData = { + ExpenseId : int64 + RiderId : int64 + DeletedAt : DateTime +} + +type ExpenseEvent = + | ExpenseRecorded of ExpenseRecordedData + | ExpenseEdited of ExpenseEditedData + | ExpenseDeleted of ExpenseDeletedData + +// Pure validation — Railway Oriented Programming +let validateAmount (amount: decimal) : Result = + if amount > 0m then Ok amount + else Error "Expense amount must be greater than zero" + +let validateNotes (notes: string option) : Result = + match notes with + | None -> Ok None + | Some n when n.Length > 500 -> Error "Note must be 500 characters or fewer" + | Some n -> Ok (Some n) + +let validateDate (date: DateTime) : Result = + if date = DateTime.MinValue then Error "Expense date is required" + else Ok date +``` + +--- + +## Receipt File Storage Layout + +``` +{app_data_path}/ +├── biketracking.local.db # SQLite database (existing) +└── receipts/ + └── {riderId}/ + └── {expenseId}/ + └── {random_guid}.{ext} # Sanitized filename generated server-side +``` + +- `ReceiptPath` column stores the path relative to `receipts/` root: `{riderId}/{expenseId}/{random_guid}.{ext}` +- Accepted extensions: `.jpg`, `.jpeg`, `.png`, `.webp`, `.pdf` +- Max size: 5 MB per receipt +- MIME type validated server-side independently of file extension + +--- + +## Database Migration + +New migration: `AddExpensesTable` + +```sql +CREATE TABLE "Expenses" ( + "Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "RiderId" INTEGER NOT NULL REFERENCES "Users"("UserId") ON DELETE CASCADE, + "ExpenseDate" TEXT NOT NULL, + "Amount" TEXT NOT NULL, + "Notes" TEXT NULL, + "ReceiptPath" TEXT NULL, + "IsDeleted" INTEGER NOT NULL DEFAULT 0, + "Version" INTEGER NOT NULL DEFAULT 1, + "CreatedAtUtc" TEXT NOT NULL, + "UpdatedAtUtc" TEXT NOT NULL, + CONSTRAINT "CK_Expenses_Amount_Positive" CHECK (CAST("Amount" AS REAL) > 0) +); + +CREATE INDEX "IX_Expenses_RiderId_ExpenseDate_Desc" + ON "Expenses" ("RiderId" ASC, "ExpenseDate" DESC); + +CREATE INDEX "IX_Expenses_RiderId_IsDeleted" + ON "Expenses" ("RiderId", "IsDeleted"); +``` + +--- + +## State Transitions + +``` +[New Expense] + │ + ▼ + ExpenseRecorded ──→ ExpenseEdited (0..n times) + │ │ + └──────────────────────┘ + │ + ▼ + ExpenseDeleted (IsDeleted = true; removed from UI and totals) +``` + +**Validation rules** (enforced at all entry points): +- Amount: required, decimal > 0, max 2 decimal places +- ExpenseDate: required, valid date +- Notes: optional, max 500 characters +- Receipt: optional; if present: accepted MIME type + ≤ 5 MB + +--- + +## Relationships + +``` +Users (1) ──── (*) Expenses +Users (1) ──── (*) Rides [existing] +UserSettings (1) ──── (1) Users [existing] +``` + +Oil-change savings are **calculated** (not stored) from: +- `SUM(Rides.Miles) WHERE RiderId = x AND IsDeleted = false` (existing Rides table) +- `UserSettings.OilChangePrice WHERE UserId = x` (existing UserSettings) +- Formula: `FLOOR(totalMiles / 3000) * oilChangePrice` diff --git a/specs/015-bike-expense-tracking/plan.md b/specs/015-bike-expense-tracking/plan.md new file mode 100644 index 0000000..a8fe8c3 --- /dev/null +++ b/specs/015-bike-expense-tracking/plan.md @@ -0,0 +1,171 @@ +# Implementation Plan: Bike Expense Tracking + +**Branch**: `015-bike-expense-tracking` | **Date**: 2026-04-17 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/015-bike-expense-tracking/spec.md` + +--- + +## Summary + +Add bike expense tracking: a rider records manual expenses (date, amount, optional note, optional receipt image), views and inline-edits expense history with date-range filtering, and deletes expenses. The dashboard gains an expense summary panel showing total manual expenses, automatic oil-change savings (`floor(lifetime_miles / 3000) × oil_change_price`), and the net figure alongside existing gas-saved and mileage-saved values. + +**Technical approach**: New `Expenses` SQLite table via EF Core migration. F# `ExpenseEvents` discriminated union for domain modeling. Four Application Services + `IReceiptStorage` port with `FileSystemReceiptStorage` adapter for local-disk receipt files. Seven new Minimal API endpoints under `/api/expenses`. `GetDashboardService` extended to query expenses and compute oil-change savings. Two new frontend pages (`/expenses/entry`, `/expenses/history`) following the existing ride pattern. Dashboard extended with expense summary card. + +--- + +## Technical Context + +**Language/Version**: C# 13 / .NET 10 (API), F# (domain), TypeScript 5 / React 19 (frontend) +**Primary Dependencies**: ASP.NET Core Minimal API, EF Core 9 (SQLite), xUnit, Vitest, Playwright, React Router v7, Vite +**Storage**: SQLite local file (`biketracking.local.db`); receipt files in `receipts/` subfolder alongside DB +**Testing**: xUnit (backend unit + integration), Vitest (frontend unit), Playwright (E2E) +**Target Platform**: Local user machine (Windows/macOS/Linux); devcontainer for development +**Project Type**: Local-first desktop web application (Aspire-orchestrated) +**Performance Goals**: API response <500ms p95 +**Constraints**: Offline-capable; no cloud services; single-user SQLite file; receipts ≤5 MB +**Scale/Scope**: Single-user local deployment; expense list typically <500 rows per rider + +--- + +## Constitution Check + +| Principle | Check | Status | +|-----------|-------|--------| +| I — Clean Architecture / Ports-and-Adapters | `IReceiptStorage` port + `FileSystemReceiptStorage` adapter; no filesystem calls in Application layer | PASS | +| I — No god services | Four focused services, each single-responsibility | PASS | +| II — Pure/Impure Sandwich | F# `ExpenseEvents.fs` pure validation returning `Result<_,string>`; I/O in C# services only | PASS | +| III — Event Sourcing | `ExpenseRecorded/Edited/Deleted` events; `IsDeleted` tombstone projection | PASS | +| IV — TDD | Red-Green-Refactor mandatory; test plan in quickstart.md; failing tests before implementation | PASS | +| V — UX Consistency | History page follows `HistoryPage.tsx`; inline edit follows ride edit pattern | PASS | +| VI — Performance | Dashboard adds one Expenses scan; indexed on (RiderId, ExpenseDate DESC) | PASS | +| VII — Three-layer validation | React form + DataAnnotations DTOs + SQLite CHECK constraint on Amount | PASS | +| VIII — Security | Receipt path never from user input; rider ownership validated before file serve; MIME server-validated | PASS | +| IX — Contract-first | API contracts in `contracts/api-contracts.md` before implementation | PASS | +| X — TBD | Additive new pages + new endpoints; no feature flag needed | PASS | + +--- + +## Project Structure + +### Documentation (this feature) + +```text +specs/015-bike-expense-tracking/ +├── plan.md <- this file +├── research.md <- Phase 0 output +├── data-model.md <- Phase 1 output +├── quickstart.md <- Phase 1 output +├── contracts/ +│ └── api-contracts.md <- Phase 1 output +└── tasks.md <- Phase 2 output (/speckit.tasks) +``` + +### Source Code — New Files + +```text +src/BikeTracking.Domain.FSharp/ +└── Expenses/ + └── ExpenseEvents.fs + +src/BikeTracking.Api/ +├── Infrastructure/ +│ ├── Persistence/ +│ │ ├── Entities/ +│ │ │ └── ExpenseEntity.cs +│ │ └── Migrations/ +│ │ └── {timestamp}_AddExpensesTable.cs +│ └── Receipts/ +│ └── FileSystemReceiptStorage.cs +├── Application/ +│ └── Expenses/ +│ ├── IReceiptStorage.cs +│ ├── RecordExpenseService.cs +│ ├── EditExpenseService.cs +│ ├── DeleteExpenseService.cs +│ └── GetExpenseHistoryService.cs +├── Contracts/ +│ └── ExpenseContracts.cs +└── Endpoints/ + └── ExpensesEndpoints.cs + +src/BikeTracking.Api.Tests/ +└── Expenses/ + ├── RecordExpenseServiceTests.cs + ├── EditExpenseServiceTests.cs + ├── DeleteExpenseServiceTests.cs + └── GetExpenseHistoryServiceTests.cs + +src/BikeTracking.Frontend/src/ +├── pages/ +│ └── expenses/ +│ ├── ExpenseEntryPage.tsx +│ ├── ExpenseEntryPage.css +│ ├── ExpenseHistoryPage.tsx +│ ├── ExpenseHistoryPage.css +│ └── expense-page.helpers.ts +└── services/ + └── expenses-api.ts +``` + +### Source Code — Modified Files + +```text +src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj # Register ExpenseEvents.fs +src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs # Add DbSet + model config +src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs # Add expense + savings calc +src/BikeTracking.Api/Contracts/DashboardContracts.cs # Add DashboardExpenseSummary +src/BikeTracking.Api/Program.cs # Register services + endpoints +src/BikeTracking.Frontend/src/App.tsx # Add 2 new routes +src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx # Add expense panel +src/BikeTracking.Frontend/src/[nav component] # Add 2 nav links +``` + +--- + +## Architecture Decisions + +### 1. Multipart for Record, JSON + Separate Endpoints for Edits +Initial `POST /api/expenses` uses `multipart/form-data` (fields + optional receipt in one request). Edit uses plain JSON `PUT /api/expenses/{id}`. Receipt changes use dedicated `PUT /api/expenses/{id}/receipt` and `DELETE /api/expenses/{id}/receipt` — avoids re-uploading existing receipts on text-only edits. + +### 2. Receipt File Path Security +Server generates a `Guid`-based filename — original browser filename is never stored or used. DB `ReceiptPath` stores relative path from receipts root: `{riderId}/{expenseId}/{guid}.{ext}`. `GET /api/expenses/{id}/receipt` validates JWT `sub` claim against expense `RiderId` before serving the file — path is never derived from user request data. + +### 3. Oil-Change Savings in Dashboard +Dashboard already loads all rides for the rider. Oil-change savings are a simple in-memory calculation over the already-loaded ride list + `UserSettings.OilChangePrice` — no extra DB round-trip needed. + +### 4. IsDeleted Tombstone +`ExpenseEntity.IsDeleted = true` is the tombstone. Receipt file removed from disk on delete. All queries filter `WHERE IsDeleted = false`. `IX_Expenses_RiderId_IsDeleted` index keeps this cheap. + +--- + +## Test Plan (TDD Gates) + +### F# Domain +- `validateAmount 0m` -> Error; `-1m` -> Error; `0.01m` -> Ok +- `validateNotes (501 chars)` -> Error; `None` -> Ok; short string -> Ok +- `validateDate DateTime.MinValue` -> Error; valid date -> Ok + +### Backend Application Services +- `RecordExpenseService`: saves entity; null receipt is valid +- `EditExpenseService`: version conflict -> Conflict result; valid edit increments version +- `DeleteExpenseService`: sets IsDeleted=true; subsequent GET excludes it +- `GetExpenseHistoryService`: date filter excludes out-of-range; total is correct + +### API Endpoints (integration) +- POST with missing amount -> 400 +- POST with oversized receipt -> 422 +- PUT with wrong version -> 409 +- DELETE -> 204; GET excludes deleted expense +- GET receipt as different rider -> 404 + +### Frontend Unit (Vitest) +- Entry form renders all fields +- Blank amount -> validation message shown +- Valid submit -> calls expenses-api service + +### E2E (Playwright) +- Record -> appears in history with correct amount +- Edit -> updated in list and total +- Delete -> removed; total decreases +- Dashboard shows totalManualExpenses matching recorded +- Dashboard shows oilChangeSavings when price set and miles >= 3000 diff --git a/specs/015-bike-expense-tracking/quickstart.md b/specs/015-bike-expense-tracking/quickstart.md new file mode 100644 index 0000000..bec7666 --- /dev/null +++ b/specs/015-bike-expense-tracking/quickstart.md @@ -0,0 +1,148 @@ +# Quickstart: Bike Expense Tracking (Spec 015) + +**Date**: 2026-04-17 +**For**: Implementers working from tasks.md + +--- + +## Prerequisites + +- DevContainer running (all tooling pre-configured) +- App starts via `dotnet run --project src/BikeTracking.AppHost` +- Existing specs 009 (user settings) and 012 (dashboard) implemented — `UserSettingsEntity.OilChangePrice` and `GetDashboardService` already exist + +--- + +## Key Files to Read Before Starting + +| File | Why | +|------|-----| +| `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` | Template for ExpenseEntity shape | +| `src/BikeTracking.Api/Application/Rides/DeleteRideHandler.cs` | Template for tombstone delete pattern | +| `src/BikeTracking.Api/Application/Rides/EditRideService.cs` | Template for optimistic concurrency edit pattern | +| `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs` | Extension point for expense summary calculation | +| `src/BikeTracking.Api/Contracts/DashboardContracts.cs` | Extension point for DashboardExpenseSummary | +| `src/BikeTracking.Api/Endpoints/RidesEndpoints.cs` | Template for new ExpensesEndpoints | +| `src/BikeTracking.Domain.FSharp/Users/UserEvents.fs` | Template for new ExpenseEvents.fs | +| `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx` | Template for ExpenseHistoryPage | +| `src/BikeTracking.Frontend/src/services/ridesService.ts` | Template for expenses-api.ts | +| `src/BikeTracking.Frontend/src/App.tsx` | Add new routes here | + +--- + +## Implementation Sequence (TDD order) + +### Step 1 — F# Domain Events +Add `ExpenseEvents.fs` to `BikeTracking.Domain.FSharp` and register it in `.fsproj`. Write F# unit tests for: +- `validateAmount` rejects ≤ 0, accepts > 0 +- `validateNotes` rejects > 500 chars, accepts None and short strings +- `validateDate` rejects MinValue + +### Step 2 — EF Core Entity + Migration +Add `ExpenseEntity.cs` to `Infrastructure/Persistence/Entities/`. Add `DbSet` to `BikeTrackingDbContext`. Add EF model config with check constraint and indexes. Run `dotnet ef migrations add AddExpensesTable --project src/BikeTracking.Api`. + +### Step 3 — Receipt Storage Port/Adapter +Create `Application/Expenses/IReceiptStorage.cs` (port interface) and `Infrastructure/Receipts/FileSystemReceiptStorage.cs` (adapter). Register in `Program.cs`. Write unit tests with in-memory/temp-path stub. + +### Step 4 — Application Services (TDD) +For each service, write failing tests first: +- `RecordExpenseService` — saves entity, calls receipt storage, returns success +- `EditExpenseService` — optimistic concurrency check, updates entity +- `DeleteExpenseService` — sets IsDeleted=true, removes receipt file +- `GetExpenseHistoryService` — returns filtered list, computes totalAmount + +### Step 5 — API Contracts + Endpoints +Add `Contracts/ExpenseContracts.cs`. Add `Endpoints/ExpensesEndpoints.cs` (7 endpoints). Register in `Program.cs`. Write integration tests via `BikeTracking.Api.Tests`. + +### Step 6 — Dashboard Extension +Extend `DashboardContracts.cs` with `DashboardExpenseSummary`. Update `GetDashboardService` to query non-deleted expenses and compute oil-change savings. Update frontend `DashboardPage`. + +### Step 7 — Frontend Entry Form +Add `src/pages/expenses/ExpenseEntryPage.tsx` + service calls. Add route `/expenses/entry`. Add nav link. + +### Step 8 — Frontend History Page +Add `src/pages/expenses/ExpenseHistoryPage.tsx` following `HistoryPage.tsx` pattern. Add route `/expenses/history`. Add nav link. Implement inline edit + delete + date range filter. + +### Step 9 — Dashboard UI Updates +Update `DashboardPage` / `DashboardSummaryCard` to show `expenseSummary` panel alongside existing savings cards. + +### Step 10 — E2E Tests +Add Playwright tests: +- Full expense record → view in history flow +- Edit expense +- Delete expense +- Dashboard shows correct totals + +--- + +## Common Patterns Reference + +### Optimistic Concurrency (Edit) +```csharp +// From EditRideService pattern: +var expense = await dbContext.Expenses + .Where(e => e.Id == request.ExpenseId && e.RiderId == riderId) + .SingleOrDefaultAsync(ct); +if (expense is null) return ExpenseEditResult.NotFound; +if (expense.Version != request.ExpectedVersion) return ExpenseEditResult.Conflict; +// ... update fields ... +expense.Version++; +``` + +### Tombstone Delete +```csharp +// From DeleteRideHandler pattern: +expense.IsDeleted = true; +expense.UpdatedAtUtc = DateTime.UtcNow; +await dbContext.SaveChangesAsync(ct); +// then remove receipt file via IReceiptStorage +``` + +### Oil-Change Savings Calculation +```csharp +var lifetimeMiles = rides.Where(r => !r.IsDeleted).Sum(r => r.Miles); // rides already loaded +var intervalCount = (int)Math.Floor((double)lifetimeMiles / 3000); +var oilChangeSavings = settings?.OilChangePrice is decimal price + ? intervalCount * price + : (decimal?)null; +``` + +### Multipart Expense Record (API) +```csharp +app.MapPost("/api/expenses", async ( + [FromForm] RecordExpenseRequest request, + IFormFile? receipt, + HttpContext context, + RecordExpenseService service, + CancellationToken ct) => { ... }) + .DisableAntiforgery(); // JWT auth; CSRF not applicable for API +``` + +--- + +## Test Commands + +```bash +# Backend tests +dotnet test src/BikeTracking.Api.Tests/BikeTracking.Api.Tests.csproj + +# Frontend unit tests +cd src/BikeTracking.Frontend && npm run test:unit + +# E2E (start app first) +cd src/BikeTracking.Frontend && npm run test:e2e +``` + +--- + +## File Size / MIME Validation +```csharp +private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) +{ + "image/jpeg", "image/png", "image/webp", "application/pdf" +}; +private const long MaxReceiptBytes = 5 * 1024 * 1024; // 5 MB + +if (receipt.Length > MaxReceiptBytes) return Results.UnprocessableEntity(...); +if (!AllowedMimeTypes.Contains(receipt.ContentType)) return Results.UnprocessableEntity(...); +``` diff --git a/specs/015-bike-expense-tracking/research.md b/specs/015-bike-expense-tracking/research.md new file mode 100644 index 0000000..e8c600d --- /dev/null +++ b/specs/015-bike-expense-tracking/research.md @@ -0,0 +1,139 @@ +# Research: Bike Expense Tracking (Spec 015) + +**Date**: 2026-04-17 +**Resolved**: All unknowns from Technical Context + +--- + +## 1. Receipt File Storage — Local-First Deployment + +**Decision**: Store receipt files in a `receipts/` subdirectory alongside the SQLite database file on the user's machine. The database record stores only the relative file path/reference; binary content is never stored in the SQLite BLOB column. + +**Rationale**: +- Consistent with existing SQLite local-file deployment pattern; one backup folder covers both DB and receipts. +- Avoids BLOB storage which degrades EF Core query performance and inflates DB file size. +- The existing `SqliteMigrationBootstrapper.cs` already resolves the DB path from `IConfiguration`; the same path resolution can derive the receipts root. +- IReceiptStorage port → FileSystemReceiptStorage adapter satisfies the Ports-and-Adapters architecture requirement (Principle I) and allows testing without real filesystem. + +**File path convention**: `{receipts_root}/{riderId}/{expenseId}/{original_filename_sanitized}.{ext}` +Using `expenseId` subfolder prevents filename collisions across edits/retries. + +**Accepted file types**: JPEG, PNG, WEBP, PDF — common receipt capture formats (phone camera → JPEG/HEIC converted by browser, scanner → PDF). +**Max file size**: 5 MB per receipt — sufficient for a high-res phone photo; prevents runaway disk usage. +**Alternatives considered**: +- SQLite BLOB: Rejected — degrades query performance; harder to open/preview receipts externally. +- User-chosen folder: Rejected — adds setup friction; inconsistent with local-first simplicity. + +--- + +## 2. Oil-Change Savings Calculation Scope + +**Decision**: Lifetime cumulative ride miles (all-time total, never resets). Formula: `floor(lifetime_ride_miles / 3000) × oil_change_price`. + +**Rationale**: +- Oil changes are per-vehicle maintenance events unrelated to calendar years — a rider who has ridden 8500 lifetime miles has genuinely deferred ~2.8 oil changes. +- Annual reset creates a confusing "cliff" on January 1 where savings drop to zero even though the bike still hasn't needed an oil change. +- Consistent with the existing `SnapshotOilChangePrice` field already present on `RideEntity` — the data is already available for historical calculation accuracy. +- Uses `UserSettingsEntity.OilChangePrice` as the multiplier; if null → savings unavailable (not zero). + +**Alternatives considered**: +- Annual reset: Rejected — conceptually incorrect; oil change intervals don't reset with the year. +- Rolling 12 months: Rejected — adds complexity without matching real-world bike maintenance. + +--- + +## 3. Integration with Existing Dashboard + +**Decision**: Extend `DashboardResponse` with a new `ExpenseSummary` property rather than a separate endpoint. + +**Rationale**: +- The dashboard already returns a single `DashboardResponse` from `GET /api/dashboard`; adding expense data avoids a second round-trip for the page. +- `GetDashboardService` already queries both `Rides` and `UserSettings`; adding an `Expenses` query is a natural extension. +- Oil-change savings (`floor(totalMiles / 3000) × oilChangePrice`) can be computed from already-loaded rides + settings data — no additional DB call needed. +- Existing `DashboardTotals` record is extended; frontend `DashboardPage` component adds the new summary card. + +**New dashboard fields**: +``` +DashboardTotals.ExpenseSummary: + TotalManualExpenses: decimal # sum of non-deleted expense amounts + OilChangeSavings: decimal? # null if oil change price not set + NetExpenses: decimal? # null if oil change price not set + OilChangeIntervalsMiles: int # floor(lifetime miles / 3000) +``` + +--- + +## 4. Expense Delete Pattern + +**Decision**: Same tombstone-event pattern as ride delete (spec 007). A logical `IsDeleted` flag on `ExpenseEntity` acts as the tombstone projection; the underlying EF row is never physically removed. Deleted expenses are excluded from history list and expense totals. + +**Rationale**: +- Matches existing `DeleteRideHandler`/`DeleteRideService` pattern — implementation consistency. +- Event sourcing principle (Principle III): events are append-only; delete is a new event, not a mutation. +- Deleted expense receipt files: removed from filesystem on delete; tombstone event records that removal occurred. + +--- + +## 5. Expense History Filter Pattern + +**Decision**: Date-range filter matching the ride history page pattern (start date + end date; both optional). Filtered total updates with the visible list. Applied client-side on already-loaded expense list (same approach as ride history) for responsive UX without extra API calls. + +**Rationale**: +- Ride history uses client-side filtering over a fully loaded list; expense lists will typically be shorter than ride lists so the same approach is appropriate. +- API remains simple: `GET /api/expenses?startDate=...&endDate=...` with optional date params to optionally filter server-side when the list grows large. + +--- + +## 6. F# Domain Layer Design + +**Decision**: Add `ExpenseEvents` discriminated union in `BikeTracking.Domain.FSharp` following the same pattern as `UserEvents.fs`. + +**Module structure**: +```fsharp +type ExpenseEvent = + | ExpenseRecorded of ExpenseRecordedData + | ExpenseEdited of ExpenseEditedData + | ExpenseDeleted of ExpenseDeletedId + +type ExpenseRecordedData = { + ExpenseId : int64 + RiderId : int64 + Date : DateTime + Amount : decimal // always positive + Note : string option + ReceiptPath: string option + RecordedAt : DateTime +} +// etc. +``` + +Pure validation functions return `Result` following Railway Oriented Programming (Principle II). + +--- + +## 7. Navigation / Menu Link + +**Decision**: Add two routes and nav links: +- `/expenses/entry` — "Add Expense" entry form +- `/expenses/history` — "Expense History" list with filter and edit + +Navigation links added to the shared nav component used by all protected pages (same pattern as existing `/rides/history`, `/rides/record`, `/settings`). + +**Naming**: "Expenses" as the menu group label; "Add Expense" and "Expense History" as individual link labels — descriptive and consistent with "Record Ride" / "Ride History" naming. + +--- + +## 8. Multipart vs JSON for Receipt Upload + +**Decision**: Multipart form upload (`multipart/form-data`) for the initial `POST /api/expenses` (expense + optional receipt in one request). For edits, a separate `PUT /api/expenses/{id}/receipt` endpoint handles receipt replacement/removal to keep the edit JSON endpoint clean. + +**Rationale**: +- Multipart is the standard HTTP mechanism for file + JSON field co-submission. +- Separating receipt management from field editing avoids re-uploading an existing receipt every time text fields are edited. +- ASP.NET Core Minimal API supports `IFormFile` natively. + +**Security considerations**: +- Validate MIME type server-side (not just client file extension). +- Generate a sanitized, random filename on upload — never trust the original filename. +- Restrict file read to the owning rider (path includes riderId; API validates). +- Max 5 MB enforced via `RequestSizeLimitAttribute` or middleware. diff --git a/specs/015-bike-expense-tracking/spec.md b/specs/015-bike-expense-tracking/spec.md new file mode 100644 index 0000000..6186ab3 --- /dev/null +++ b/specs/015-bike-expense-tracking/spec.md @@ -0,0 +1,150 @@ +# Feature Specification: Bike Expense Tracking + +**Feature Branch**: `015-bike-expense-tracking` +**Created**: 2026-04-17 +**Status**: Clarified +**Input**: User description: "There are many expenses that occur with bike tracking. A new page, with a menu link needs to be created to allow the user to enter an expense and another to view and edit (use the existing ride history as a guide). The Date, amount, Note and upload a receipt (optional) is needed. Show the total amount of expenses on the dashboard. Some expenses are negative (savings) and are automatic: 1) every $3000 save x (based on the user settings: price per oil change). savings will just reduce from the expenses. Include these savings in the already existing savings for gas saved and mileage. Show the savings in the dashboard alongside of it so the user sees those" + +## Clarifications + +### Session 2026-04-17 + +- Q: Should riders be able to delete expenses? → A: Delete supported — same pattern as ride delete (spec 007); tombstone event retained in event log, expense removed from display. +- Q: Does the oil-change savings interval use lifetime cumulative miles or reset annually? → A: Lifetime cumulative — all-time ride miles, never resets. +- Q: Where are receipt files stored on the user's machine? → A: App data folder — receipts/ subfolder alongside the SQLite database file. +- Q: Should expense history support date range filtering? → A: Yes — same date range filter pattern as ride history. +- Q: What is the maximum note length for expense notes? → A: 500 characters — same as ride notes (spec 014). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Enter a Manual Expense (Priority: P1) + +As a rider, I want to record a bike-related expense with a date, amount, optional note, and optional receipt attachment so I can track what I actually spend on my bike. + +**Why this priority**: Recording expenses is the foundation of all other expense features. Without this, there is no data to view, edit, or summarize. + +**Independent Test**: Can be fully tested by navigating to the expense entry page, entering a date, positive amount, a note, and saving — then confirming the expense appears in the expense list with the correct values. + +**Acceptance Scenarios**: + +1. **Given** a signed-in rider on the expense entry form, **When** they enter a valid date, a positive amount, an optional note, and optionally attach a receipt, **Then** the expense is saved and the rider sees a success confirmation. +2. **Given** a rider does not provide a receipt, **When** they save the expense, **Then** the expense is saved successfully without requiring a receipt. +3. **Given** a rider does not enter a note, **When** they save the expense, **Then** the expense is saved successfully without requiring a note. +4. **Given** a rider attempts to save with a missing date or blank amount, **When** they submit, **Then** the save is blocked and clear field-level validation messages are shown. + +--- + +### User Story 2 - View and Edit Expense History (Priority: P2) + +As a rider, I want to see a list of my recorded expenses and be able to edit them in place, following the same pattern as the ride history page, so I can correct mistakes and keep records accurate. + +**Why this priority**: Viewing and correcting recorded expenses is essential to maintaining accurate financial tracking over time. + +**Independent Test**: Can be fully tested by viewing the expense list with at least one recorded expense, entering edit mode on a row, changing the amount or note, saving, and verifying the updated values appear in the list. + +**Acceptance Scenarios**: + +1. **Given** a signed-in rider with at least one saved expense, **When** they open the expense history page, **Then** they see a list of their expenses sorted by date (newest first) with date, amount, note preview, and receipt indicator. +2. **Given** a rider applies a date range filter, **When** the filter is confirmed, **Then** only expenses with dates within the selected range are shown and the visible total updates to reflect the filtered set. +3. **Given** a rider clears or resets the date range filter, **When** the filter is removed, **Then** all expenses are shown again. +4. **Given** a rider is viewing the expense list, **When** they activate edit mode on an expense row, **Then** the row becomes editable with save and cancel actions. +5. **Given** a row is in edit mode with valid changes, **When** the rider saves, **Then** the updated values are persisted and the row returns to read-only with the new values. +6. **Given** a row is in edit mode, **When** the rider cancels, **Then** the original values are restored and no change is saved. +7. **Given** a rider edits a row with invalid values, **When** they attempt to save, **Then** save is blocked and clear field-level validation messages are shown. + +--- + +### User Story 3 - View Expense Totals on Dashboard (Priority: P3) + +As a rider, I want the dashboard to show my total out-of-pocket expenses and my automatic oil-change savings so I can see at a glance whether my bike is saving me money overall. + +**Why this priority**: The dashboard is the rider's primary summary view. Expenses without a dashboard summary miss the core value of the feature. + +**Independent Test**: Can be fully tested by recording several expenses and confirming the dashboard shows the correct total expense amount and the automatically calculated oil-change savings alongside existing gas and mileage savings. + +**Acceptance Scenarios**: + +1. **Given** a rider has recorded expenses, **When** they view the dashboard, **Then** they see a total expense figure that sums all of their saved manual expenses. +2. **Given** a rider has accumulated enough ride miles for one or more oil-change intervals, **When** they view the dashboard, **Then** they see an automatic oil-change savings figure derived from their total ride miles and their saved oil change price setting. +3. **Given** a rider has automatic oil-change savings, **When** those savings are displayed, **Then** they appear alongside the existing gas-saved and mileage-saved figures in the savings section of the dashboard. +4. **Given** a rider has no expenses recorded yet, **When** they view the dashboard, **Then** the expense total shows zero and oil-change savings show based on accrued miles if applicable. + +--- + +### User Story 4 - Automatic Oil-Change Savings Reduce Expense Total (Priority: P4) + +As a rider, I want automatic oil-change savings to count as negative expenses that reduce my net expense total so the dashboard reflects my true financial position. + +**Why this priority**: The user explicitly requested that automatic savings reduce the expense total, making this a required calculation rule rather than a display choice. + +**Independent Test**: Can be fully tested by setting an oil change price in user settings, accumulating rides totaling at least 3000 miles, and confirming the net expense total on the dashboard equals manual expenses minus automatic oil-change savings. + +**Acceptance Scenarios**: + +1. **Given** a rider has a saved oil change price and cumulative ride miles of at least 3000, **When** the dashboard or expense total is calculated, **Then** every complete 3000-mile interval contributes one oil-change saving equal to the rider's saved oil change price. +2. **Given** a rider has automatic oil-change savings, **When** the net expense total is displayed, **Then** it equals total manual expenses minus total automatic oil-change savings (net total can be negative, indicating net savings). +3. **Given** a rider has not set an oil change price, **When** the dashboard loads, **Then** oil-change savings are shown as unavailable rather than zero and the expense total shows only manual expenses. +4. **Given** a rider updates their oil change price, **When** the dashboard is next loaded, **Then** oil-change savings recalculate using the new price while preserving all existing manual expense records. + +--- + +### Edge Cases + +- What happens when a rider has no expenses and no qualifying miles? The dashboard shows zero total expenses and explains that oil-change savings require an oil change price to be set. +- What happens if the receipt file is too large or an unsupported format? The save is blocked with a clear message showing accepted formats and size limits. +- What happens if a receipt cannot be stored due to a storage error? The expense is still saved without the receipt and the rider is notified that the receipt was not attached. +- What happens when a rider deletes a receipt from an existing expense? The expense remains but the receipt attachment is removed. +- What happens if a rider has 4500 miles? Only one complete 3000-mile interval counts; the remaining 1500 miles do not trigger a second oil-change saving. +- What happens if an expense amount is entered as a negative number? Manual expenses must be positive amounts; only automatic savings are treated as negative. The system blocks saving a negative manual expense with a clear validation message. +- What happens when a rider edits an expense and changes the amount but the receipt was already uploaded? The existing receipt remains associated unless the rider explicitly removes or replaces it. +- What happens when an expense has a very long note? Note text is capped at a defined maximum length with appropriate truncation in list view and full display in edit mode. +- What happens when the rider opens the expense list without being authenticated? Expenses are not shown and the rider is redirected to sign in. +- What happens when a rider deletes an expense that had a receipt attached? The receipt file is also removed from storage and the tombstone event records that the receipt was deleted. +- What happens when the same rider opens the same expense in two browser tabs and saves changes from both? The first save succeeds and increments the version; the second save is rejected with a version conflict and must be refreshed before retrying. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide an expense entry page accessible from a named navigation menu link. +- **FR-002**: The expense entry form MUST require a date and a positive numeric amount. +- **FR-003**: The expense entry form MUST allow an optional plain-text note. +- **FR-004**: The expense entry form MUST allow an optional receipt attachment. +- **FR-005**: System MUST validate that the expense amount is a positive number before saving. +- **FR-006**: System MUST validate that a date is provided before saving and block saving with clear field-level messages when validation fails. +- **FR-007**: System MUST save expenses per rider so one rider cannot view or edit another rider's expenses. +- **FR-008**: System MUST provide an expense history page accessible from the navigation menu, styled and behaving consistently with the existing ride history page. +- **FR-008a**: The expense history page MUST support date range filtering using the same filter pattern as ride history; applying a filter MUST update both the displayed expense list and any visible expense total to reflect only the filtered date range. +- **FR-009**: The expense history list MUST display each expense's date, amount, note (truncated for space), and a receipt indicator, sorted by date with the newest first. +- **FR-010**: System MUST allow inline editing of an expense from the expense history page using the same save/cancel pattern as ride history. +- **FR-011**: System MUST validate edited expense fields using the same rules as entry and block save when validation fails. +- **FR-012**: System MUST persist expense edits as a new immutable event while retaining prior history for traceability. +- **FR-012a**: System MUST allow a rider to delete an expense from the expense history page using the same delete pattern as ride delete (spec 007). +- **FR-012b**: System MUST record expense deletion as a tombstone event in the event log; the expense MUST no longer appear in the expense history list or contribute to the dashboard expense total after deletion. +- **FR-013**: System MUST allow a rider to remove or replace an existing receipt attachment when editing an expense. +- **FR-014**: System MUST display the rider's total manual expense amount (sum of all saved positive expense amounts) on the dashboard. +- **FR-015**: System MUST automatically calculate oil-change savings as a derived dashboard value, not a stored expense row, based on the rider's **lifetime cumulative ride miles** (all-time, never reset) divided by 3000, rounded down to the nearest whole interval, multiplied by the rider's saved oil change price. +- **FR-016**: System MUST display oil-change savings on the dashboard alongside the existing gas-saved and mileage-saved figures. +- **FR-017**: System MUST subtract total automatic oil-change savings from total manual expenses to produce a net expense figure displayed on the dashboard. +- **FR-018**: When a rider's oil change price setting is not set, System MUST show oil-change savings as unavailable rather than zero and exclude the oil-change savings from the net expense calculation. +- **FR-019**: System MUST enforce a maximum note length of 500 characters for expense notes (consistent with ride notes, spec 014) and reject entries that exceed it. +- **FR-020**: System MUST enforce accepted file types and maximum file size for receipt uploads and show clear error messaging when a file does not meet those constraints. Accepted formats are JPEG, PNG, WEBP, and PDF. Maximum file size is 5 MB. +- **FR-021**: System MUST not expose expense records or receipt files to unauthenticated users. + +### Key Entities *(include if feature involves data)* + +- **Expense Record**: A rider-owned financial entry consisting of a required date, required positive amount, optional plain-text note, and optional receipt attachment. Stored as immutable events with full edit history. +- **Receipt Attachment**: An optional receipt file linked to an expense record. Stored as a file in a `receipts/` subfolder within the application data folder alongside the SQLite database. Supported formats are JPEG, PNG, WEBP, and PDF. Only accessible to the owning rider. The database record stores the file path/reference, not the binary content. +- **Oil-Change Saving**: An automatically calculated derived value that behaves like a negative expense in dashboard totals. It is derived from the rider's **lifetime cumulative ride miles** (all-time total, never reset) and their oil change price setting. Calculated as `floor(lifetime_ride_miles / 3000) × oil_change_price`. It is not a manually entered record and is not stored as its own expense row. +- **Net Expense Total**: The displayed financial summary on the dashboard: total manual expenses minus total oil-change savings. Can be negative (net savings). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A rider can navigate to the expense entry page, record a complete expense (with or without receipt), and see it reflected in the expense list in under 2 minutes. +- **SC-002**: 100% of manual expense records are isolated per rider — no rider can access another rider's expenses or receipt files. +- **SC-003**: The dashboard expense total and oil-change savings figure recalculate correctly whenever a new expense is recorded, a ride is added, or the oil change price setting is changed, with no stale figures shown to the rider. +- **SC-004**: A rider with cumulative ride miles of at least 3000 and a saved oil change price sees a non-zero oil-change savings figure on the dashboard, positioned alongside gas-saved and mileage-saved values. +- **SC-005**: At least 90% of riders in acceptance testing can locate the expense entry form and record a valid expense on their first attempt without assistance. +- **SC-006**: Receipt uploads that exceed size or format limits are rejected with a clear message before the rider loses other form data they have already entered. diff --git a/specs/015-bike-expense-tracking/tasks.md b/specs/015-bike-expense-tracking/tasks.md new file mode 100644 index 0000000..6257900 --- /dev/null +++ b/specs/015-bike-expense-tracking/tasks.md @@ -0,0 +1,324 @@ +# Tasks: Bike Expense Tracking (Spec 015) + +**Feature**: Bike Expense Tracking +**Branch**: `015-bike-expense-tracking` +**Date Generated**: 2026-04-17 +**Total Tasks**: 86 | **Phases**: 7 | **Parallel Opportunities**: 12+ + +--- + +## Overview + +This implementation follows Test-Driven Development (TDD) — each task is preceded by failing tests to validate the requirement before implementation. Tasks marked `[CONFIRM RED TESTS]` require the failing test output to be reviewed and confirmed before implementation begins, matching the constitution's TDD gate. Tasks are organized by user story (US1–US4) to enable independent, incremental delivery. The suggested MVP scope includes **Phases 1–3** (Setup + Foundational + US1), which delivers the core "record expense" feature end-to-end. + +--- + +## Phase 1: Setup + +**Goal**: Initialize project structure and register dependencies. + +### Independent Test Criteria +- [ ] Solution builds without errors after migrations and new project references added +- [ ] DevContainer environment includes all required tooling (dotnet, npm, F# compiler) +- [ ] New files are registered in correct project files (.csproj, .fsproj) + +### Tasks + +- [X] T001 Create new F# module file `src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs` with empty module declaration +- [X] T002 Register `ExpenseEvents.fs` in `src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj` compilation order +- [X] T003 Create new EF Core entity file `src/BikeTracking.Api/Infrastructure/Persistence/Entities/ExpenseEntity.cs` with table mapping +- [X] T004 Create new folder structure `src/BikeTracking.Api/Application/Expenses/` for application services +- [X] T005 Create new folder structure `src/BikeTracking.Api/Infrastructure/Receipts/` for receipt storage adapter +- [X] T006 Create new folder structure `src/BikeTracking.Api/Endpoints/` (reuse if exists) ready for endpoints +- [X] T007 Create new frontend folder `src/BikeTracking.Frontend/src/pages/expenses/` for entry and history pages +- [X] T008 Create new frontend service file `src/BikeTracking.Frontend/src/services/expenses-api.ts` (placeholder with imports) + +--- + +## Phase 2: Foundational + +**Goal**: Implement core domain logic, data model, and receipt storage port/adapter before any user story work. + +**Blocking**: All user stories depend on completion of this phase. + +### Independent Test Criteria +- [ ] F# domain unit tests validate amount/note/date constraints with 100% pass rate +- [ ] EF Core entity compiles and migration can be generated without errors +- [ ] Receipt storage port interface is implemented and `FileSystemReceiptStorage` adapter passes unit tests (in-memory stubs) +- [ ] Database migration applies successfully in test environment + +### Sub-Phase 2.1: F# Domain (TDD) + +- [X] T009 [P] [CONFIRM RED TESTS] Write failing F# unit tests for `validateAmount`: reject ≤ 0 decimals; accept > 0 (decimal amount) in `src/BikeTracking.Api.Tests/Expenses/ExpenseDomainTests.fs`, run them, and capture user confirmation that the failures are behavioral rather than setup issues +- [X] T010 [P] Implement `validateAmount` function in `src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs` returning `Result` +- [X] T011 [P] Write failing F# unit tests for `validateNotes`: reject strings > 500 chars; accept None; accept short valid strings in `src/BikeTracking.Api.Tests/Expenses/ExpenseDomainTests.fs` +- [X] T012 [P] Implement `validateNotes` function in `src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs` returning `Result` +- [X] T013 [P] Write failing F# unit tests for `validateDate`: reject DateTime.MinValue; accept valid dates in `src/BikeTracking.Api.Tests/Expenses/ExpenseDomainTests.fs` +- [X] T014 [P] Implement `validateDate` function in `src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs` returning `Result` +- [X] T015 [P] Define F# discriminated union types `ExpenseEvent` (ExpenseRecorded | ExpenseEdited | ExpenseDeleted) in `src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs` + +### Sub-Phase 2.2: Data Model (EF Core) + +- [X] T016 [P] Implement `ExpenseEntity.cs` with columns: Id, RiderId, ExpenseDate, Amount, Notes, ReceiptPath, IsDeleted, Version, CreatedAtUtc, UpdatedAtUtc in `src/BikeTracking.Api/Infrastructure/Persistence/Entities/ExpenseEntity.cs` +- [X] T017 [P] Add `DbSet Expenses { get; set; }` to `BikeTrackingDbContext.cs` +- [X] T018 [P] Add EF Core model configuration for ExpenseEntity in `BikeTrackingDbContext.OnModelCreating()`: primary key, foreign key (RiderId → Users), check constraint (Amount > 0), indexes (RiderId ASC + ExpenseDate DESC, RiderId + IsDeleted) +- [X] T019 [P] Generate EF Core migration via `dotnet ef migrations add AddExpensesTable --project src/BikeTracking.Api` from repository root +- [X] T020 [P] Verify migration compiles and applies to test database without errors + +### Sub-Phase 2.3: Receipt Storage Port/Adapter (TDD) + +- [X] T021 [P] Define `IReceiptStorage` port interface in `src/BikeTracking.Api/Application/Expenses/IReceiptStorage.cs` with methods: `SaveAsync(riderId, expenseId, filename, stream)` → Task (relative path), `DeleteAsync(relativePath)` → Task, `GetAsync(relativePath)` → Task +- [X] T022 [P] Implement `FileSystemReceiptStorage.cs` in `src/BikeTracking.Api/Infrastructure/Receipts/FileSystemReceiptStorage.cs` with local filesystem storage (receipts/ subfolder strategy) +- [X] T023 [P] Write unit tests for `FileSystemReceiptStorage` using temporary directories (in-memory stubs for fast feedback) in `src/BikeTracking.Api.Tests/Expenses/ReceiptStorageTests.cs` +- [X] T024 [P] Register `IReceiptStorage` → `FileSystemReceiptStorage` in `src/BikeTracking.Api/Program.cs` dependency injection + +--- + +## Phase 3: User Story 1 — Enter Manual Expense (P1) + +**Goal**: Enable riders to record a manual expense with required date/amount and optional note/receipt. + +**Acceptance**: Expense entry page accessible from menu; form validates and saves expense; rider sees success confirmation. + +### Independent Test Criteria +- [ ] Rider navigates to expense entry page and sees form with date, amount, note, and receipt fields +- [ ] Submitting with missing date or negative/zero amount shows validation error on that field +- [ ] Submitting with valid date + positive amount saves the expense and shows success message +- [ ] Submitting without note or receipt succeeds (both optional) +- [ ] Submitted expense appears in the database with correct RiderId, date, amount, notes, and receipt status + +### Sub-Phase 3.1: Backend Services (TDD) + +- [X] T025 [US1] [CONFIRM RED TESTS] Write failing unit tests for `RecordExpenseService` in `src/BikeTracking.Api.Tests/Expenses/RecordExpenseServiceTests.cs`: validates amount > 0, validates notes ≤ 500 chars, saves to DB, calls receipt storage; run them and capture user confirmation before implementing the service +- [X] T026 [US1] Implement `RecordExpenseService` in `src/BikeTracking.Api/Application/Expenses/RecordExpenseService.cs`: accepts `RecordExpenseRequest` (date, amount, notes), calls domain validators, creates `ExpenseEntity`, saves to context, returns success with expenseId +- [X] T027 [US1] Define `RecordExpenseRequest` and `RecordExpenseResponse` DTOs in `src/BikeTracking.Api/Contracts/ExpenseContracts.cs` + +### Sub-Phase 3.2: API Endpoints (TDD) + +- [X] T028 [US1] Write failing integration tests for `POST /api/expenses` in `src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsTests.cs`: 201 with valid expense, 400 with validation error, 422 with invalid receipt +- [X] T029 [US1] Implement `POST /api/expenses` endpoint in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` accepting `multipart/form-data` (expenseDate, amount, notes, receipt file), calling `RecordExpenseService`, returning 201 with response +- [X] T030 [US1] Implement `GET /api/expenses` endpoint in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` accepting optional `startDate` + `endDate` query params, returning list of non-deleted expenses for authenticated rider with total amount +- [X] T031 [US1] Register `/api/expenses` endpoints in `src/BikeTracking.Api/Program.cs` +- [X] T031A [US1] Write failing integration security tests proving unauthenticated requests to expense endpoints are rejected in `src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsSecurityTests.cs` +- [X] T031B [US1] Write failing integration security tests proving rider A cannot read, edit, delete, or fetch receipts for rider B's expenses in `src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsSecurityTests.cs` +- [X] T031C [US1] Write failing integration security tests proving receipt file access ignores user-supplied paths and blocks path traversal or direct file access attempts in `src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsSecurityTests.cs` + +### Sub-Phase 3.3: Frontend Entry Page (TDD) + +- [X] T032 [US1] Write failing Vitest unit tests for `ExpenseEntryPage` component in `src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.test.tsx`: renders form fields, validates client-side, calls API on submit +- [X] T033 [US1] Implement `ExpenseEntryPage.tsx` in `src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.tsx` with form (date, amount, note, receipt file input), client-side validation, POST to `/api/expenses`, success/error states +- [X] T034 [US1] Implement `ExpenseEntryPage.css` styling for form layout and responsive design in `src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.css` +- [X] T035 [US1] Implement `expenses-api.ts` functions: `recordExpense(formData)` → Promise in `src/BikeTracking.Frontend/src/services/expenses-api.ts` +- [X] T036 [US1] Add route `/expenses/entry` → `ExpenseEntryPage` in `src/BikeTracking.Frontend/src/App.tsx` +- [X] T037 [US1] Add navigation menu link "Record Expense" → `/expenses/entry` in `src/BikeTracking.Frontend/src/[nav component]` + +### Sub-Phase 3.4: E2E Tests (Optional; TDD if included) + +- [ ] T038 [US1] Write Playwright E2E test: navigate to expense entry, fill form, submit, verify expense appears in list in `src/BikeTracking.Frontend/tests/e2e/record-expense.spec.ts` + +--- + +## Phase 4: User Story 2 — View & Edit Expense History (P2) + +**Goal**: Enable riders to view, inline-edit, and delete expenses following the existing ride history pattern. + +**Acceptance**: History page shows list of expenses sorted by date (newest first) with date-range filter; inline editing with save/cancel; deletion with tombstone. + +### Independent Test Criteria +- [ ] History page displays all rider's non-deleted expenses sorted by date (newest first) +- [ ] Date-range filter applied updates both list and visible total +- [ ] Inline edit on a row allows changing amount/note; save persists changes and increments version +- [ ] Edit with version conflict shows error; edit with invalid data blocks save +- [ ] Delete on a row removes expense from list and database (tombstone) +- [ ] Rider cannot access or edit another rider's expenses + +### Sub-Phase 4.1: Backend Services (TDD) + +- [X] T039 [US2] [CONFIRM RED TESTS] Write failing unit tests for `EditExpenseService` in `src/BikeTracking.Api.Tests/Expenses/EditExpenseServiceTests.cs`: optimistic concurrency check, version increment, validation; run them and capture user confirmation before implementing the service +- [X] T040 [US2] Implement `EditExpenseService` in `src/BikeTracking.Api/Application/Expenses/EditExpenseService.cs`: accepts `EditExpenseRequest` (id, date, amount, notes, expectedVersion), validates concurrency, updates entity, increments version +- [X] T041 [US2] [CONFIRM RED TESTS] Write failing unit tests for `DeleteExpenseService` in `src/BikeTracking.Api.Tests/Expenses/DeleteExpenseServiceTests.cs`: sets IsDeleted, removes receipt file; run them and capture user confirmation before implementing the service +- [X] T042 [US2] Implement `DeleteExpenseService` in `src/BikeTracking.Api/Application/Expenses/DeleteExpenseService.cs`: accepts `DeleteExpenseRequest` (id), sets IsDeleted=true, calls receipt storage delete +- [X] T043 [US2] Define `EditExpenseRequest`, `EditExpenseResponse`, `DeleteExpenseRequest` DTOs in `src/BikeTracking.Api/Contracts/ExpenseContracts.cs` + +### Sub-Phase 4.2: API Endpoints + +- [X] T044 [US2] Write failing integration tests for `PUT /api/expenses/{id}`, `DELETE /api/expenses/{id}` in `src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsTests.cs` +- [X] T045 [US2] Implement `PUT /api/expenses/{id}` endpoint in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` accepting JSON (date, amount, notes, expectedVersion), calling `EditExpenseService`, returning 200 with success or 409 conflict +- [X] T046 [US2] Implement `DELETE /api/expenses/{id}` endpoint in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` calling `DeleteExpenseService`, returning 204 +- [X] T047 [US2] Implement `PUT /api/expenses/{id}/receipt` endpoint for receipt upload in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` +- [X] T048 [US2] Implement `DELETE /api/expenses/{id}/receipt` endpoint for receipt removal in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` + +### Sub-Phase 4.3: Frontend History Page (TDD) + +- [X] T049 [US2] Write failing Vitest unit tests for `ExpenseHistoryPage` in `src/BikeTracking.Frontend/src/pages/expenses/ExpenseHistoryPage.test.tsx`: renders list, filters by date, inline edit, delete +- [X] T050 [US2] Implement `ExpenseHistoryPage.tsx` in `src/BikeTracking.Frontend/src/pages/expenses/ExpenseHistoryPage.tsx` following `HistoryPage.tsx` pattern: fetch expenses, render table, support inline edit with save/cancel, delete action, date-range filter +- [X] T051 [US2] Implement `ExpenseHistoryPage.css` styling in `src/BikeTracking.Frontend/src/pages/expenses/ExpenseHistoryPage.css` +- [X] T052 [US2] Implement `expense-page.helpers.ts` utility functions in `src/BikeTracking.Frontend/src/pages/expenses/expense-page.helpers.ts`: format helpers, validation helpers +- [X] T053 [US2] Add functions to `expenses-api.ts`: `getExpenseHistory(startDate?, endDate?)`, `editExpense(id, request)`, `deleteExpense(id)`, `uploadReceipt(id, file)`, `deleteReceipt(id)` +- [X] T054 [US2] Add route `/expenses/history` → `ExpenseHistoryPage` in `src/BikeTracking.Frontend/src/App.tsx` +- [X] T055 [US2] Add navigation menu link "Expense History" → `/expenses/history` in `src/BikeTracking.Frontend/src/[nav component]` + +### Sub-Phase 4.4: E2E Tests (Optional; TDD if included) + +- [X] T056 [US2] Write Playwright E2E test: navigate to history, view expenses, filter by date, inline edit, delete in `src/BikeTracking.Frontend/tests/e2e/manage-expenses.spec.ts` + +--- + +## Phase 5: User Story 3 — View Expense Totals on Dashboard (P3) + +**Goal**: Display total manual expenses and automatic oil-change savings on the dashboard. + +**Acceptance**: Dashboard shows expense summary card with total manual expenses, oil-change savings (if applicable), and net total. + +### Independent Test Criteria +- [ ] Dashboard loads and includes `ExpenseSummary` in response with `TotalManualExpenses`, `OilChangeSavings`, `NetExpenses` +- [ ] Total manual expenses equals sum of all non-deleted expense amounts +- [ ] Oil-change savings calculated as `floor(totalMiles / 3000) × oilChangePrice` or null if price not set +- [ ] Net total equals manual expenses minus oil-change savings (or null if price not set) +- [ ] Dashboard UI displays expense card alongside existing gas-saved and mileage-saved cards + +### Sub-Phase 5.1: Backend Service Extension + +- [X] T057 [US3] [CONFIRM RED TESTS] Write failing unit tests for expense calculation in `GetDashboardService` in `src/BikeTracking.Api.Tests/Dashboard/GetDashboardServiceTests.cs`, run them, and capture user confirmation before implementation +- [X] T058 [US3] Extend `GetDashboardService` in `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs` to query `Expenses` table (non-deleted), sum amounts, calculate oil-change savings +- [X] T059 [US3] Define `DashboardExpenseSummary` record in `src/BikeTracking.Api/Contracts/DashboardContracts.cs` with fields: TotalManualExpenses, OilChangeSavings?, NetExpenses?, OilChangeIntervalCount +- [X] T060 [US3] Add `ExpenseSummary` property to `DashboardTotals` record in `src/BikeTracking.Api/Contracts/DashboardContracts.cs` + +### Sub-Phase 5.2: Frontend Dashboard Update + +- [X] T061 [US3] Write failing Vitest unit tests for expense summary display in `src/BikeTracking.Frontend/src/pages/dashboard/DashboardPage.test.tsx` +- [X] T062 [US3] Update `DashboardPage.tsx` in `src/BikeTracking.Frontend/src/pages/dashboard/DashboardPage.tsx` to render `ExpenseSummary` card from dashboard response +- [X] T063 [US3] Create new component `ExpenseSummaryCard.tsx` in `src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.tsx` displaying TotalManualExpenses, OilChangeSavings, NetExpenses +- [X] T064 [US3] Add CSS styling for `ExpenseSummaryCard` in `src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.css` + +### Sub-Phase 5.3: E2E Tests (Optional; TDD if included) + +- [X] T065 [US3] Write Playwright E2E test: record expense, view dashboard, verify totals updated in `src/BikeTracking.Frontend/tests/e2e/dashboard-expenses.spec.ts` + +--- + +## Phase 6: User Story 4 — Automatic Oil-Change Savings Reduce Expense Total (P4) + +**Goal**: Ensure automatic oil-change savings are subtracted from total expenses to show net financial position. + +**Acceptance**: Net expense total displayed and calculated correctly; negative net (savings) clearly shown; calculation updates when rides or settings change. + +### Independent Test Criteria +- [ ] Dashboard net expense total = manual expenses - oil-change savings +- [ ] Net total can be negative (net savings) +- [ ] Net total recalculates when new expenses added, rides recorded, or oil change price updated +- [ ] UI clearly indicates when net position is savings vs. expenses + +### Sub-Phase 6.1: Calculation & Display Logic + +- [X] T066 [US4] [CONFIRM RED TESTS] Write failing unit tests for net expense calculation in `GetDashboardService` tests, run them, and capture user confirmation before implementation +- [X] T067 [US4] Verify `GetDashboardService` correctly computes `NetExpenses = TotalManualExpenses - OilChangeSavings` (null if oil price not set) +- [X] T068 [US4] Update `ExpenseSummaryCard.tsx` to display net total with visual indicator (e.g., green for savings, red for expense) +- [X] T069 [US4] Add CSS styling for net total indicator in `ExpenseSummaryCard.css` + +### Sub-Phase 6.2: Integration Validation + +- [X] T070 [US4] Write failing E2E test covering: record expense + ride combo, update oil price, verify dashboard recalculation in `src/BikeTracking.Frontend/tests/e2e/savings-calculation.spec.ts` +- [X] T071 [US4] Verify all integration tests pass: expense recording, editing, deletion, dashboard updates + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Goal**: Add final validation, error handling, accessibility, and prepare for production. + +### Tasks + +- [ ] T072 [P] Add comprehensive error handling and logging for receipt upload failures in `FileSystemReceiptStorage.cs` and endpoints, covering disk-full, permission-denied, and non-writable-path scenarios; preserve other form data, save the expense without a receipt when allowed by the spec, and show a clear user-facing message that the receipt was not attached while logging riderId, expenseId, and the failure reason +- [ ] T073 [P] Add request/response logging for expense endpoints in `src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs` +- [ ] T074 [P] Validate receipt file types and sizes on both client (browser) and server (API); reject unsupported formats with clear messages that explicitly name the accepted formats (JPEG, PNG, WEBP, PDF) and the 5 MB maximum, without clearing the other entered form fields +- [X] T075 [P] Add accessibility attributes (aria-labels, role hints) to form fields in `ExpenseEntryPage.tsx` and `ExpenseHistoryPage.tsx` +- [X] T076 [P] Add responsive design breakpoints for mobile/tablet in `ExpenseEntryPage.css`, `ExpenseHistoryPage.css`, `ExpenseSummaryCard.css` +- [X] T077 [P] Run code formatting via `csharpier format .` and ESLint/Stylelint on all new files +- [X] T078 [P] Verify `dotnet test BikeTracking.slnx` and `npm run test:unit` all pass with >85% code coverage +- [X] T079 [P] Run full E2E test suite `npm run test:e2e` against live API/DB and verify all pass +- [X] T080 [P] Create feature documentation in `README.md` (if needed) or project wiki describing the new expense tracking feature +- [X] T080A [P] Document platform-specific receipt storage paths and configuration expectations for Windows, macOS, and Linux so the app-data / `receipts/` location is explicit for support and troubleshooting +- [X] T080B [P] Document local backup and restore guidance for the SQLite database and `receipts/` folder so end users can preserve expense history and attachments together +- [X] T081 [P] Clean up any temporary test files or commented-out code before final commit + +--- + +## Dependency Graph + +``` +Phase 1 (Setup) + ↓ +Phase 2 (Foundational: F# Domain + EF Core + Receipt Storage) + ↓ +Phase 3 (US1: Record Expense) ← Must complete before US2, US3, US4 + ↓ +Phase 4 (US2: View & Edit History) ← Can run parallel with Phase 5 & 6 after US1 complete +Phase 5 (US3: Dashboard Totals) ← Can run parallel with Phase 4 & 6 after US1 complete +Phase 6 (US4: Oil-Change Savings) ← Depends on Phase 5 complete + ↓ +Phase 7 (Polish) +``` + +--- + +## Parallel Execution Opportunities + +### After Phase 2 Completes (Foundational) +- **Backend (T025-T048)** and **Frontend (T032-T055)** for US1 can proceed in parallel on separate machines/branches. +- Unit tests (T025, T039, T041) can be written simultaneously while service implementation happens in parallel. + +### During Phase 4 & 5 +- **Backend service extension (T057-T060)** and **frontend dashboard update (T062-T064)** can run in parallel once Phase 3 is complete. + +### Test Layers +- **Unit tests** (F#, C# services, React components) can run in parallel and are unblocking for implementation. +- **Integration tests** (API endpoints) are unblocking for E2E tests but can start before full implementation. +- **E2E tests** (Playwright) require live API + DB and run last but provide full-stack validation. + +--- + +## Summary + +- **Total Tasks**: 86 (including optional E2E tests) +- **Non-Optional Tasks**: 82 +- **Optional E2E Tasks**: 4 (T038, T056, T065, T070) +- **Phase 1 (Setup)**: 8 tasks +- **Phase 2 (Foundational)**: 16 tasks +- **Phase 3 (US1)**: 17 tasks +- **Phase 4 (US2)**: 18 tasks +- **Phase 5 (US3)**: 9 tasks +- **Phase 6 (US4)**: 6 tasks +- **Phase 7 (Polish)**: 12 tasks + +**Suggested MVP Scope**: Phases 1–3 (41 tasks), delivering end-to-end expense recording with API persistence, basic frontend form, and the critical security tests for expense isolation. + +**Estimated Timeline (Full Feature)**: +- 1–2 weeks (one developer, full-time) with existing BikeTracking codebase familiarity. +- Parallelization can reduce to 1 week with 2+ developers on independent backend/frontend streams. + +--- + +## Implementation Strategy + +### Red-Green-Refactor Cycle (TDD) +1. For each task with tests, write **failing** test(s) first, verify failure, and pause for user confirmation on tasks marked `[CONFIRM RED TESTS]`. +2. Implement minimal code to make tests pass (**green**). +3. Refactor for clarity/performance while keeping tests passing. +4. Move to next task. + +### Code Patterns Reference +- **Optimistic Concurrency**: See `EditRideService.cs` pattern for expense edits. +- **Tombstone Delete**: See `DeleteRideHandler.cs` pattern for expense deletion. +- **Ports-and-Adapters**: `IReceiptStorage` port + `FileSystemReceiptStorage` adapter. +- **Frontend History Page**: See `HistoryPage.tsx` for inline edit + date filter pattern. +- **Dashboard Extensions**: See `GetDashboardService.cs` for service extension pattern. + +### Key Files to Reference +- `src/BikeTracking.Api/Infrastructure/Persistence/Entities/RideEntity.cs` — entity template +- `src/BikeTracking.Api/Application/Rides/EditRideService.cs` — concurrency pattern +- `src/BikeTracking.Api/Application/Rides/DeleteRideHandler.cs` — tombstone pattern +- `src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs` — dashboard extension +- `src/BikeTracking.Frontend/src/pages/HistoryPage.tsx` — frontend history pattern +- `src/BikeTracking.Domain.FSharp/Users/UserEvents.fs` — F# domain pattern + diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs index 9b51005..61b370d 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs @@ -165,6 +165,176 @@ public async Task GetDashboardService_IncludesOptionalMetricValues_WhenDataIsAva Assert.Equal("%", goalSuggestion.UnitLabel); } + [Fact] + public async Task GetDashboardService_ExpenseSummary_WithNoExpenses_ReturnsZeroTotal() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Zero Expense Rider", + NormalizedName = "zero expense rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.NotNull(dashboard.Totals.ExpenseSummary); + Assert.Equal(0m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); + Assert.Null(dashboard.Totals.ExpenseSummary.OilChangeSavings); + Assert.Null(dashboard.Totals.ExpenseSummary.NetExpenses); + } + + [Fact] + public async Task GetDashboardService_ExpenseSummary_SumsNonDeletedExpenses() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Expense Sum Rider", + NormalizedName = "expense sum rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.Expenses.AddRange( + new ExpenseEntity + { + RiderId = rider.UserId, + ExpenseDate = DateTime.Today, + Amount = 25.00m, + IsDeleted = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }, + new ExpenseEntity + { + RiderId = rider.UserId, + ExpenseDate = DateTime.Today.AddDays(-1), + Amount = 15.50m, + IsDeleted = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }, + new ExpenseEntity + { + RiderId = rider.UserId, + ExpenseDate = DateTime.Today.AddDays(-2), + Amount = 100.00m, + IsDeleted = true, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(40.50m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); + } + + [Fact] + public async Task GetDashboardService_ExpenseSummary_WithOilChangePrice_CalculatesSavings() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "Oil Change Rider", + NormalizedName = "oil change rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.UserSettings.Add( + new UserSettingsEntity + { + UserId = rider.UserId, + OilChangePrice = 50m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + + // 7500 miles = 2 full oil change intervals (floor(7500 / 3000) = 2) + dbContext.Rides.AddRange( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 5000m, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now.AddDays(-1), + Miles = 2500m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + + dbContext.Expenses.Add( + new ExpenseEntity + { + RiderId = rider.UserId, + ExpenseDate = DateTime.Today, + Amount = 80m, + IsDeleted = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(80m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); + Assert.Equal(100m, dashboard.Totals.ExpenseSummary.OilChangeSavings); // 2 × $50 + Assert.Equal(-20m, dashboard.Totals.ExpenseSummary.NetExpenses); // 80 - 100 = -20 (net saving) + Assert.Equal(2, dashboard.Totals.ExpenseSummary.OilChangeIntervalCount); + } + + [Fact] + public async Task GetDashboardService_ExpenseSummary_WithNoOilChangePrice_OilSavingsIsNull() + { + using var dbContext = CreateDbContext(); + var rider = new UserEntity + { + DisplayName = "No Oil Price Rider", + NormalizedName = "no oil price rider", + CreatedAtUtc = DateTime.UtcNow, + }; + dbContext.Users.Add(rider); + await dbContext.SaveChangesAsync(); + + dbContext.Rides.Add( + new RideEntity + { + RiderId = rider.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 6000m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await dbContext.SaveChangesAsync(); + + var service = new GetDashboardService(dbContext); + var dashboard = await service.GetAsync(rider.UserId); + + Assert.Equal(0m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); + Assert.Null(dashboard.Totals.ExpenseSummary.OilChangeSavings); + Assert.Null(dashboard.Totals.ExpenseSummary.NetExpenses); + } + private static BikeTrackingDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() diff --git a/src/BikeTracking.Api.Tests/Expenses/DeleteExpenseServiceTests.cs b/src/BikeTracking.Api.Tests/Expenses/DeleteExpenseServiceTests.cs new file mode 100644 index 0000000..c3340f2 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/DeleteExpenseServiceTests.cs @@ -0,0 +1,116 @@ +using BikeTracking.Api.Application.Expenses; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Api.Tests.TestSupport; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class DeleteExpenseServiceTests +{ + [Fact] + public async Task ExecuteAsync_WithActiveExpense_TombstonesExpenseAndDeletesReceipt() + { + await using var context = TestFactories.CreateDbContext(); + var rider = await SeedUserAsync(context, "delete-success"); + var expense = await SeedExpenseAsync(context, rider.UserId, receiptPath: "1/2/receipt.png"); + var receiptStorage = new SpyReceiptStorage(); + var service = new DeleteExpenseService( + context, + receiptStorage, + NullLogger.Instance + ); + + var result = await service.ExecuteAsync(rider.UserId, expense.Id); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Response); + + var persisted = await context.Expenses.SingleAsync(entity => entity.Id == expense.Id); + Assert.True(persisted.IsDeleted); + Assert.Equal("1/2/receipt.png", receiptStorage.DeletedPath); + } + + [Fact] + public async Task ExecuteAsync_WithDeletedExpense_ReturnsAlreadyDeletedError() + { + await using var context = TestFactories.CreateDbContext(); + var rider = await SeedUserAsync(context, "delete-already"); + var expense = await SeedExpenseAsync(context, rider.UserId, receiptPath: null); + expense.IsDeleted = true; + await context.SaveChangesAsync(); + + var service = new DeleteExpenseService( + context, + new SpyReceiptStorage(), + NullLogger.Instance + ); + + var result = await service.ExecuteAsync(rider.UserId, expense.Id); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + Assert.Equal("EXPENSE_ALREADY_DELETED", result.Error.Code); + } + + private static async Task SeedUserAsync(BikeTrackingDbContext context, string name) + { + var user = new UserEntity + { + DisplayName = name, + NormalizedName = name.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + context.Users.Add(user); + await context.SaveChangesAsync(); + return user; + } + + private static async Task SeedExpenseAsync( + BikeTrackingDbContext context, + long riderId, + string? receiptPath + ) + { + var expense = new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = DateTime.Today, + Amount = 11.25m, + Notes = "Delete me", + ReceiptPath = receiptPath, + IsDeleted = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }; + + context.Expenses.Add(expense); + await context.SaveChangesAsync(); + return expense; + } + + private sealed class SpyReceiptStorage : IReceiptStorage + { + public string? DeletedPath { get; private set; } + + public Task SaveAsync(long riderId, long expenseId, string filename, Stream stream) + { + throw new NotSupportedException("Not used in delete tests."); + } + + public Task DeleteAsync(string relativePath) + { + DeletedPath = relativePath; + return Task.CompletedTask; + } + + public Task GetAsync(string relativePath) + { + throw new NotSupportedException("Not used in delete tests."); + } + } +} diff --git a/src/BikeTracking.Api.Tests/Expenses/EditExpenseServiceTests.cs b/src/BikeTracking.Api.Tests/Expenses/EditExpenseServiceTests.cs new file mode 100644 index 0000000..fa132b6 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/EditExpenseServiceTests.cs @@ -0,0 +1,118 @@ +using BikeTracking.Api.Application.Expenses; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Api.Tests.TestSupport; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class EditExpenseServiceTests +{ + [Fact] + public async Task ExecuteAsync_WithVersionMismatch_ReturnsConflictWithCurrentVersion() + { + await using var context = TestFactories.CreateDbContext(); + var rider = await SeedUserAsync(context, "edit-conflict"); + var expense = await SeedExpenseAsync(context, rider.UserId, amount: 18.25m, version: 3); + var service = new EditExpenseService(context, NullLogger.Instance); + + var result = await service.ExecuteAsync( + rider.UserId, + expense.Id, + new EditExpenseRequest(DateTime.Today, 19.0m, "Updated", ExpectedVersion: 2) + ); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + Assert.Equal("EXPENSE_VERSION_CONFLICT", result.Error.Code); + Assert.Equal(3, result.Error.CurrentVersion); + } + + [Fact] + public async Task ExecuteAsync_WithValidRequest_UpdatesFieldsAndIncrementsVersion() + { + await using var context = TestFactories.CreateDbContext(); + var rider = await SeedUserAsync(context, "edit-success"); + var expense = await SeedExpenseAsync(context, rider.UserId, amount: 22.15m, version: 1); + var service = new EditExpenseService(context, NullLogger.Instance); + + var request = new EditExpenseRequest(DateTime.Today.AddDays(-1), 27.5m, "New notes", 1); + + var result = await service.ExecuteAsync(rider.UserId, expense.Id, request); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Response); + Assert.Equal(expense.Id, result.Response.ExpenseId); + Assert.Equal(2, result.Response.NewVersion); + + var persisted = await context.Expenses.SingleAsync(entity => entity.Id == expense.Id); + Assert.Equal(request.ExpenseDate, persisted.ExpenseDate); + Assert.Equal(request.Amount, persisted.Amount); + Assert.Equal(request.Notes, persisted.Notes); + Assert.Equal(2, persisted.Version); + } + + [Fact] + public async Task ExecuteAsync_WithInvalidAmount_ReturnsValidationFailure() + { + await using var context = TestFactories.CreateDbContext(); + var rider = await SeedUserAsync(context, "edit-validate"); + var expense = await SeedExpenseAsync(context, rider.UserId, amount: 8.75m, version: 1); + var service = new EditExpenseService(context, NullLogger.Instance); + + var result = await service.ExecuteAsync( + rider.UserId, + expense.Id, + new EditExpenseRequest(DateTime.Today, 0m, "Bad amount", ExpectedVersion: 1) + ); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + Assert.Equal("VALIDATION_FAILED", result.Error.Code); + + var persisted = await context.Expenses.SingleAsync(entity => entity.Id == expense.Id); + Assert.Equal(8.75m, persisted.Amount); + Assert.Equal(1, persisted.Version); + } + + private static async Task SeedUserAsync(BikeTrackingDbContext context, string name) + { + var user = new UserEntity + { + DisplayName = name, + NormalizedName = name.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + context.Users.Add(user); + await context.SaveChangesAsync(); + return user; + } + + private static async Task SeedExpenseAsync( + BikeTrackingDbContext context, + long riderId, + decimal amount, + int version + ) + { + var expense = new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = DateTime.Today, + Amount = amount, + Notes = "Original note", + IsDeleted = false, + Version = version, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }; + + context.Expenses.Add(expense); + await context.SaveChangesAsync(); + return expense; + } +} diff --git a/src/BikeTracking.Api.Tests/Expenses/ExpenseDomainTests.cs b/src/BikeTracking.Api.Tests/Expenses/ExpenseDomainTests.cs new file mode 100644 index 0000000..4265592 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/ExpenseDomainTests.cs @@ -0,0 +1,129 @@ +using System; +using BikeTracking.Domain.FSharp.Expenses; +using Microsoft.FSharp.Core; +using Microsoft.FSharp.Reflection; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class ExpenseDomainTests +{ + [Fact] + public void ValidateAmount_RejectsZeroAndNegativeValues() + { + var zeroResult = ExpenseEvents.validateAmount(0m); + var negativeResult = ExpenseEvents.validateAmount(-1m); + + AssertResultIsError(zeroResult); + AssertResultIsError(negativeResult); + } + + [Fact] + public void ValidateAmount_AcceptsPositiveValue() + { + var result = ExpenseEvents.validateAmount(12.34m); + + var (caseName, fields) = GetUnionCase(result); + Assert.Equal("Ok", caseName); + Assert.Single(fields); + Assert.Equal(12.34m, Assert.IsType(fields[0])); + } + + [Fact] + public void ValidateNotes_RejectsValuesLongerThan500Characters() + { + var notes = new string('n', 501); + + var result = ExpenseEvents.validateNotes(FSharpOption.Some(notes)); + + AssertResultIsError(result); + } + + [Fact] + public void ValidateNotes_AcceptsNoneAndShortValues() + { + var noneResult = ExpenseEvents.validateNotes(FSharpOption.None); + var shortResult = ExpenseEvents.validateNotes(FSharpOption.Some("Chain lube")); + + var (noneCaseName, noneFields) = GetUnionCase(noneResult); + var (shortCaseName, shortFields) = GetUnionCase(shortResult); + + Assert.Equal("Ok", noneCaseName); + Assert.Single(noneFields); + Assert.Null(noneFields[0]); + + Assert.Equal("Ok", shortCaseName); + Assert.Single(shortFields); + Assert.Equal("Chain lube", Assert.IsType>(shortFields[0]).Value); + } + + [Fact] + public void ValidateDate_RejectsDateTimeMinValue() + { + var result = ExpenseEvents.validateDate(DateTime.MinValue); + + AssertResultIsError(result); + } + + [Fact] + public void ValidateDate_AcceptsValidDate() + { + var expenseDate = new DateTime(2026, 4, 17, 0, 0, 0, DateTimeKind.Local); + + var result = ExpenseEvents.validateDate(expenseDate); + + var (caseName, fields) = GetUnionCase(result); + Assert.Equal("Ok", caseName); + Assert.Single(fields); + Assert.Equal(expenseDate, Assert.IsType(fields[0])); + } + + private static void AssertResultIsError(FSharpResult result) + { + var (caseName, _) = GetUnionCase(result); + Assert.Equal("Error", caseName); + } + + private static void AssertResultIsError(FSharpResult, string> result) + { + var (caseName, _) = GetUnionCase(result); + Assert.Equal("Error", caseName); + } + + private static void AssertResultIsError(FSharpResult result) + { + var (caseName, _) = GetUnionCase(result); + Assert.Equal("Error", caseName); + } + + private static (string CaseName, object[] Fields) GetUnionCase( + FSharpResult result + ) + { + var union = FSharpValue.GetUnionFields(result, typeof(FSharpResult), null); + return (union.Item1.Name, union.Item2); + } + + private static (string CaseName, object[] Fields) GetUnionCase( + FSharpResult, string> result + ) + { + var union = FSharpValue.GetUnionFields( + result, + typeof(FSharpResult, string>), + null + ); + return (union.Item1.Name, union.Item2); + } + + private static (string CaseName, object[] Fields) GetUnionCase( + FSharpResult result + ) + { + var union = FSharpValue.GetUnionFields( + result, + typeof(FSharpResult), + null + ); + return (union.Item1.Name, union.Item2); + } +} diff --git a/src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsSecurityTests.cs b/src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsSecurityTests.cs new file mode 100644 index 0000000..41b4169 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsSecurityTests.cs @@ -0,0 +1,322 @@ +using System.Net; +using System.Net.Http.Json; +using BikeTracking.Api.Application.Expenses; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Endpoints; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class ExpensesEndpointsSecurityTests +{ + [Fact] + public async Task PostExpenses_WithoutAuthentication_ReturnsUnauthorized() + { + await using var host = await SecurityHost.StartAsync(); + + using var form = new MultipartFormDataContent(); + form.Add(new StringContent("2026-04-17"), "expenseDate"); + form.Add(new StringContent("12.50"), "amount"); + + var response = await host.Client.PostAsync("/api/expenses", form); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetExpenses_WithoutAuthentication_ReturnsUnauthorized() + { + await using var host = await SecurityHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/expenses"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetExpenses_AsDifferentRider_ExcludesOtherRidersExpenses() + { + await using var host = await SecurityHost.StartAsync(); + var ownerId = await host.SeedUserAsync("expense-owner"); + var attackerId = await host.SeedUserAsync("expense-attacker"); + + await host.SeedExpenseAsync( + ownerId, + new DateTime(2026, 4, 15), + 22.50m, + "Owner expense", + null + ); + var attackerExpenseId = await host.SeedExpenseAsync( + attackerId, + new DateTime(2026, 4, 16), + 8.75m, + "Attacker expense", + null + ); + + var response = await host.Client.GetWithAuthAsync("/api/expenses", attackerId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Single(payload.Expenses); + Assert.Equal(attackerExpenseId, payload.Expenses[0].ExpenseId); + Assert.DoesNotContain(payload.Expenses, expense => expense.Notes == "Owner expense"); + } + + [Fact] + public async Task PutExpense_ForDifferentRider_ReturnsNotFound() + { + await using var host = await SecurityHost.StartAsync(); + var ownerId = await host.SeedUserAsync("edit-owner"); + var attackerId = await host.SeedUserAsync("edit-attacker"); + var expenseId = await host.SeedExpenseAsync( + ownerId, + new DateTime(2026, 4, 17), + 41.20m, + "Owner edit target", + null + ); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/expenses/{expenseId}") + { + Content = JsonContent.Create( + new + { + expenseDate = "2026-04-17", + amount = 45.10m, + notes = "Attacker update", + expectedVersion = 1, + } + ), + }; + request.Headers.Add("X-User-Id", attackerId.ToString()); + + var response = await host.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteExpense_ForDifferentRider_ReturnsNotFound() + { + await using var host = await SecurityHost.StartAsync(); + var ownerId = await host.SeedUserAsync("delete-owner"); + var attackerId = await host.SeedUserAsync("delete-attacker"); + var expenseId = await host.SeedExpenseAsync( + ownerId, + new DateTime(2026, 4, 18), + 17.15m, + "Owner delete target", + null + ); + + using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/expenses/{expenseId}"); + request.Headers.Add("X-User-Id", attackerId.ToString()); + + var response = await host.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetExpenseReceipt_ForDifferentRider_ReturnsNotFound() + { + await using var host = await SecurityHost.StartAsync(); + var ownerId = await host.SeedUserAsync("receipt-owner"); + var attackerId = await host.SeedUserAsync("receipt-attacker"); + var expenseId = await host.SeedExpenseAsync( + ownerId, + new DateTime(2026, 4, 19), + 63.40m, + "Receipt target", + "1/2/existing-receipt.pdf" + ); + + var response = await host.Client.GetWithAuthAsync( + $"/api/expenses/{expenseId}/receipt", + attackerId + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetExpenseReceipt_WithUserSuppliedPathQuery_IgnoresQueryAndReturnsOwnerReceipt() + { + await using var host = await SecurityHost.StartAsync(); + var ownerId = await host.SeedUserAsync("query-owner"); + var expenseId = await host.SeedExpenseAsync( + ownerId, + new DateTime(2026, 4, 20), + 28.15m, + "Ignore user path query", + "1/1/receipt.pdf" + ); + + var response = await host.Client.GetWithAuthAsync( + $"/api/expenses/{expenseId}/receipt?relativePath=../../../../etc/passwd", + ownerId + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetExpenseReceipt_WithTraversalLikeStoredPath_ReturnsNotFound() + { + await using var host = await SecurityHost.StartAsync(); + var ownerId = await host.SeedUserAsync("traversal-owner"); + var expenseId = await host.SeedExpenseAsync( + ownerId, + new DateTime(2026, 4, 21), + 39.95m, + "Traversal attempt", + "../../../../etc/passwd" + ); + + var response = await host.Client.GetWithAuthAsync( + $"/api/expenses/{expenseId}/receipt", + ownerId + ); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private sealed class SecurityHost(WebApplication app) : IAsyncDisposable + { + public WebApplication App { get; } = app; + + public HttpClient Client { get; } = app.GetTestClient(); + + public static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var databaseName = Guid.NewGuid().ToString(); + + builder.Services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName) + ); + builder + .Services.AddAuthentication("security-test") + .AddScheme( + "security-test", + _ => { } + ); + builder.Services.AddAuthorization(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapExpensesEndpoints(); + await app.StartAsync(); + + return new SecurityHost(app); + } + + public async Task SeedUserAsync(string displayName) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var user = new UserEntity + { + DisplayName = displayName, + NormalizedName = displayName.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + return user.UserId; + } + + public async Task SeedExpenseAsync( + long riderId, + DateTime expenseDate, + decimal amount, + string? notes, + string? receiptPath + ) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var expense = new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = expenseDate, + Amount = amount, + Notes = notes, + ReceiptPath = receiptPath, + IsDeleted = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }; + + dbContext.Expenses.Add(expense); + await dbContext.SaveChangesAsync(); + return expense.Id; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.StopAsync(); + await App.DisposeAsync(); + } + } + + private sealed class SecurityStubReceiptStorage : IReceiptStorage + { + public Task SaveAsync( + long riderId, + long expenseId, + string filename, + Stream stream + ) => Task.FromResult($"{riderId}/{expenseId}/security-stub.bin"); + + public Task DeleteAsync(string relativePath) => Task.CompletedTask; + + public Task GetAsync(string relativePath) => + Task.FromResult(new MemoryStream()); + } + + private sealed class SecurityAuthSchemeOptions : AuthenticationSchemeOptions; + + private sealed class SecurityAuthHandler( + Microsoft.Extensions.Options.IOptionsMonitor options, + Microsoft.Extensions.Logging.ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder + ) : AuthenticationHandler(options, logger, encoder) + { + protected override Task HandleAuthenticateAsync() + { + var userIdString = Request.Headers["X-User-Id"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(userIdString)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var claims = new[] { new System.Security.Claims.Claim("sub", userIdString) }; + var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name); + var principal = new System.Security.Claims.ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsTests.cs new file mode 100644 index 0000000..31cc571 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/ExpensesEndpointsTests.cs @@ -0,0 +1,507 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BikeTracking.Api.Application.Expenses; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Endpoints; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class ExpensesEndpointsTests +{ + [Fact] + public async Task PostExpenses_WithValidExpense_ReturnsCreated() + { + await using var host = await ExpensesApiHost.StartAsync(); + var userId = await host.SeedUserAsync("expense-created"); + + using var form = BuildForm("2026-04-17", "49.95", "Chain replacement"); + + var response = await host.Client.PostWithAuthMultipartAsync("/api/expenses", form, userId); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task PostExpenses_WithInvalidAmount_ReturnsBadRequest() + { + await using var host = await ExpensesApiHost.StartAsync(); + var userId = await host.SeedUserAsync("expense-invalid-amount"); + + using var form = BuildForm("2026-04-17", "0", "Should fail"); + + var response = await host.Client.PostWithAuthMultipartAsync("/api/expenses", form, userId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostExpenses_WithInvalidReceipt_ReturnsUnprocessableEntity() + { + await using var host = await ExpensesApiHost.StartAsync(); + var userId = await host.SeedUserAsync("expense-invalid-receipt"); + + using var form = BuildForm("2026-04-17", "19.99", "With bad receipt"); + var badReceipt = new ByteArrayContent("bad file"u8.ToArray()); + badReceipt.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain"); + form.Add(badReceipt, "receipt", "receipt.txt"); + + var response = await host.Client.PostWithAuthMultipartAsync("/api/expenses", form, userId); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + [Fact] + public async Task GetExpenses_ReturnsOnlyCurrentRiderNonDeletedAndTotals() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderA = await host.SeedUserAsync("rider-a"); + var riderB = await host.SeedUserAsync("rider-b"); + + await host.SeedExpenseAsync(riderA, new DateTime(2026, 4, 1), 10m, "A1", null, false); + await host.SeedExpenseAsync(riderA, new DateTime(2026, 4, 2), 20m, "A2", "file.pdf", false); + await host.SeedExpenseAsync(riderA, new DateTime(2026, 4, 3), 30m, "A3", null, true); + await host.SeedExpenseAsync(riderB, new DateTime(2026, 4, 2), 99m, "B1", null, false); + + var response = await host.Client.GetWithAuthAsync("/api/expenses", riderA); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(2, payload.ExpenseCount); + Assert.Equal(30m, payload.TotalAmount); + Assert.Equal(2, payload.Expenses.Count); + Assert.Contains(payload.Expenses, expense => expense.HasReceipt); + } + + [Fact] + public async Task GetExpenses_WithDateRange_FiltersResult() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("filter-rider"); + + await host.SeedExpenseAsync(riderId, new DateTime(2026, 4, 1), 5m, null, null, false); + await host.SeedExpenseAsync(riderId, new DateTime(2026, 4, 10), 15m, null, null, false); + await host.SeedExpenseAsync(riderId, new DateTime(2026, 4, 20), 25m, null, null, false); + + var response = await host.Client.GetWithAuthAsync( + "/api/expenses?startDate=2026-04-05&endDate=2026-04-15", + riderId + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Single(payload.Expenses); + Assert.Equal(15m, payload.TotalAmount); + Assert.Equal(new DateTime(2026, 4, 10), payload.Expenses[0].ExpenseDate); + } + + [Fact] + public async Task PutExpense_WithValidRequest_ReturnsOkAndUpdatesExpense() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("edit-success-rider"); + var expenseId = await host.SeedExpenseAsync( + riderId, + new DateTime(2026, 4, 12), + 25m, + "Before edit", + null, + false + ); + + var editRequest = new EditExpenseRequest( + ExpenseDate: new DateTime(2026, 4, 13), + Amount: 31.40m, + Notes: "After edit", + ExpectedVersion: 1 + ); + + var editResponse = await host.Client.PutWithAuthAsync( + $"/api/expenses/{expenseId}", + editRequest, + riderId + ); + + Assert.Equal(HttpStatusCode.OK, editResponse.StatusCode); + var payload = await editResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(expenseId, payload.ExpenseId); + Assert.Equal(2, payload.NewVersion); + + var historyResponse = await host.Client.GetWithAuthAsync("/api/expenses", riderId); + Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode); + var historyPayload = + await historyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(historyPayload); + var row = Assert.Single(historyPayload.Expenses); + Assert.Equal(31.40m, row.Amount); + Assert.Equal("After edit", row.Notes); + Assert.Equal(2, row.Version); + } + + [Fact] + public async Task PutExpense_WithStaleExpectedVersion_ReturnsConflict() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("edit-conflict-rider"); + var expenseId = await host.SeedExpenseAsync( + riderId, + new DateTime(2026, 4, 14), + 12.50m, + null, + null, + false + ); + + var response = await host.Client.PutWithAuthAsync( + $"/api/expenses/{expenseId}", + new EditExpenseRequest( + ExpenseDate: new DateTime(2026, 4, 14), + Amount: 20m, + Notes: "Conflict", + ExpectedVersion: 99 + ), + riderId + ); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task DeleteExpense_WithValidOwner_ReturnsNoContentAndExcludesFromHistory() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("delete-success-rider"); + var expenseId = await host.SeedExpenseAsync( + riderId, + new DateTime(2026, 4, 15), + 18.75m, + "Delete me", + null, + false + ); + + var deleteResponse = await host.Client.DeleteWithAuthAsync( + $"/api/expenses/{expenseId}", + riderId + ); + + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + + var historyResponse = await host.Client.GetWithAuthAsync("/api/expenses", riderId); + Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode); + var historyPayload = + await historyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(historyPayload); + Assert.Empty(historyPayload.Expenses); + } + + [Fact] + public async Task DeleteExpense_WhenAlreadyDeleted_ReturnsConflict() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("delete-conflict-rider"); + var expenseId = await host.SeedExpenseAsync( + riderId, + new DateTime(2026, 4, 16), + 9.99m, + null, + null, + false + ); + + var firstDelete = await host.Client.DeleteWithAuthAsync( + $"/api/expenses/{expenseId}", + riderId + ); + Assert.Equal(HttpStatusCode.NoContent, firstDelete.StatusCode); + + var secondDelete = await host.Client.DeleteWithAuthAsync( + $"/api/expenses/{expenseId}", + riderId + ); + + Assert.Equal(HttpStatusCode.Conflict, secondDelete.StatusCode); + } + + [Fact] + public async Task PutExpenseReceipt_WithValidReceipt_ReturnsNoContentAndMarksExpenseHasReceipt() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("receipt-put-rider"); + var expenseId = await host.SeedExpenseAsync( + riderId, + new DateTime(2026, 4, 16), + 14.25m, + "Receipt pending", + null, + false + ); + + using var form = new MultipartFormDataContent(); + var content = new ByteArrayContent("receipt-binary"u8.ToArray()); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("image/png"); + form.Add(content, "receipt", "receipt.png"); + + var putResponse = await host.Client.PutWithAuthMultipartAsync( + $"/api/expenses/{expenseId}/receipt", + form, + riderId + ); + + Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); + + var historyResponse = await host.Client.GetWithAuthAsync("/api/expenses", riderId); + var historyPayload = + await historyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(historyPayload); + var row = Assert.Single(historyPayload.Expenses); + Assert.True(row.HasReceipt); + } + + [Fact] + public async Task DeleteExpenseReceipt_WithExistingReceipt_ReturnsNoContentAndClearsReceipt() + { + await using var host = await ExpensesApiHost.StartAsync(); + var riderId = await host.SeedUserAsync("receipt-delete-rider"); + var expenseId = await host.SeedExpenseAsync( + riderId, + new DateTime(2026, 4, 16), + 14.25m, + "Receipt set", + "1/2/old-receipt.png", + false + ); + + var deleteResponse = await host.Client.DeleteWithAuthAsync( + $"/api/expenses/{expenseId}/receipt", + riderId + ); + + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + + var historyResponse = await host.Client.GetWithAuthAsync("/api/expenses", riderId); + var historyPayload = + await historyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(historyPayload); + var row = Assert.Single(historyPayload.Expenses); + Assert.False(row.HasReceipt); + } + + private static MultipartFormDataContent BuildForm( + string expenseDate, + string amount, + string? notes + ) + { + var form = new MultipartFormDataContent(); + form.Add(new StringContent(expenseDate), "expenseDate"); + form.Add(new StringContent(amount), "amount"); + + if (!string.IsNullOrWhiteSpace(notes)) + { + form.Add(new StringContent(notes), "notes"); + } + + return form; + } + + private sealed class ExpensesApiHost(WebApplication app) : IAsyncDisposable + { + public WebApplication App { get; } = app; + + public HttpClient Client { get; } = app.GetTestClient(); + + public static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var databaseName = Guid.NewGuid().ToString(); + builder.Services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName) + ); + builder + .Services.AddAuthentication("test") + .AddScheme( + "test", + _ => { } + ); + builder.Services.AddAuthorization(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapExpensesEndpoints(); + await app.StartAsync(); + + return new ExpensesApiHost(app); + } + + public async Task SeedUserAsync(string displayName) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var user = new UserEntity + { + DisplayName = displayName, + NormalizedName = displayName.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + return user.UserId; + } + + public async Task SeedExpenseAsync( + long riderId, + DateTime expenseDate, + decimal amount, + string? notes, + string? receiptPath, + bool isDeleted + ) + { + using var scope = App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var expense = new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = expenseDate, + Amount = amount, + Notes = notes, + ReceiptPath = receiptPath, + IsDeleted = isDeleted, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }; + + dbContext.Expenses.Add(expense); + await dbContext.SaveChangesAsync(); + return expense.Id; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.StopAsync(); + await App.DisposeAsync(); + } + } +} + +internal sealed class StubReceiptStorage : IReceiptStorage +{ + public Task SaveAsync(long riderId, long expenseId, string filename, Stream stream) => + Task.FromResult($"{riderId}/{expenseId}/stub.bin"); + + public Task DeleteAsync(string relativePath) => Task.CompletedTask; + + public Task GetAsync(string relativePath) => + Task.FromResult(new MemoryStream()); +} + +internal sealed class ExpensesTestAuthSchemeOptions : AuthenticationSchemeOptions; + +internal sealed class ExpensesTestAuthHandler( + Microsoft.Extensions.Options.IOptionsMonitor options, + Microsoft.Extensions.Logging.ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder +) : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var userIdString = Request.Headers["X-User-Id"].FirstOrDefault(); + if (string.IsNullOrEmpty(userIdString)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var claims = new[] { new System.Security.Claims.Claim("sub", userIdString) }; + var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name); + var principal = new System.Security.Claims.ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} + +internal static class ExpensesHttpClientExtensions +{ + public static async Task PostWithAuthMultipartAsync( + this HttpClient client, + string requestUri, + MultipartFormDataContent form, + long userId + ) + { + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = form }; + request.Headers.Add("X-User-Id", userId.ToString()); + return await client.SendAsync(request); + } + + public static async Task GetWithAuthAsync( + this HttpClient client, + string requestUri, + long userId + ) + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Add("X-User-Id", userId.ToString()); + return await client.SendAsync(request); + } + + public static async Task PutWithAuthAsync( + this HttpClient client, + string requestUri, + T value, + long userId + ) + { + using var request = new HttpRequestMessage(HttpMethod.Put, requestUri) + { + Content = JsonContent.Create(value), + }; + request.Headers.Add("X-User-Id", userId.ToString()); + return await client.SendAsync(request); + } + + public static async Task PutWithAuthMultipartAsync( + this HttpClient client, + string requestUri, + MultipartFormDataContent form, + long userId + ) + { + using var request = new HttpRequestMessage(HttpMethod.Put, requestUri) { Content = form }; + request.Headers.Add("X-User-Id", userId.ToString()); + return await client.SendAsync(request); + } + + public static async Task DeleteWithAuthAsync( + this HttpClient client, + string requestUri, + long userId + ) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri); + request.Headers.Add("X-User-Id", userId.ToString()); + return await client.SendAsync(request); + } +} diff --git a/src/BikeTracking.Api.Tests/Expenses/ReceiptStorageTests.cs b/src/BikeTracking.Api.Tests/Expenses/ReceiptStorageTests.cs new file mode 100644 index 0000000..78689e2 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/ReceiptStorageTests.cs @@ -0,0 +1,89 @@ +using System.Text; +using BikeTracking.Api.Infrastructure.Receipts; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class ReceiptStorageTests +{ + [Fact] + public async Task SaveAsync_StoresFileAndReturnsRelativePath() + { + await using var harness = new ReceiptStorageHarness(); + await using var content = new MemoryStream(Encoding.UTF8.GetBytes("receipt-content")); + + var relativePath = await harness.Storage.SaveAsync(12, 34, "receipt.pdf", content); + + Assert.StartsWith("12/34/", relativePath, StringComparison.Ordinal); + Assert.EndsWith(".pdf", relativePath, StringComparison.OrdinalIgnoreCase); + + var savedPath = Path.Combine( + harness.RootPath, + relativePath.Replace('/', Path.DirectorySeparatorChar) + ); + Assert.True(File.Exists(savedPath)); + } + + [Fact] + public async Task GetAsync_ReturnsPreviouslySavedContent() + { + await using var harness = new ReceiptStorageHarness(); + await using var content = new MemoryStream(Encoding.UTF8.GetBytes("read-me")); + var relativePath = await harness.Storage.SaveAsync(7, 8, "receipt.png", content); + + await using var stored = await harness.Storage.GetAsync(relativePath); + using var reader = new StreamReader(stored, Encoding.UTF8); + + var payload = await reader.ReadToEndAsync(); + + Assert.Equal("read-me", payload); + } + + [Fact] + public async Task DeleteAsync_RemovesPreviouslySavedFile() + { + await using var harness = new ReceiptStorageHarness(); + await using var content = new MemoryStream(Encoding.UTF8.GetBytes("delete-me")); + var relativePath = await harness.Storage.SaveAsync(3, 4, "receipt.webp", content); + + await harness.Storage.DeleteAsync(relativePath); + + var savedPath = Path.Combine( + harness.RootPath, + relativePath.Replace('/', Path.DirectorySeparatorChar) + ); + Assert.False(File.Exists(savedPath)); + } + + [Fact] + public async Task GetAsync_RejectsPathTraversal() + { + await using var harness = new ReceiptStorageHarness(); + + await Assert.ThrowsAsync(() => + harness.Storage.GetAsync("../outside.txt") + ); + } + + private sealed class ReceiptStorageHarness : IAsyncDisposable + { + public ReceiptStorageHarness() + { + RootPath = Path.Combine(Path.GetTempPath(), $"receipts-tests-{Guid.NewGuid():N}"); + Storage = new FileSystemReceiptStorage(RootPath); + } + + public string RootPath { get; } + + public FileSystemReceiptStorage Storage { get; } + + public ValueTask DisposeAsync() + { + if (Directory.Exists(RootPath)) + { + Directory.Delete(RootPath, recursive: true); + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/src/BikeTracking.Api.Tests/Expenses/RecordExpenseServiceTests.cs b/src/BikeTracking.Api.Tests/Expenses/RecordExpenseServiceTests.cs new file mode 100644 index 0000000..bc60d46 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Expenses/RecordExpenseServiceTests.cs @@ -0,0 +1,138 @@ +using BikeTracking.Api.Application.Expenses; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Api.Tests.TestSupport; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Expenses; + +public sealed class RecordExpenseServiceTests +{ + [Fact] + public async Task ExecuteAsync_WithAmountLessThanOrEqualToZero_ThrowsArgumentException() + { + await using var context = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(context, "amount-check"); + var receiptStorage = new SpyReceiptStorage(); + var service = new RecordExpenseService( + context, + receiptStorage, + NullLogger.Instance + ); + + var request = new RecordExpenseRequest(DateTime.Today, 0m, null); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } + + [Fact] + public async Task ExecuteAsync_WithNoteLongerThanFiveHundredCharacters_ThrowsArgumentException() + { + await using var context = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(context, "notes-check"); + var receiptStorage = new SpyReceiptStorage(); + var service = new RecordExpenseService( + context, + receiptStorage, + NullLogger.Instance + ); + + var request = new RecordExpenseRequest(DateTime.Today, 19.95m, new string('n', 501)); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } + + [Fact] + public async Task ExecuteAsync_WithValidRequest_PersistsExpenseAndReturnsResponse() + { + await using var context = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(context, "save-check"); + var receiptStorage = new SpyReceiptStorage(); + var service = new RecordExpenseService( + context, + receiptStorage, + NullLogger.Instance + ); + + var request = new RecordExpenseRequest(DateTime.Today, 49.95m, "New tube"); + + var response = await service.ExecuteAsync(user.UserId, request); + + Assert.True(response.ExpenseId > 0); + Assert.Equal(user.UserId, response.RiderId); + Assert.False(response.ReceiptAttached); + + var persisted = await context.Expenses.SingleAsync(); + Assert.Equal(user.UserId, persisted.RiderId); + Assert.Equal(DateTime.Today, persisted.ExpenseDate); + Assert.Equal(49.95m, persisted.Amount); + Assert.Equal("New tube", persisted.Notes); + Assert.Null(persisted.ReceiptPath); + } + + [Fact] + public async Task ExecuteAsync_WithReceipt_SavesFileAndPersistsReceiptPath() + { + await using var context = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(context, "receipt-check"); + var receiptStorage = new SpyReceiptStorage(); + var service = new RecordExpenseService( + context, + receiptStorage, + NullLogger.Instance + ); + await using var receiptStream = new MemoryStream("stub"u8.ToArray()); + + var response = await service.ExecuteAsync( + user.UserId, + new RecordExpenseRequest(DateTime.Today, 12.5m, "Chain oil"), + "receipt.png", + receiptStream + ); + + Assert.True(response.ReceiptAttached); + Assert.Equal(1, receiptStorage.SaveCallCount); + + var persisted = await context.Expenses.SingleAsync(); + Assert.Equal(receiptStorage.StoredRelativePath, persisted.ReceiptPath); + } + + private static async Task SeedUserAsync(BikeTrackingDbContext context, string name) + { + var user = new UserEntity + { + DisplayName = name, + NormalizedName = name.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + context.Users.Add(user); + await context.SaveChangesAsync(); + return user; + } + + private sealed class SpyReceiptStorage : IReceiptStorage + { + public int SaveCallCount { get; private set; } + + public string StoredRelativePath { get; } = "receipts/stub.png"; + + public Task SaveAsync(long riderId, long expenseId, string filename, Stream stream) + { + SaveCallCount += 1; + return Task.FromResult(StoredRelativePath); + } + + public Task DeleteAsync(string relativePath) => Task.CompletedTask; + + public Task GetAsync(string relativePath) => + Task.FromResult(new MemoryStream()); + } +} diff --git a/src/BikeTracking.Api.Tests/Infrastructure/ExpensesPersistenceTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/ExpensesPersistenceTests.cs new file mode 100644 index 0000000..c57ae80 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Infrastructure/ExpensesPersistenceTests.cs @@ -0,0 +1,123 @@ +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Infrastructure; + +public sealed class ExpensesPersistenceTests +{ + [Fact] + public async Task SqliteMigrations_ApplyIncludingAddExpensesTable() + { + await using var harness = await SqliteExpenseDbHarness.StartAsync(); + + var appliedMigrations = await harness.Context.Database.GetAppliedMigrationsAsync(); + + Assert.Contains( + appliedMigrations, + migration => migration.Contains("AddExpensesTable", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task DbContext_CanSaveExpenseEntity_WithAllConfiguredFields() + { + await using var harness = await SqliteExpenseDbHarness.StartAsync(); + + var user = new UserEntity + { + DisplayName = "Expense Rider", + NormalizedName = "expense rider", + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + harness.Context.Users.Add(user); + await harness.Context.SaveChangesAsync(); + + var expense = new ExpenseEntity + { + RiderId = user.UserId, + ExpenseDate = new DateTime(2026, 4, 17), + Amount = 49.95m, + Notes = "New tire", + ReceiptPath = "1/10/receipt.pdf", + IsDeleted = false, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }; + + harness.Context.Expenses.Add(expense); + await harness.Context.SaveChangesAsync(); + + var retrieved = await harness.Context.Expenses.SingleAsync(); + Assert.Equal(user.UserId, retrieved.RiderId); + Assert.Equal(expense.ExpenseDate, retrieved.ExpenseDate); + Assert.Equal(49.95m, retrieved.Amount); + Assert.Equal("New tire", retrieved.Notes); + Assert.Equal("1/10/receipt.pdf", retrieved.ReceiptPath); + Assert.False(retrieved.IsDeleted); + Assert.Equal(1, retrieved.Version); + } + + private sealed class SqliteExpenseDbHarness : IAsyncDisposable + { + private SqliteExpenseDbHarness(BikeTrackingDbContext context, string databasePath) + { + Context = context; + DatabasePath = databasePath; + } + + public BikeTrackingDbContext Context { get; } + + private string DatabasePath { get; } + + public static async Task StartAsync() + { + var databasePath = Path.Combine( + Path.GetTempPath(), + $"biketracking-expenses-tests-{Guid.NewGuid():N}.db" + ); + + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath}") + .Options; + + var context = new BikeTrackingDbContext(options); + + await SqliteMigrationBootstrapper.ApplyCompatibilityWorkaroundsAsync( + context, + NullLogger.Instance + ); + await context.Database.MigrateAsync(); + + return new SqliteExpenseDbHarness(context, databasePath); + } + + public async ValueTask DisposeAsync() + { + await Context.DisposeAsync(); + + foreach ( + var path in new[] { DatabasePath, $"{DatabasePath}-shm", $"{DatabasePath}-wal" } + ) + { + if (!File.Exists(path)) + { + continue; + } + + try + { + File.Delete(path); + } + catch (IOException) + { + // Ignore transient cleanup failures from SQLite file locks. + } + } + } + } +} diff --git a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs index 5706175..f1365e5 100644 --- a/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs +++ b/src/BikeTracking.Api.Tests/Infrastructure/MigrationTestCoveragePolicyTests.cs @@ -36,6 +36,8 @@ public sealed class MigrationTestCoveragePolicyTests "Updated test: gas lookup and import enrichment coverage validates weekly WeekStartDate cache-key behavior after schema migration.", ["20260414164512_AddRideNotes"] = "Added test: rides service and history projection coverage validates note persistence and retrieval after schema migration.", + ["20260417194545_AddExpensesTable"] = + "Added test: expense endpoint integration tests validate expense creation, editing, deletion, and receipt handling after schema migration.", }; [Fact] diff --git a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs index 7a3f31c..ad7125b 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs @@ -45,12 +45,25 @@ public async Task GetAsync( var savings = CalculateSavings(rides); + var totalManualExpenses = + await dbContext + .Expenses.Where(e => e.RiderId == riderId && !e.IsDeleted) + .SumAsync(e => (decimal?)e.Amount, cancellationToken) + ?? 0m; + + var expenseSummary = CalculateExpenseSummary( + totalManualExpenses, + rides.Sum(r => r.Miles), + settings?.OilChangePrice + ); + return new DashboardResponse( Totals: new DashboardTotals( CurrentMonthMiles: CreateMileageMetric(currentMonthRides, "thisMonth"), YearToDateMiles: CreateMileageMetric(currentYearRides, "thisYear"), AllTimeMiles: CreateMileageMetric(rides, "allTime"), - MoneySaved: savings.Totals + MoneySaved: savings.Totals, + ExpenseSummary: expenseSummary ), Averages: new DashboardAverages( AverageTemperature: CalculateAverageTemperature(rides), @@ -74,6 +87,34 @@ ride.SnapshotMileageRateCents is null || ride.SnapshotAverageCarMpg is null ); } + private static DashboardExpenseSummary CalculateExpenseSummary( + decimal totalManualExpenses, + decimal totalMiles, + decimal? oilChangePrice + ) + { + if (oilChangePrice is null) + { + return new DashboardExpenseSummary( + TotalManualExpenses: totalManualExpenses, + OilChangeSavings: null, + NetExpenses: null, + OilChangeIntervalCount: 0 + ); + } + + var intervalCount = (int)Math.Floor(totalMiles / 3000m); + var oilChangeSavings = intervalCount * oilChangePrice.Value; + var netExpenses = totalManualExpenses - oilChangeSavings; + + return new DashboardExpenseSummary( + TotalManualExpenses: totalManualExpenses, + OilChangeSavings: oilChangeSavings, + NetExpenses: netExpenses, + OilChangeIntervalCount: intervalCount + ); + } + private static DashboardMileageMetric CreateMileageMetric( IReadOnlyCollection rides, string period diff --git a/src/BikeTracking.Api/Application/Expenses/DeleteExpenseService.cs b/src/BikeTracking.Api/Application/Expenses/DeleteExpenseService.cs new file mode 100644 index 0000000..314e0dd --- /dev/null +++ b/src/BikeTracking.Api/Application/Expenses/DeleteExpenseService.cs @@ -0,0 +1,79 @@ +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace BikeTracking.Api.Application.Expenses; + +public sealed class DeleteExpenseService( + BikeTrackingDbContext dbContext, + IReceiptStorage receiptStorage, + ILogger logger +) +{ + public sealed record DeleteExpenseError(string Code, string Message); + + public sealed record DeleteExpenseResult( + bool IsSuccess, + DeleteExpenseResponse? Response, + DeleteExpenseError? Error + ) + { + public static DeleteExpenseResult Success(DeleteExpenseResponse response) + { + return new DeleteExpenseResult(true, response, null); + } + + public static DeleteExpenseResult Failure(string code, string message) + { + return new DeleteExpenseResult(false, null, new DeleteExpenseError(code, message)); + } + } + + public async Task ExecuteAsync( + long riderId, + long expenseId, + CancellationToken cancellationToken = default + ) + { + var expense = await dbContext + .Expenses.Where(current => current.Id == expenseId) + .SingleOrDefaultAsync(cancellationToken); + + if (expense is null || expense.RiderId != riderId) + { + return DeleteExpenseResult.Failure( + "EXPENSE_NOT_FOUND", + $"Expense {expenseId} was not found." + ); + } + + if (expense.IsDeleted) + { + return DeleteExpenseResult.Failure( + "EXPENSE_ALREADY_DELETED", + $"Expense {expenseId} was already deleted." + ); + } + + if (!string.IsNullOrWhiteSpace(expense.ReceiptPath)) + { + await receiptStorage.DeleteAsync(expense.ReceiptPath); + } + + expense.IsDeleted = true; + expense.UpdatedAtUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Deleted expense {ExpenseId} for rider {RiderId}", + expense.Id, + riderId + ); + + return DeleteExpenseResult.Success( + new DeleteExpenseResponse(expense.Id, expense.UpdatedAtUtc) + ); + } +} diff --git a/src/BikeTracking.Api/Application/Expenses/EditExpenseService.cs b/src/BikeTracking.Api/Application/Expenses/EditExpenseService.cs new file mode 100644 index 0000000..ea8b4c1 --- /dev/null +++ b/src/BikeTracking.Api/Application/Expenses/EditExpenseService.cs @@ -0,0 +1,132 @@ +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace BikeTracking.Api.Application.Expenses; + +public sealed class EditExpenseService( + BikeTrackingDbContext dbContext, + ILogger logger +) +{ + public sealed record EditExpenseError(string Code, string Message, int? CurrentVersion = null); + + public sealed record EditExpenseResult( + bool IsSuccess, + EditExpenseResponse? Response, + EditExpenseError? Error + ) + { + public static EditExpenseResult Success(EditExpenseResponse response) + { + return new EditExpenseResult(true, response, null); + } + + public static EditExpenseResult Failure( + string code, + string message, + int? currentVersion = null + ) + { + return new EditExpenseResult( + false, + null, + new EditExpenseError(code, message, currentVersion) + ); + } + } + + public async Task ExecuteAsync( + long riderId, + long expenseId, + EditExpenseRequest request, + CancellationToken cancellationToken = default + ) + { + var validationFailure = ValidateRequest(request); + if (validationFailure is not null) + { + return validationFailure; + } + + var expense = await dbContext + .Expenses.Where(current => current.Id == expenseId) + .SingleOrDefaultAsync(cancellationToken); + + if (expense is null || expense.RiderId != riderId || expense.IsDeleted) + { + return EditExpenseResult.Failure( + "EXPENSE_NOT_FOUND", + $"Expense {expenseId} was not found." + ); + } + + var currentVersion = expense.Version <= 0 ? 1 : expense.Version; + if (request.ExpectedVersion != currentVersion) + { + return EditExpenseResult.Failure( + "EXPENSE_VERSION_CONFLICT", + "Expense edit conflict. The expense was updated by another request.", + currentVersion + ); + } + + expense.ExpenseDate = request.ExpenseDate; + expense.Amount = request.Amount; + expense.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes; + expense.Version = currentVersion + 1; + expense.UpdatedAtUtc = DateTime.UtcNow; + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return EditExpenseResult.Failure( + "EXPENSE_VERSION_CONFLICT", + "Expense edit conflict. The expense was updated by another request.", + currentVersion + ); + } + + logger.LogInformation( + "Edited expense {ExpenseId} for rider {RiderId} from version {PreviousVersion} to {NewVersion}", + expense.Id, + riderId, + currentVersion, + expense.Version + ); + + return EditExpenseResult.Success( + new EditExpenseResponse(expense.Id, expense.UpdatedAtUtc, expense.Version) + ); + } + + private static EditExpenseResult? ValidateRequest(EditExpenseRequest request) + { + if (request.Amount <= 0) + { + return EditExpenseResult.Failure("VALIDATION_FAILED", "Amount must be greater than 0."); + } + + if (request.ExpectedVersion <= 0) + { + return EditExpenseResult.Failure( + "VALIDATION_FAILED", + "Expected version must be at least 1." + ); + } + + if (request.Notes is not null && request.Notes.Length > 500) + { + return EditExpenseResult.Failure( + "VALIDATION_FAILED", + "Note must be 500 characters or fewer." + ); + } + + return null; + } +} diff --git a/src/BikeTracking.Api/Application/Expenses/IReceiptStorage.cs b/src/BikeTracking.Api/Application/Expenses/IReceiptStorage.cs new file mode 100644 index 0000000..e4ad14c --- /dev/null +++ b/src/BikeTracking.Api/Application/Expenses/IReceiptStorage.cs @@ -0,0 +1,10 @@ +namespace BikeTracking.Api.Application.Expenses; + +public interface IReceiptStorage +{ + Task SaveAsync(long riderId, long expenseId, string filename, Stream stream); + + Task DeleteAsync(string relativePath); + + Task GetAsync(string relativePath); +} diff --git a/src/BikeTracking.Api/Application/Expenses/RecordExpenseService.cs b/src/BikeTracking.Api/Application/Expenses/RecordExpenseService.cs new file mode 100644 index 0000000..e1fc26a --- /dev/null +++ b/src/BikeTracking.Api/Application/Expenses/RecordExpenseService.cs @@ -0,0 +1,121 @@ +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Domain.FSharp.Expenses; +using Microsoft.FSharp.Core; +using Microsoft.FSharp.Reflection; + +namespace BikeTracking.Api.Application.Expenses; + +public sealed class RecordExpenseService( + BikeTrackingDbContext dbContext, + IReceiptStorage receiptStorage, + ILogger logger +) +{ + public async Task ExecuteAsync( + long riderId, + RecordExpenseRequest request, + string? receiptFileName = null, + Stream? receiptStream = null, + CancellationToken cancellationToken = default + ) + { + var validatedAmount = EnsureValid( + ExpenseEvents.validateAmount(request.Amount), + nameof(request) + ); + var validatedDate = EnsureValid( + ExpenseEvents.validateDate(request.ExpenseDate), + nameof(request) + ); + var noteOption = request.Notes is null + ? FSharpOption.None + : FSharpOption.Some(request.Notes); + var validatedNotesOption = EnsureValid( + ExpenseEvents.validateNotes(noteOption), + nameof(request) + ); + + var now = DateTime.UtcNow; + var expense = new ExpenseEntity + { + RiderId = riderId, + ExpenseDate = validatedDate, + Amount = validatedAmount, + Notes = validatedNotesOption?.Value, + IsDeleted = false, + Version = 1, + CreatedAtUtc = now, + UpdatedAtUtc = now, + }; + + dbContext.Expenses.Add(expense); + await dbContext.SaveChangesAsync(cancellationToken); + + var receiptAttached = false; + string? receiptError = null; + if (!string.IsNullOrWhiteSpace(receiptFileName) && receiptStream is not null) + { + try + { + expense.ReceiptPath = await receiptStorage.SaveAsync( + riderId, + expense.Id, + receiptFileName, + receiptStream + ); + expense.UpdatedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + receiptAttached = true; + } + catch (IOException ex) + { + logger.LogError( + ex, + "Receipt upload failed for riderId={RiderId}, expenseId={ExpenseId}: I/O or disk error — {Reason}", + riderId, + expense.Id, + ex.Message + ); + receiptError = + "Receipt could not be saved due to a storage error. The expense has been recorded without the receipt."; + } + catch (UnauthorizedAccessException ex) + { + logger.LogError( + ex, + "Receipt upload failed for riderId={RiderId}, expenseId={ExpenseId}: permission denied — {Reason}", + riderId, + expense.Id, + ex.Message + ); + receiptError = + "Receipt could not be saved due to a permission error. The expense has been recorded without the receipt."; + } + } + + return new RecordExpenseResponse( + expense.Id, + riderId, + expense.UpdatedAtUtc, + receiptAttached, + receiptError + ); + } + + private static T EnsureValid(FSharpResult validationResult, string paramName) + { + var union = FSharpValue.GetUnionFields( + validationResult, + typeof(FSharpResult), + null + ); + if (union.Item1.Name == "Ok") + { + return (T)union.Item2[0]; + } + + throw new ArgumentException((string)union.Item2[0], paramName); + } +} diff --git a/src/BikeTracking.Api/Contracts/DashboardContracts.cs b/src/BikeTracking.Api/Contracts/DashboardContracts.cs index 75d4ef1..8d327e3 100644 --- a/src/BikeTracking.Api/Contracts/DashboardContracts.cs +++ b/src/BikeTracking.Api/Contracts/DashboardContracts.cs @@ -13,7 +13,8 @@ public sealed record DashboardTotals( DashboardMileageMetric CurrentMonthMiles, DashboardMileageMetric YearToDateMiles, DashboardMileageMetric AllTimeMiles, - DashboardMoneySaved MoneySaved + DashboardMoneySaved MoneySaved, + DashboardExpenseSummary ExpenseSummary ); public sealed record DashboardMileageMetric(decimal Miles, int RideCount, string Period); @@ -55,6 +56,13 @@ public sealed record DashboardMetricSuggestion( string? UnitLabel = null ); +public sealed record DashboardExpenseSummary( + decimal TotalManualExpenses, + decimal? OilChangeSavings, + decimal? NetExpenses, + int OilChangeIntervalCount +); + public sealed record DashboardMissingData( int RidesMissingSavingsSnapshot, int RidesMissingGasPrice, diff --git a/src/BikeTracking.Api/Contracts/ExpenseContracts.cs b/src/BikeTracking.Api/Contracts/ExpenseContracts.cs new file mode 100644 index 0000000..45df150 --- /dev/null +++ b/src/BikeTracking.Api/Contracts/ExpenseContracts.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace BikeTracking.Api.Contracts; + +public sealed record RecordExpenseRequest( + [property: Required(ErrorMessage = "Expense date is required")] DateTime ExpenseDate, + [property: Required(ErrorMessage = "Amount is required")] + [property: Range(0.01, 999999.99, ErrorMessage = "Amount must be greater than 0")] + decimal Amount, + [property: MaxLength(500, ErrorMessage = "Note must be 500 characters or fewer")] string? Notes +); + +public sealed record RecordExpenseResponse( + long ExpenseId, + long RiderId, + DateTime SavedAtUtc, + bool ReceiptAttached, + string? ReceiptError = null +); + +public sealed record ExpenseHistoryRow( + long ExpenseId, + DateTime ExpenseDate, + decimal Amount, + string? Notes, + bool HasReceipt, + int Version, + DateTime CreatedAtUtc +); + +public sealed record ExpenseHistoryResponse( + IReadOnlyList Expenses, + decimal TotalAmount, + int ExpenseCount, + DateTime GeneratedAtUtc +); + +public sealed record EditExpenseRequest( + [property: Required(ErrorMessage = "Expense date is required")] DateTime ExpenseDate, + [property: Required(ErrorMessage = "Amount is required")] + [property: Range(0.01, 999999.99, ErrorMessage = "Amount must be greater than 0")] + decimal Amount, + [property: MaxLength(500, ErrorMessage = "Note must be 500 characters or fewer")] string? Notes, + [property: Range(1, int.MaxValue, ErrorMessage = "Expected version must be at least 1")] + int ExpectedVersion +); + +public sealed record EditExpenseResponse(long ExpenseId, DateTime SavedAtUtc, int NewVersion); + +public sealed record DeleteExpenseResponse(long ExpenseId, DateTime DeletedAtUtc); diff --git a/src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs b/src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs new file mode 100644 index 0000000..a3e3122 --- /dev/null +++ b/src/BikeTracking.Api/Endpoints/ExpensesEndpoints.cs @@ -0,0 +1,651 @@ +using BikeTracking.Api.Application.Expenses; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Endpoints; + +public static class ExpensesEndpoints +{ + private static readonly FileExtensionContentTypeProvider ReceiptContentTypeProvider = new(); + + private static readonly HashSet AllowedReceiptContentTypes = + [ + "image/jpeg", + "image/png", + "image/webp", + "application/pdf", + ]; + + private const long MaxReceiptSizeBytes = 5L * 1024 * 1024; // 5 MB + + private const string AllowedReceiptFormatsMessage = + "Receipt must be JPEG, PNG, WEBP, or PDF and cannot exceed 5 MB."; + + public static IEndpointRouteBuilder MapExpensesEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/expenses"); + + group + .MapPost("", PostExpense) + .WithName("RecordExpense") + .WithSummary("Record a new expense") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status422UnprocessableEntity) + .RequireAuthorization(); + + group + .MapGet("", GetExpenses) + .WithName("GetExpenses") + .WithSummary("Get expense history for authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .RequireAuthorization(); + + group + .MapPut("/{expenseId:long}", PutExpense) + .WithName("EditExpense") + .WithSummary("Edit an existing expense for the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .RequireAuthorization(); + + group + .MapDelete("/{expenseId:long}", DeleteExpense) + .WithName("DeleteExpense") + .WithSummary("Delete an existing expense for the authenticated rider") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .RequireAuthorization(); + + group + .MapPut("/{expenseId:long}/receipt", PutExpenseReceipt) + .WithName("PutExpenseReceipt") + .WithSummary("Upload or replace a receipt for an existing expense") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status422UnprocessableEntity) + .RequireAuthorization(); + + group + .MapDelete("/{expenseId:long}/receipt", DeleteExpenseReceipt) + .WithName("DeleteExpenseReceipt") + .WithSummary("Remove a receipt from an expense") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + group + .MapGet("/{expenseId:long}/receipt", GetExpenseReceipt) + .WithName("GetExpenseReceipt") + .WithSummary("Get the receipt for an expense owned by the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + return endpoints; + } + + private static async Task PostExpense( + HttpContext context, + ILoggerFactory loggerFactory, + RecordExpenseService recordExpenseService, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + logger.LogInformation("POST /api/expenses — riderId={RiderId}", riderId); + + var form = await context.Request.ReadFormAsync(cancellationToken); + var expenseDateValue = form["expenseDate"].ToString(); + var amountValue = form["amount"].ToString(); + var notes = form["notes"].ToString(); + + if (!DateTime.TryParse(expenseDateValue, out var expenseDate)) + { + logger.LogWarning( + "POST /api/expenses rejected: invalid date — riderId={RiderId}", + riderId + ); + return Results.BadRequest( + new ErrorResponse("VALIDATION_FAILED", "Expense date is required") + ); + } + + if (!decimal.TryParse(amountValue, out var amount)) + { + logger.LogWarning( + "POST /api/expenses rejected: invalid amount — riderId={RiderId}", + riderId + ); + return Results.BadRequest(new ErrorResponse("VALIDATION_FAILED", "Amount is required")); + } + + var receipt = form.Files.GetFile("receipt"); + if (receipt is not null && receipt.Length > MaxReceiptSizeBytes) + { + logger.LogWarning( + "POST /api/expenses rejected: receipt too large ({Size} bytes) — riderId={RiderId}", + receipt.Length, + riderId + ); + return Results.UnprocessableEntity( + new ErrorResponse("UNSUPPORTED_RECEIPT", AllowedReceiptFormatsMessage) + ); + } + + if (receipt is not null && !AllowedReceiptContentTypes.Contains(receipt.ContentType)) + { + logger.LogWarning( + "POST /api/expenses rejected: unsupported receipt type {ContentType} — riderId={RiderId}", + receipt.ContentType, + riderId + ); + return Results.UnprocessableEntity( + new ErrorResponse("UNSUPPORTED_RECEIPT", AllowedReceiptFormatsMessage) + ); + } + + try + { + var request = new RecordExpenseRequest( + expenseDate, + amount, + string.IsNullOrWhiteSpace(notes) ? null : notes + ); + + await using var receiptStream = receipt?.OpenReadStream(); + var response = await recordExpenseService.ExecuteAsync( + riderId, + request, + receipt?.FileName, + receiptStream, + cancellationToken + ); + + if (response.ReceiptError is not null) + { + logger.LogWarning( + "POST /api/expenses expenseId={ExpenseId} saved without receipt for riderId={RiderId}: {ReceiptError}", + response.ExpenseId, + riderId, + response.ReceiptError + ); + } + else + { + logger.LogInformation( + "POST /api/expenses expenseId={ExpenseId} created for riderId={RiderId}, receiptAttached={ReceiptAttached}", + response.ExpenseId, + riderId, + response.ReceiptAttached + ); + } + + return Results.Created($"/api/expenses/{response.ExpenseId}", response); + } + catch (ArgumentException ex) + { + logger.LogWarning( + ex, + "POST /api/expenses domain validation failed — riderId={RiderId}", + riderId + ); + return Results.BadRequest(new ErrorResponse("VALIDATION_FAILED", ex.Message)); + } + } + + private static async Task GetExpenses( + HttpContext context, + ILoggerFactory loggerFactory, + BikeTrackingDbContext dbContext, + string? startDate, + string? endDate, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + logger.LogInformation( + "GET /api/expenses — riderId={RiderId}, startDate={StartDate}, endDate={EndDate}", + riderId, + startDate, + endDate + ); + + DateTime? parsedStartDate = null; + if (!string.IsNullOrWhiteSpace(startDate)) + { + if (!DateTime.TryParse(startDate, out var parsed)) + { + return Results.BadRequest( + new ErrorResponse("VALIDATION_FAILED", "startDate must be a valid date") + ); + } + + parsedStartDate = parsed.Date; + } + + DateTime? parsedEndDate = null; + if (!string.IsNullOrWhiteSpace(endDate)) + { + if (!DateTime.TryParse(endDate, out var parsed)) + { + return Results.BadRequest( + new ErrorResponse("VALIDATION_FAILED", "endDate must be a valid date") + ); + } + + parsedEndDate = parsed.Date; + } + + var query = dbContext + .Expenses.AsNoTracking() + .Where(expense => expense.RiderId == riderId && !expense.IsDeleted); + + if (parsedStartDate.HasValue) + { + query = query.Where(expense => expense.ExpenseDate >= parsedStartDate.Value); + } + + if (parsedEndDate.HasValue) + { + query = query.Where(expense => expense.ExpenseDate <= parsedEndDate.Value); + } + + var expenses = await query + .OrderByDescending(expense => expense.ExpenseDate) + .ThenByDescending(expense => expense.CreatedAtUtc) + .ToListAsync(cancellationToken); + + var rows = expenses + .Select(expense => new ExpenseHistoryRow( + ExpenseId: expense.Id, + ExpenseDate: expense.ExpenseDate, + Amount: expense.Amount, + Notes: expense.Notes, + HasReceipt: !string.IsNullOrWhiteSpace(expense.ReceiptPath), + Version: expense.Version, + CreatedAtUtc: expense.CreatedAtUtc + )) + .ToList(); + + var response = new ExpenseHistoryResponse( + Expenses: rows, + TotalAmount: rows.Sum(row => row.Amount), + ExpenseCount: rows.Count, + GeneratedAtUtc: DateTime.UtcNow + ); + + logger.LogInformation( + "GET /api/expenses — riderId={RiderId} returned {Count} expenses", + riderId, + response.ExpenseCount + ); + + return Results.Ok(response); + } + + private static async Task PutExpense( + [FromRoute] long expenseId, + [FromBody] EditExpenseRequest request, + HttpContext context, + ILoggerFactory loggerFactory, + [FromServices] EditExpenseService editExpenseService, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + logger.LogInformation( + "PUT /api/expenses/{ExpenseId} — riderId={RiderId}", + expenseId, + riderId + ); + + var result = await editExpenseService.ExecuteAsync( + riderId, + expenseId, + request, + cancellationToken + ); + + if (result.IsSuccess && result.Response is not null) + { + logger.LogInformation( + "PUT /api/expenses/{ExpenseId} — updated for riderId={RiderId}", + expenseId, + riderId + ); + return Results.Ok(result.Response); + } + + var error = + result.Error ?? new EditExpenseService.EditExpenseError("ERROR", "Unknown error."); + + return error.Code switch + { + "VALIDATION_FAILED" => Results.BadRequest(new ErrorResponse(error.Code, error.Message)), + "EXPENSE_NOT_FOUND" => Results.NotFound(new ErrorResponse(error.Code, error.Message)), + "EXPENSE_VERSION_CONFLICT" => Results.Conflict( + new + { + code = error.Code, + message = error.Message, + currentVersion = error.CurrentVersion, + } + ), + _ => Results.BadRequest(new ErrorResponse(error.Code, error.Message)), + }; + } + + private static async Task DeleteExpense( + [FromRoute] long expenseId, + HttpContext context, + ILoggerFactory loggerFactory, + DeleteExpenseService deleteExpenseService, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + logger.LogInformation( + "DELETE /api/expenses/{ExpenseId} — riderId={RiderId}", + expenseId, + riderId + ); + + var result = await deleteExpenseService.ExecuteAsync(riderId, expenseId, cancellationToken); + + if (result.IsSuccess) + { + logger.LogInformation( + "DELETE /api/expenses/{ExpenseId} — deleted for riderId={RiderId}", + expenseId, + riderId + ); + return Results.NoContent(); + } + + var error = + result.Error ?? new DeleteExpenseService.DeleteExpenseError("ERROR", "Unknown error."); + + return error.Code switch + { + "EXPENSE_NOT_FOUND" => Results.NotFound(new ErrorResponse(error.Code, error.Message)), + "EXPENSE_ALREADY_DELETED" => Results.Conflict( + new ErrorResponse(error.Code, error.Message) + ), + _ => Results.BadRequest(new ErrorResponse(error.Code, error.Message)), + }; + } + + private static async Task PutExpenseReceipt( + [FromRoute] long expenseId, + HttpContext context, + ILoggerFactory loggerFactory, + BikeTrackingDbContext dbContext, + IReceiptStorage receiptStorage, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + logger.LogInformation( + "PUT /api/expenses/{ExpenseId}/receipt — riderId={RiderId}", + expenseId, + riderId + ); + + var expense = await dbContext + .Expenses.Where(current => current.Id == expenseId) + .SingleOrDefaultAsync(cancellationToken); + + if (expense is null || expense.RiderId != riderId || expense.IsDeleted) + { + return Results.NotFound( + new ErrorResponse("EXPENSE_NOT_FOUND", $"Expense {expenseId} was not found.") + ); + } + + var form = await context.Request.ReadFormAsync(cancellationToken); + var receipt = form.Files.GetFile("receipt"); + if (receipt is null) + { + return Results.BadRequest( + new ErrorResponse("VALIDATION_FAILED", "Receipt file is required.") + ); + } + + if (!AllowedReceiptContentTypes.Contains(receipt.ContentType)) + { + return Results.UnprocessableEntity( + new ErrorResponse("UNSUPPORTED_RECEIPT", AllowedReceiptFormatsMessage) + ); + } + + if (receipt.Length > MaxReceiptSizeBytes) + { + logger.LogWarning( + "PUT /api/expenses/{ExpenseId}/receipt rejected: file too large ({Size} bytes) — riderId={RiderId}", + expenseId, + receipt.Length, + riderId + ); + return Results.UnprocessableEntity( + new ErrorResponse("UNSUPPORTED_RECEIPT", AllowedReceiptFormatsMessage) + ); + } + + if (!string.IsNullOrWhiteSpace(expense.ReceiptPath)) + { + await receiptStorage.DeleteAsync(expense.ReceiptPath); + } + + try + { + await using var receiptStream = receipt.OpenReadStream(); + expense.ReceiptPath = await receiptStorage.SaveAsync( + riderId, + expense.Id, + receipt.FileName, + receiptStream + ); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogError( + ex, + "PUT /api/expenses/{ExpenseId}/receipt storage failure for riderId={RiderId}: {Reason}", + expenseId, + riderId, + ex.Message + ); + return Results.Problem( + detail: "Receipt could not be saved due to a storage error. Please try again later.", + statusCode: StatusCodes.Status500InternalServerError + ); + } + + expense.UpdatedAtUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "PUT /api/expenses/{ExpenseId}/receipt — saved for riderId={RiderId}", + expenseId, + riderId + ); + return Results.NoContent(); + } + + private static async Task DeleteExpenseReceipt( + [FromRoute] long expenseId, + HttpContext context, + ILoggerFactory loggerFactory, + BikeTrackingDbContext dbContext, + IReceiptStorage receiptStorage, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + logger.LogInformation( + "DELETE /api/expenses/{ExpenseId}/receipt — riderId={RiderId}", + expenseId, + riderId + ); + + var expense = await dbContext + .Expenses.Where(current => current.Id == expenseId) + .SingleOrDefaultAsync(cancellationToken); + + if (expense is null || expense.RiderId != riderId || expense.IsDeleted) + { + return Results.NotFound( + new ErrorResponse("EXPENSE_NOT_FOUND", $"Expense {expenseId} was not found.") + ); + } + + if (string.IsNullOrWhiteSpace(expense.ReceiptPath)) + { + return Results.NoContent(); + } + + await receiptStorage.DeleteAsync(expense.ReceiptPath); + expense.ReceiptPath = null; + expense.UpdatedAtUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "DELETE /api/expenses/{ExpenseId}/receipt — removed for riderId={RiderId}", + expenseId, + riderId + ); + return Results.NoContent(); + } + + private static async Task GetExpenseReceipt( + [FromRoute] long expenseId, + HttpContext context, + ILoggerFactory loggerFactory, + BikeTrackingDbContext dbContext, + IReceiptStorage receiptStorage, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("ExpensesEndpoints"); + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + { + return Results.Unauthorized(); + } + + var expense = await dbContext + .Expenses.AsNoTracking() + .Where(current => current.Id == expenseId) + .SingleOrDefaultAsync(cancellationToken); + + if ( + expense is null + || expense.RiderId != riderId + || expense.IsDeleted + || string.IsNullOrWhiteSpace(expense.ReceiptPath) + || !IsSafeReceiptRelativePath(expense.ReceiptPath) + ) + { + return Results.NotFound( + new ErrorResponse("EXPENSE_NOT_FOUND", $"Expense {expenseId} was not found.") + ); + } + + try + { + var receiptStream = await receiptStorage.GetAsync(expense.ReceiptPath); + var contentType = ResolveReceiptContentType(expense.ReceiptPath); + return Results.File(receiptStream, contentType); + } + catch (FileNotFoundException) + { + return Results.NotFound( + new ErrorResponse( + "RECEIPT_NOT_FOUND", + $"Receipt for expense {expenseId} was not found." + ) + ); + } + } + + private static string ResolveReceiptContentType(string receiptPath) + { + return ReceiptContentTypeProvider.TryGetContentType(receiptPath, out var contentType) + ? contentType + : "application/octet-stream"; + } + + private static bool IsSafeReceiptRelativePath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) + { + return false; + } + + var normalized = relativePath.Replace('\\', '/'); + if (normalized.StartsWith('/') || normalized.Contains(":", StringComparison.Ordinal)) + { + return false; + } + + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + return false; + } + + return segments.All(segment => segment != "." && segment != ".."); + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index 9bbaecd..65bf1b1 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -11,6 +11,7 @@ public sealed class BikeTrackingDbContext(DbContextOptions AuthAttemptStates => Set(); public DbSet OutboxEvents => Set(); public DbSet Rides => Set(); + public DbSet Expenses => Set(); public DbSet ImportJobs => Set(); public DbSet ImportRows => Set(); public DbSet GasPriceLookups => Set(); @@ -152,6 +153,49 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(static entity => + { + entity.ToTable( + "Expenses", + static tableBuilder => + { + tableBuilder.HasCheckConstraint( + "CK_Expenses_Amount_Positive", + "CAST(\"Amount\" AS REAL) > 0" + ); + } + ); + entity.HasKey(static x => x.Id); + entity.Property(static x => x.RiderId).IsRequired(); + entity.Property(static x => x.ExpenseDate).IsRequired(); + entity.Property(static x => x.Amount).IsRequired().HasPrecision(10, 2); + entity.Property(static x => x.Notes).HasMaxLength(500); + entity.Property(static x => x.ReceiptPath).HasMaxLength(500); + entity.Property(static x => x.IsDeleted).HasDefaultValue(false); + entity + .Property(static x => x.Version) + .IsRequired() + .HasDefaultValue(1) + .IsConcurrencyToken(); + entity.Property(static x => x.CreatedAtUtc).IsRequired(); + entity.Property(static x => x.UpdatedAtUtc).IsRequired(); + + entity + .HasIndex(static x => new { x.RiderId, x.ExpenseDate }) + .IsDescending(false, true) + .HasDatabaseName("IX_Expenses_RiderId_ExpenseDate_Desc"); + + entity + .HasIndex(static x => new { x.RiderId, x.IsDeleted }) + .HasDatabaseName("IX_Expenses_RiderId_IsDeleted"); + + entity + .HasOne() + .WithMany() + .HasForeignKey(static x => x.RiderId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(static entity => { entity.ToTable( diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/ExpenseEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/ExpenseEntity.cs new file mode 100644 index 0000000..2352105 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/ExpenseEntity.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace BikeTracking.Api.Infrastructure.Persistence.Entities; + +[Table("Expenses")] +public sealed class ExpenseEntity +{ + public long Id { get; set; } + + public long RiderId { get; set; } + + public DateTime ExpenseDate { get; set; } + + public decimal Amount { get; set; } + + public string? Notes { get; set; } + + public string? ReceiptPath { get; set; } + + public bool IsDeleted { get; set; } + + public int Version { get; set; } = 1; + + public DateTime CreatedAtUtc { get; set; } + + public DateTime UpdatedAtUtc { get; set; } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260417194545_AddExpensesTable.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260417194545_AddExpensesTable.Designer.cs new file mode 100644 index 0000000..ed8f14c --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260417194545_AddExpensesTable.Designer.cs @@ -0,0 +1,722 @@ +// +using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260417194545_AddExpensesTable")] + partial class AddExpensesTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("ConsecutiveWrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DelayUntilUtc") + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulAuthUtc") + .HasColumnType("TEXT"); + + b.Property("LastWrongAttemptUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("AuthAttemptStates", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpenseDate") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceiptPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "ExpenseDate") + .IsDescending(false, true) + .HasDatabaseName("IX_Expenses_RiderId_ExpenseDate_Desc"); + + b.HasIndex("RiderId", "IsDeleted") + .HasDatabaseName("IX_Expenses_RiderId_IsDeleted"); + + b.ToTable("Expenses", null, t => + { + t.HasCheckConstraint("CK_Expenses_Amount_Positive", "CAST(\"Amount\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => + { + b.Property("GasPriceLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EiaPeriodDate") + .HasColumnType("TEXT"); + + b.Property("PriceDate") + .HasColumnType("TEXT"); + + b.Property("PricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("WeekStartDate") + .HasColumnType("TEXT"); + + b.HasKey("GasPriceLookupId"); + + b.HasIndex("PriceDate") + .IsUnique(); + + b.HasIndex("WeekStartDate") + .IsUnique(); + + b.ToTable("GasPriceLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("EtaMinutesRounded") + .HasColumnType("INTEGER"); + + b.Property("FailedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ImportedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OverrideAllDuplicates") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ProcessedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SkippedRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TotalRows") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc"); + + b.ToTable("ImportJobs", null, t => + { + t.HasCheckConstraint("CK_ImportJobs_FailedRows_NonNegative", "\"FailedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ImportedRows_NonNegative", "\"ImportedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_Lte_TotalRows", "\"ProcessedRows\" <= \"TotalRows\""); + + t.HasCheckConstraint("CK_ImportJobs_ProcessedRows_NonNegative", "\"ProcessedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_SkippedRows_NonNegative", "\"SkippedRows\" >= 0"); + + t.HasCheckConstraint("CK_ImportJobs_TotalRows_NonNegative", "\"TotalRows\" >= 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedRideId") + .HasColumnType("INTEGER"); + + b.Property("DuplicateResolution") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("DuplicateStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("ExistingRideIdsJson") + .HasColumnType("TEXT"); + + b.Property("ImportJobId") + .HasColumnType("INTEGER"); + + b.Property("Miles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("RideDateLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RowNumber") + .HasColumnType("INTEGER"); + + b.Property("TagsRaw") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("ValidationErrorsJson") + .HasColumnType("TEXT"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ImportJobId", "RowNumber") + .IsUnique(); + + b.ToTable("ImportRows", null, t => + { + t.HasCheckConstraint("CK_ImportRows_Miles_Range", "\"Miles\" IS NULL OR (CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200)"); + + t.HasCheckConstraint("CK_ImportRows_RideMinutes_Positive", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_ImportRows_RowNumber_Positive", "\"RowNumber\" > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("GasPricePerGallon") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RideDateTimeLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("SnapshotAverageCarMpg") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotMileageRateCents") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotOilChangePrice") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("SnapshotYearlyGoalMiles") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("WeatherUserOverridden") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); + + b.ToTable("Rides", null, t => + { + t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotAverageCarMpg_Positive", "\"SnapshotAverageCarMpg\" IS NULL OR CAST(\"SnapshotAverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotMileageRateCents_Positive", "\"SnapshotMileageRateCents\" IS NULL OR CAST(\"SnapshotMileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotOilChangePrice_Positive", "\"SnapshotOilChangePrice\" IS NULL OR CAST(\"SnapshotOilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_Rides_SnapshotYearlyGoalMiles_Positive", "\"SnapshotYearlyGoalMiles\" IS NULL OR CAST(\"SnapshotYearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("DashboardGallonsAvoidedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("DashboardGoalProgressEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.WeatherLookupEntity", b => + { + b.Property("WeatherLookupId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudCoverPercent") + .HasColumnType("INTEGER"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LatitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LongitudeRounded") + .HasPrecision(8, 2) + .HasColumnType("TEXT"); + + b.Property("LookupHourUtc") + .HasColumnType("TEXT"); + + b.Property("PrecipitationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RelativeHumidityPercent") + .HasColumnType("INTEGER"); + + b.Property("RetrievedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Temperature") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.Property("WindDirectionDeg") + .HasColumnType("INTEGER"); + + b.Property("WindSpeedMph") + .HasPrecision(10, 4) + .HasColumnType("TEXT"); + + b.HasKey("WeatherLookupId"); + + b.HasIndex("LookupHourUtc", "LatitudeRounded", "LongitudeRounded") + .IsUnique(); + + b.ToTable("WeatherLookups", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => + { + b.Property("OutboxEventId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateId") + .HasColumnType("INTEGER"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EventPayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("NextAttemptUtc") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PublishedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("OutboxEventId"); + + b.HasIndex("AggregateType", "AggregateId"); + + b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); + + b.ToTable("OutboxEvents", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.Property("UserCredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IterationCount") + .HasColumnType("INTEGER"); + + b.Property("PinHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PinSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("UserCredentialId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCredentials", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("AuthAttemptState") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportRowEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", "ImportJob") + .WithMany("Rows") + .HasForeignKey("ImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ImportJob"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("Credential") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Navigation("AuthAttemptState"); + + b.Navigation("Credential"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260417194545_AddExpensesTable.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260417194545_AddExpensesTable.cs new file mode 100644 index 0000000..a45ac0b --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260417194545_AddExpensesTable.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddExpensesTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Expenses", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RiderId = table.Column(type: "INTEGER", nullable: false), + ExpenseDate = table.Column(type: "TEXT", nullable: false), + Amount = table.Column( + type: "TEXT", + precision: 10, + scale: 2, + nullable: false + ), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + ReceiptPath = table.Column( + type: "TEXT", + maxLength: 500, + nullable: true + ), + IsDeleted = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: false + ), + Version = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Expenses", x => x.Id); + table.CheckConstraint( + "CK_Expenses_Amount_Positive", + "CAST(\"Amount\" AS REAL) > 0" + ); + table.ForeignKey( + name: "FK_Expenses_Users_RiderId", + column: x => x.RiderId, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_RiderId_ExpenseDate_Desc", + table: "Expenses", + columns: new[] { "RiderId", "ExpenseDate" }, + descending: new[] { false, true } + ); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_RiderId_IsDeleted", + table: "Expenses", + columns: new[] { "RiderId", "IsDeleted" } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Expenses"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index 3d1b266..e7b3778 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -41,6 +41,62 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuthAttemptStates", (string)null); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpenseDate") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ReceiptPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "ExpenseDate") + .IsDescending(false, true) + .HasDatabaseName("IX_Expenses_RiderId_ExpenseDate_Desc"); + + b.HasIndex("RiderId", "IsDeleted") + .HasDatabaseName("IX_Expenses_RiderId_IsDeleted"); + + b.ToTable("Expenses", null, t => + { + t.HasCheckConstraint("CK_Expenses_Amount_Positive", "CAST(\"Amount\" AS REAL) > 0"); + }); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.GasPriceLookupEntity", b => { b.Property("GasPriceLookupId") @@ -588,6 +644,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ExpenseEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.ImportJobEntity", b => { b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) diff --git a/src/BikeTracking.Api/Infrastructure/Receipts/FileSystemReceiptStorage.cs b/src/BikeTracking.Api/Infrastructure/Receipts/FileSystemReceiptStorage.cs new file mode 100644 index 0000000..460046d --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Receipts/FileSystemReceiptStorage.cs @@ -0,0 +1,145 @@ +using BikeTracking.Api.Application.Expenses; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Infrastructure.Receipts; + +public sealed class FileSystemReceiptStorage : IReceiptStorage +{ + private readonly string receiptsRootPath; + private readonly ILogger logger; + + public FileSystemReceiptStorage( + IConfiguration configuration, + ILogger logger + ) + : this(ResolveReceiptsRoot(configuration)) + { + this.logger = logger; + } + + public FileSystemReceiptStorage(string receiptsRootPath) + { + this.receiptsRootPath = Path.GetFullPath(receiptsRootPath); + this.logger = NullLogger.Instance; + } + + public async Task SaveAsync( + long riderId, + long expenseId, + string filename, + Stream stream + ) + { + var extension = Path.GetExtension(filename); + var generatedFileName = $"{Guid.NewGuid():N}{extension}"; + var relativePath = Path.Combine( + riderId.ToString(), + expenseId.ToString(), + generatedFileName + ); + var fullPath = ResolveFullPath(relativePath); + var directory = Path.GetDirectoryName(fullPath); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + if (stream.CanSeek) + { + stream.Position = 0; + } + + try + { + await using var fileStream = new FileStream( + fullPath, + FileMode.Create, + FileAccess.Write + ); + await stream.CopyToAsync(fileStream); + } + catch (IOException ex) + { + logger.LogError( + ex, + "Failed to write receipt file at {FullPath}: I/O or disk error — {Reason}", + fullPath, + ex.Message + ); + throw; + } + catch (UnauthorizedAccessException ex) + { + logger.LogError( + ex, + "Failed to write receipt file at {FullPath}: permission denied — {Reason}", + fullPath, + ex.Message + ); + throw; + } + + return relativePath.Replace(Path.DirectorySeparatorChar, '/'); + } + + public Task DeleteAsync(string relativePath) + { + var fullPath = ResolveFullPath(relativePath); + + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + + return Task.CompletedTask; + } + + public Task GetAsync(string relativePath) + { + var fullPath = ResolveFullPath(relativePath); + Stream fileStream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + return Task.FromResult(fileStream); + } + + private string ResolveFullPath(string relativePath) + { + var normalizedRelativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); + var fullPath = Path.GetFullPath(Path.Combine(receiptsRootPath, normalizedRelativePath)); + + if (!fullPath.StartsWith(receiptsRootPath, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Receipt path must stay within the receipts root."); + } + + return fullPath; + } + + private static string ResolveReceiptsRoot(IConfiguration configuration) + { + var connectionString = + configuration.GetConnectionString("BikeTracking") + ?? "Data Source=biketracking.local.db"; + var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString); + var dataSource = sqliteBuilder.DataSource; + + if (string.IsNullOrWhiteSpace(dataSource)) + { + dataSource = "biketracking.local.db"; + } + + if (!Path.IsPathRooted(dataSource)) + { + dataSource = Path.GetFullPath(dataSource, AppContext.BaseDirectory); + } + + var databaseDirectory = Path.GetDirectoryName(dataSource) ?? AppContext.BaseDirectory; + return Path.Combine(databaseDirectory, "receipts"); + } +} diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 95c2101..91cb713 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -1,11 +1,13 @@ using BikeTracking.Api.Application.Dashboard; using BikeTracking.Api.Application.Events; +using BikeTracking.Api.Application.Expenses; using BikeTracking.Api.Application.Imports; using BikeTracking.Api.Application.Notifications; using BikeTracking.Api.Application.Rides; using BikeTracking.Api.Application.Users; using BikeTracking.Api.Endpoints; using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Receipts; using BikeTracking.Api.Infrastructure.Security; using Microsoft.AspNetCore.HttpLogging; using Microsoft.Data.Sqlite; @@ -53,6 +55,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -156,6 +162,7 @@ app.MapDashboardEndpoints(); app.MapUsersEndpoints(); app.MapRidesEndpoints(); +app.MapExpensesEndpoints(); app.MapImportEndpoints(); app.MapHub("/hubs/import-progress").RequireAuthorization(); app.MapDefaultEndpoints(); diff --git a/src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj b/src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj index b6eb31a..1da9383 100644 --- a/src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj +++ b/src/BikeTracking.Domain.FSharp/BikeTracking.Domain.FSharp.fsproj @@ -6,6 +6,7 @@ + diff --git a/src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs b/src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs new file mode 100644 index 0000000..9b81860 --- /dev/null +++ b/src/BikeTracking.Domain.FSharp/Expenses/ExpenseEvents.fs @@ -0,0 +1,54 @@ +namespace BikeTracking.Domain.FSharp.Expenses + +open System + +module ExpenseEvents = + type ExpenseRecordedData = { + ExpenseId: int64 + RiderId: int64 + ExpenseDate: DateTime + Amount: decimal + Notes: string option + ReceiptPath: string option + RecordedAt: DateTime + } + + type ExpenseEditedData = { + ExpenseId: int64 + RiderId: int64 + ExpenseDate: DateTime + Amount: decimal + Notes: string option + ReceiptPath: string option + ExpectedVersion: int + EditedAt: DateTime + } + + type ExpenseDeletedData = { + ExpenseId: int64 + RiderId: int64 + DeletedAt: DateTime + } + + type ExpenseEvent = + | ExpenseRecorded of ExpenseRecordedData + | ExpenseEdited of ExpenseEditedData + | ExpenseDeleted of ExpenseDeletedData + + let validateAmount (amount: decimal) : Result = + if amount > 0m then + Ok amount + else + Error "Expense amount must be greater than zero" + + let validateNotes (notes: string option) : Result = + match notes with + | None -> Ok None + | Some value when value.Length > 500 -> Error "Note must be 500 characters or fewer" + | Some value -> Ok (Some value) + + let validateDate (expenseDate: DateTime) : Result = + if expenseDate = DateTime.MinValue then + Error "Expense date is required" + else + Ok expenseDate diff --git a/src/BikeTracking.Frontend/src/App.tsx b/src/BikeTracking.Frontend/src/App.tsx index 09e7531..48fac83 100644 --- a/src/BikeTracking.Frontend/src/App.tsx +++ b/src/BikeTracking.Frontend/src/App.tsx @@ -9,6 +9,8 @@ import { RecordRidePage } from './pages/RecordRidePage' import { HistoryPage } from './pages/HistoryPage' import { SettingsPage } from './pages/settings/SettingsPage' import { ImportRidesPage } from './pages/import-rides/ImportRidesPage' +import { ExpenseEntryPage } from './pages/expenses/ExpenseEntryPage' +import { ExpenseHistoryPage } from './pages/expenses/ExpenseHistoryPage' function App() { return ( @@ -24,6 +26,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx b/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx index d8935f4..ebc88ee 100644 --- a/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx +++ b/src/BikeTracking.Frontend/src/components/app-header/app-header.tsx @@ -37,6 +37,22 @@ export function AppHeader() { > Ride History + + isActive ? 'nav-link nav-link-active' : 'nav-link' + } + > + Record Expense + + + isActive ? 'nav-link nav-link-active' : 'nav-link' + } + > + Expense History +
diff --git a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx index f3f184a..72dc171 100644 --- a/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/HistoryPage.test.tsx @@ -502,10 +502,6 @@ describe('HistoryPage', () => { name: /gas price/i, }) as HTMLInputElement expect(gasPriceInput.value).toBe('3.9999') - // I moved this to a tooltip - // expect( - // screen.getByText('Source: U.S. Energy Information Administration (EIA)') - // ).toBeInTheDocument() }) }) diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.css b/src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.css new file mode 100644 index 0000000..5484777 --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.css @@ -0,0 +1,42 @@ +.dashboard-summary-card-accent-expenses { + background: linear-gradient(180deg, #7c3aed, #f97316); +} + +.expense-summary-card-details { + display: grid; + gap: 0.55rem; +} + +.expense-summary-card-row { + display: flex; + justify-content: space-between; + gap: 1rem; + color: var(--dashboard-muted); + font-size: 0.92rem; +} + +.expense-summary-card-row-net { + padding-top: 0.35rem; + border-top: 1px solid var(--dashboard-border); + color: var(--dashboard-ink); + font-weight: 700; +} + +.expense-summary-card-row-net-savings { + color: #166534; +} + +.expense-summary-card-row-net-expense { + color: #b91c1c; +} + +.expense-summary-card-row-net-neutral { + color: var(--dashboard-ink); +} + +@media (width <= 640px) { + .expense-summary-card-row { + flex-direction: column; + gap: 0.2rem; + } +} \ No newline at end of file diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.tsx new file mode 100644 index 0000000..6c9404b --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/dashboard/ExpenseSummaryCard.tsx @@ -0,0 +1,59 @@ +import { DashboardSummaryCard } from '../../components/dashboard/dashboard-summary-card' +import type { DashboardExpenseSummary } from '../../services/dashboard-api' +import './ExpenseSummaryCard.css' + +interface ExpenseSummaryCardProps { + expenseSummary: DashboardExpenseSummary +} + +function formatCurrency(value: number | null): string { + if (value === null) { + return '—' + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + }).format(value) +} + +function getNetState(netExpenses: number | null): 'neutral' | 'savings' | 'expense' { + if (netExpenses === null) { + return 'neutral' + } + + return netExpenses < 0 ? 'savings' : 'expense' +} + +export function ExpenseSummaryCard({ expenseSummary }: ExpenseSummaryCardProps) { + const netState = getNetState(expenseSummary.netExpenses) + const netLabel = netState === 'savings' ? 'Net Savings' : 'Net Expenses' + + return ( + +
+
+ Total Expenses + {formatCurrency(expenseSummary.totalManualExpenses)} +
+
+ Oil Change Savings + {formatCurrency(expenseSummary.oilChangeSavings)} +
+
+ {netLabel} + {formatCurrency(expenseSummary.netExpenses)} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx index 4ba1ba8..30b64b8 100644 --- a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.test.tsx @@ -17,4 +17,30 @@ describe('DashboardPage', () => { expect(screen.getByText(/year to date/i)).toBeInTheDocument() expect(screen.getByText(/all time/i)).toBeInTheDocument() }) + + it('renders expense summary card with total manual expenses label', async () => { + const module = await import('./dashboard-page') + const DashboardPage = module.DashboardPage + + render( + + + + ) + + expect(screen.getByText(/total expenses/i)).toBeInTheDocument() + }) + + it('renders oil change savings label when available', async () => { + const module = await import('./dashboard-page') + const DashboardPage = module.DashboardPage + + render( + + + + ) + + expect(screen.getByText(/oil change savings/i)).toBeInTheDocument() + }) }) \ No newline at end of file diff --git a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx index b911937..1618017 100644 --- a/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/dashboard/dashboard-page.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { DashboardStatusPanel } from '../../components/dashboard/dashboard-status-panel' import { DashboardSummaryCard } from '../../components/dashboard/dashboard-summary-card' +import { ExpenseSummaryCard } from './ExpenseSummaryCard' import { getDashboard, type DashboardResponse } from '../../services/dashboard-api' import './dashboard-page.css' @@ -37,6 +38,12 @@ function buildEmptyDashboard(): DashboardResponse { combinedSavings: null, qualifiedRideCount: 0, }, + expenseSummary: { + totalManualExpenses: 0, + oilChangeSavings: null, + netExpenses: null, + oilChangeIntervalCount: 0, + }, }, averages: { averageTemperature: null, @@ -207,6 +214,7 @@ export function DashboardPage() { Fuel {formatCurrency(dashboard.totals.moneySaved.fuelCostAvoided)}
+
diff --git a/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.css b/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.css new file mode 100644 index 0000000..07060eb --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.css @@ -0,0 +1,95 @@ +.expense-entry-page { + max-width: 40rem; + margin: 2rem auto; + padding: 0 1rem 2rem; +} + +.expense-entry-title { + margin: 0 0 1rem; + color: #0f172a; +} + +.expense-entry-form { + display: grid; + gap: 1rem; + padding: 1rem; + border: 1px solid #d4dbe5; + border-radius: 0.5rem; + background: #fff; +} + +.expense-entry-field { + display: grid; + gap: 0.375rem; +} + +.expense-entry-field label { + font-weight: 600; + color: #1f2937; +} + +.expense-entry-field input, +.expense-entry-field textarea { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + font: inherit; +} + +.expense-entry-submit { + width: fit-content; + padding: 0.55rem 0.95rem; + border: 1px solid #2563eb; + border-radius: 0.375rem; + background: #2563eb; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.expense-entry-submit:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.expense-entry-error { + color: #b91c1c; + margin: 0.35rem 0 0; +} + +.expense-entry-success { + color: #166534; + margin: 0.35rem 0 0; +} + +.expense-entry-warning { + color: #92400e; + margin: 0.35rem 0 0; +} + +@media (width <= 640px) { + .expense-entry-page { + margin-top: 1rem; + } + + .expense-entry-form { + padding: 0.85rem; + } + + .expense-entry-submit { + width: 100%; + justify-self: stretch; + } +} + +@media (width <= 420px) { + .expense-entry-page { + padding-inline: 0.75rem; + } + + .expense-entry-field input, + .expense-entry-field textarea { + padding: 0.5rem 0.6rem; + } +} diff --git a/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.test.tsx b/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.test.tsx new file mode 100644 index 0000000..9f060c6 --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { ExpenseEntryPage } from './ExpenseEntryPage' + +vi.mock('../../services/expenses-api', () => ({ + recordExpense: vi.fn(), +})) + +import * as expensesApi from '../../services/expenses-api' + +const mockRecordExpense = vi.mocked(expensesApi.recordExpense) + +describe('ExpenseEntryPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders expense entry form fields', () => { + render( + + + + ) + + expect(screen.getByLabelText(/expense date/i)).toBeInTheDocument() + expect(screen.getByLabelText(/amount/i)).toBeInTheDocument() + expect(screen.getByLabelText(/note/i)).toBeInTheDocument() + expect(screen.getByLabelText(/receipt/i)).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /record expense/i }) + ).toBeInTheDocument() + }) + + it('shows validation errors for missing date and non-positive amount', async () => { + render( + + + + ) + + fireEvent.change(screen.getByLabelText(/amount/i), { + target: { value: '0' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /record expense/i })) + + await waitFor(() => { + expect(screen.getByText(/expense date is required/i)).toBeInTheDocument() + expect(screen.getByText(/amount must be greater than zero/i)).toBeInTheDocument() + }) + + expect(mockRecordExpense).not.toHaveBeenCalled() + }) + + it('submits valid form data via recordExpense', async () => { + mockRecordExpense.mockResolvedValue({ + ok: true, + value: { + expenseId: 12, + riderId: 1, + savedAtUtc: '2026-04-17T12:00:00Z', + receiptAttached: false, + }, + }) + + render( + + + + ) + + fireEvent.change(screen.getByLabelText(/expense date/i), { + target: { value: '2026-04-17' }, + }) + fireEvent.change(screen.getByLabelText(/amount/i), { + target: { value: '23.45' }, + }) + fireEvent.change(screen.getByLabelText(/note/i), { + target: { value: 'Chain lube and cleaner' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /record expense/i })) + + await waitFor(() => { + expect(mockRecordExpense).toHaveBeenCalledTimes(1) + }) + + const firstCall = mockRecordExpense.mock.calls[0] + expect(firstCall).toBeDefined() + expect(firstCall[0]).toBeInstanceOf(FormData) + }) +}) diff --git a/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.tsx b/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.tsx new file mode 100644 index 0000000..a0aec56 --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/expenses/ExpenseEntryPage.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react' +import { recordExpense } from '../../services/expenses-api' +import './ExpenseEntryPage.css' + +const MAX_NOTE_LENGTH = 500 +const MAX_RECEIPT_BYTES = 5 * 1024 * 1024 +const ALLOWED_RECEIPT_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] +const ALLOWED_RECEIPT_FORMATS_MESSAGE = + 'Receipt must be JPEG, PNG, WEBP, or PDF and cannot exceed 5 MB.' + +export function ExpenseEntryPage() { + const [expenseDate, setExpenseDate] = useState('') + const [amount, setAmount] = useState('') + const [note, setNote] = useState('') + const [receipt, setReceipt] = useState(null) + + const [errorMessages, setErrorMessages] = useState([]) + const [receiptError, setReceiptError] = useState('') + const [receiptWarning, setReceiptWarning] = useState('') + const [successMessage, setSuccessMessage] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const validate = (): boolean => { + const validationErrors: string[] = [] + + if (!expenseDate) { + validationErrors.push('Expense date is required') + } + + const parsedAmount = Number.parseFloat(amount) + if (!amount || Number.isNaN(parsedAmount) || parsedAmount <= 0) { + validationErrors.push('Amount must be greater than zero') + } + + if (note.length > MAX_NOTE_LENGTH) { + validationErrors.push('Note must be 500 characters or fewer') + } + + setErrorMessages(validationErrors) + return validationErrors.length === 0 + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setErrorMessages([]) + setReceiptWarning('') + setSuccessMessage('') + + if (!validate()) { + return + } + + const formData = new FormData() + formData.append('expenseDate', expenseDate) + formData.append('amount', amount) + + if (note.trim().length > 0) { + formData.append('notes', note.trim()) + } + + if (receipt) { + formData.append('receipt', receipt) + } + + setIsSubmitting(true) + try { + const result = await recordExpense(formData) + if (!result.ok) { + setErrorMessages([result.error?.message ?? 'Failed to record expense']) + return + } + + if (result.data?.receiptError) { + setReceiptWarning(result.data.receiptError) + } + setSuccessMessage('Expense recorded successfully') + } catch (error) { + setErrorMessages([ + error instanceof Error ? error.message : 'Failed to record expense', + ]) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

Record Expense

+
+
+ + setExpenseDate(event.target.value)} + required + /> +
+ +
+ + setAmount(event.target.value)} + required + /> +
+ +
+ +