Feature: Add budget tracking with period-based spending limits
- Budget model with Weekly/Monthly/Yearly periods - BudgetService for CRUD and spending calculations - New /Budgets page for managing budgets - Dashboard integration with progress bars - Support for category-specific or total spending budgets - Period boundaries calculated from budget start date 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
145
ARCHITECTURE.md
145
ARCHITECTURE.md
@@ -55,7 +55,7 @@ MoneyMap follows a clean, service-oriented architecture:
|
||||
│ SQL Server Database │
|
||||
│ Cards, Accounts, Transactions, Receipts, │
|
||||
│ ReceiptLineItems, ReceiptParseLogs, │
|
||||
│ CategoryMappings, Merchants │
|
||||
│ CategoryMappings, Merchants, Budgets │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -183,6 +183,29 @@ Pattern-based rules for auto-categorization with merchant linking.
|
||||
- `MerchantId` (int?) - Foreign key to Merchant (optional)
|
||||
- `Priority` (int) - Higher priority checked first (default 0)
|
||||
|
||||
### Budget (Models/Budget.cs)
|
||||
Represents a spending budget for a category or total spending.
|
||||
|
||||
**Properties:**
|
||||
- `Id` (int) - Primary key
|
||||
- `Category` (string?, 100) - Category name (null = total spending budget)
|
||||
- `Amount` (decimal) - Budget limit amount
|
||||
- `Period` (BudgetPeriod) - Weekly, Monthly, or Yearly
|
||||
- `StartDate` (DateTime) - When budget becomes effective (used for period boundaries)
|
||||
- `IsActive` (bool) - Whether budget is currently active
|
||||
- `Notes` (string?, 500) - Optional notes
|
||||
|
||||
**Computed Properties:**
|
||||
- `IsTotalBudget` - Returns true if Category is null
|
||||
- `DisplayName` - Returns Category or "Total Spending"
|
||||
|
||||
**Unique Index:** Category + Period (filtered WHERE IsActive = 1, prevents duplicate active budgets)
|
||||
|
||||
**BudgetPeriod Enum:**
|
||||
- `Weekly` (0) - 7-day periods from StartDate
|
||||
- `Monthly` (1) - Monthly periods based on StartDate day-of-month
|
||||
- `Yearly` (2) - Annual periods from StartDate anniversary
|
||||
|
||||
## Service Layer
|
||||
|
||||
### TransactionImporter (Pages/Upload.cshtml.cs)
|
||||
@@ -451,6 +474,48 @@ Pattern-based rules for auto-categorization with merchant linking.
|
||||
|
||||
**Location:** Services/MerchantService.cs
|
||||
|
||||
### BudgetService (Services/BudgetService.cs)
|
||||
**Interface:** `IBudgetService`
|
||||
|
||||
**Responsibility:** Budget management including CRUD operations and spending calculations.
|
||||
|
||||
**Key Methods:**
|
||||
- `GetAllBudgetsAsync(bool activeOnly = true)` - Get all budgets, optionally filtered to active only
|
||||
- `GetBudgetByIdAsync(int id)` - Get budget by ID
|
||||
- `CreateBudgetAsync(Budget budget)` - Create new budget with duplicate validation
|
||||
- `UpdateBudgetAsync(Budget budget)` - Update existing budget
|
||||
- `DeleteBudgetAsync(int id)` - Delete budget
|
||||
|
||||
- `GetAllBudgetStatusesAsync(DateTime? asOfDate = null)` - Get spending status for all active budgets
|
||||
- `GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)` - Get spending status for specific budget
|
||||
|
||||
- `GetAvailableCategoriesAsync()` - Get list of categories for budget creation
|
||||
- `GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)` - Calculate period start/end dates
|
||||
|
||||
**Period Boundary Logic:**
|
||||
- **Weekly:** 7-day periods starting from StartDate
|
||||
- **Monthly:** Periods run from StartDate's day-of-month (e.g., 15th to 14th)
|
||||
- **Yearly:** Annual periods from StartDate anniversary
|
||||
|
||||
**DTOs:**
|
||||
- `BudgetOperationResult` - CRUD operation result with success/message
|
||||
- `BudgetStatus` - Budget with calculated spending data:
|
||||
- `Budget` - The budget entity
|
||||
- `PeriodStart`, `PeriodEnd` - Current period boundaries
|
||||
- `Spent` - Total spending in period
|
||||
- `Remaining` - Budget amount minus spent
|
||||
- `PercentUsed` - Percentage of budget consumed
|
||||
- `IsOverBudget` - Whether spending exceeds budget
|
||||
- `StatusClass` - CSS class (success/warning/danger)
|
||||
|
||||
**Design Pattern:**
|
||||
- Follows service layer pattern with interface
|
||||
- Uses DTOs for complex return types
|
||||
- Calculates spending by querying transactions within period boundaries
|
||||
- Excludes transfers from spending calculations
|
||||
|
||||
**Location:** Services/BudgetService.cs
|
||||
|
||||
### TransactionStatisticsService (Services/TransactionStatisticsService.cs)
|
||||
**Interface:** `ITransactionStatisticsService`
|
||||
|
||||
@@ -913,6 +978,34 @@ EF Core DbContext managing all database entities.
|
||||
|
||||
**Location:** Pages/Merchants.cshtml.cs
|
||||
|
||||
### Budgets.cshtml / BudgetsModel
|
||||
**Route:** `/Budgets`
|
||||
|
||||
**Purpose:** Manage spending budgets and track budget status.
|
||||
|
||||
**Features:**
|
||||
- Create budgets for specific categories or total spending
|
||||
- Support for Weekly, Monthly, and Yearly periods
|
||||
- Real-time spending vs budget tracking with progress bars
|
||||
- Color-coded status (green/yellow/red based on % used)
|
||||
- Pause/resume budgets without deleting
|
||||
- Edit budget amounts and periods
|
||||
- View active and inactive budgets separately
|
||||
|
||||
**Budget Types:**
|
||||
- **Category Budget:** Track spending for a specific category (e.g., "Dining Out")
|
||||
- **Total Budget:** Track all spending combined (Category = null)
|
||||
|
||||
**Period Boundaries:**
|
||||
- Periods are calculated relative to the budget's StartDate
|
||||
- Monthly budgets run from the StartDate's day (e.g., if StartDate is Jan 15, periods run 15th to 14th)
|
||||
- Weekly budgets run in 7-day increments from StartDate
|
||||
- Yearly budgets run from the StartDate anniversary
|
||||
|
||||
**Dependencies:** `IBudgetService`
|
||||
|
||||
**Location:** Pages/Budgets.cshtml.cs
|
||||
|
||||
### Recategorize.cshtml / RecategorizeModel
|
||||
**Route:** `/Recategorize`
|
||||
|
||||
@@ -1111,6 +1204,18 @@ CreatedBy (nvarchar(max), NULL) -- "AI" or "User"
|
||||
CreatedAt (datetime2, NULL) -- Rule creation timestamp
|
||||
```
|
||||
|
||||
### Budgets Table
|
||||
```sql
|
||||
Id (int, PK)
|
||||
Category (nvarchar(100), NULL) -- NULL = total spending budget
|
||||
Amount (decimal(18,2), NOT NULL)
|
||||
Period (int, NOT NULL) -- 0=Weekly, 1=Monthly, 2=Yearly
|
||||
StartDate (datetime2, NOT NULL)
|
||||
IsActive (bit, NOT NULL)
|
||||
Notes (nvarchar(500), NULL)
|
||||
UNIQUE INDEX: (Category, Period) WHERE IsActive = 1
|
||||
```
|
||||
|
||||
## Key Workflows
|
||||
|
||||
### 1. Import Transactions from CSV
|
||||
@@ -1424,15 +1529,14 @@ var importer = new TransactionImporter(mockDb.Object, mockCardResolver.Object);
|
||||
|
||||
### Potential Features
|
||||
1. **Multi-user support**: Add authentication/authorization
|
||||
2. **Budget tracking**: Monthly budget limits per category
|
||||
3. **Recurring transactions**: Auto-detect and predict recurring expenses
|
||||
4. **Data export**: Export transactions to Excel/CSV
|
||||
5. **Charts/graphs**: Spending trends over time
|
||||
6. **Mobile app**: React Native or .NET MAUI
|
||||
7. **Bank API integration**: Direct import from bank APIs (Plaid, Yodlee)
|
||||
8. **Receipt search**: Full-text search on parsed line items
|
||||
9. **Split transactions**: Divide single transaction across categories
|
||||
10. **Tags**: Tag transactions with multiple labels
|
||||
2. **Recurring transactions**: Auto-detect and predict recurring expenses
|
||||
3. **Data export**: Export transactions to Excel/CSV
|
||||
4. **Charts/graphs**: Spending trends over time
|
||||
5. **Mobile app**: React Native or .NET MAUI
|
||||
6. **Bank API integration**: Direct import from bank APIs (Plaid, Yodlee)
|
||||
7. **Receipt search**: Full-text search on parsed line items
|
||||
8. **Split transactions**: Divide single transaction across categories
|
||||
9. **Tags**: Tag transactions with multiple labels
|
||||
|
||||
### Technical Debt
|
||||
1. Move inline service implementations to separate files (Upload.cshtml.cs is 333 lines)
|
||||
@@ -1498,10 +1602,27 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-12
|
||||
**Version:** 1.2
|
||||
**Last Updated:** 2025-12-11
|
||||
**Version:** 1.3
|
||||
**Framework:** ASP.NET Core 8.0 / EF Core 9.0
|
||||
|
||||
## Recent Changes (v1.3)
|
||||
|
||||
### Budget Tracking Feature
|
||||
- **Budget Model**: New `Budget` entity with support for Weekly, Monthly, and Yearly periods
|
||||
- **Category & Total Budgets**: Create budgets for specific categories or track total spending
|
||||
- **Period Boundaries**: Smart calculation of period start/end dates based on budget start date
|
||||
- **Budget Service**: Full CRUD operations plus spending calculations via `IBudgetService`
|
||||
- **Budgets Page**: New `/Budgets` page for managing budgets with progress bars
|
||||
- **Dashboard Integration**: Budget status displayed on dashboard with color-coded progress
|
||||
- **Pause/Resume**: Deactivate budgets without deleting them
|
||||
|
||||
### Technical Details
|
||||
- Added `Budgets` table with filtered unique index on (Category, Period) WHERE IsActive = 1
|
||||
- `BudgetPeriod` enum: Weekly (0), Monthly (1), Yearly (2)
|
||||
- Period boundary calculations handle edge cases (month boundaries, leap years)
|
||||
- Spending calculated by summing debits within period, excluding transfers
|
||||
|
||||
## Recent Changes (v1.2)
|
||||
|
||||
### Receipt Management Enhancements
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace MoneyMap.Data
|
||||
|
||||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
public DbSet<Budget> Budgets => Set<Budget>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -205,6 +206,20 @@ namespace MoneyMap.Data
|
||||
// Receipt duplicate detection and lookup
|
||||
modelBuilder.Entity<Receipt>().HasIndex(x => x.FileHashSha256);
|
||||
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
|
||||
|
||||
// ---------- BUDGET ----------
|
||||
modelBuilder.Entity<Budget>(e =>
|
||||
{
|
||||
e.Property(x => x.Category).HasMaxLength(100);
|
||||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||||
e.Property(x => x.Notes).HasMaxLength(500);
|
||||
|
||||
// Only one active budget per category per period
|
||||
// Null category = total budget, so we use a filtered unique index
|
||||
e.HasIndex(x => new { x.Category, x.Period })
|
||||
.HasFilter("[IsActive] = 1")
|
||||
.IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
657
MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs
generated
Normal file
657
MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs
generated
Normal file
@@ -0,0 +1,657 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251211020503_AddBudgets")]
|
||||
partial class AddBudgets
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Institution")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Institution", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Budget", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int>("Period")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Category", "Period")
|
||||
.IsUnique()
|
||||
.HasFilter("[IsActive] = 1");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("Issuer", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MerchantId");
|
||||
|
||||
b.ToTable("CategoryMappings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Merchants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<DateTime?>("DueDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long?>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FileHashSha256");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique()
|
||||
.HasFilter("[TransactionId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("TransactionId", "ReceiptDate");
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<bool>("Voided")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TransactionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<int?>("TransferToAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Amount");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.HasIndex("MerchantId");
|
||||
|
||||
b.HasIndex("TransferToAccountId");
|
||||
|
||||
b.HasIndex("AccountId", "Category");
|
||||
|
||||
b.HasIndex("AccountId", "Date");
|
||||
|
||||
b.HasIndex("CardId", "Date");
|
||||
|
||||
b.HasIndex("MerchantId", "Date");
|
||||
|
||||
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
|
||||
.IsUnique()
|
||||
.HasFilter("[CardId] IS NOT NULL");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int?>("DestinationAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long?>("OriginalTransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("SourceAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.HasIndex("DestinationAccountId");
|
||||
|
||||
b.HasIndex("OriginalTransactionId");
|
||||
|
||||
b.HasIndex("SourceAccountId");
|
||||
|
||||
b.ToTable("Transfers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
.WithMany("Cards")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
|
||||
.WithMany("CategoryMappings")
|
||||
.HasForeignKey("MerchantId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Merchant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||
.WithMany("Receipts")
|
||||
.HasForeignKey("TransactionId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Transaction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||
.WithMany("LineItems")
|
||||
.HasForeignKey("ReceiptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Receipt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||
.WithMany("ParseLogs")
|
||||
.HasForeignKey("ReceiptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Receipt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("MerchantId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("TransferToAccountId");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Card");
|
||||
|
||||
b.Navigation("Merchant");
|
||||
|
||||
b.Navigation("TransferToAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
|
||||
.WithMany("DestinationTransfers")
|
||||
.HasForeignKey("DestinationAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("OriginalTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
|
||||
.WithMany("SourceTransfers")
|
||||
.HasForeignKey("SourceAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DestinationAccount");
|
||||
|
||||
b.Navigation("OriginalTransaction");
|
||||
|
||||
b.Navigation("SourceAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||
{
|
||||
b.Navigation("Cards");
|
||||
|
||||
b.Navigation("DestinationTransfers");
|
||||
|
||||
b.Navigation("SourceTransfers");
|
||||
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Navigation("CategoryMappings");
|
||||
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.Navigation("LineItems");
|
||||
|
||||
b.Navigation("ParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Navigation("Receipts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
47
MoneyMap/Migrations/20251211020503_AddBudgets.cs
Normal file
47
MoneyMap/Migrations/20251211020503_AddBudgets.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBudgets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Budgets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Period = table.Column<int>(type: "int", nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Budgets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Budgets_Category_Period",
|
||||
table: "Budgets",
|
||||
columns: new[] { "Category", "Period" },
|
||||
unique: true,
|
||||
filter: "[IsActive] = 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Budgets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,43 @@ namespace MoneyMap.Migrations
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Budget", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int>("Period")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Category", "Period")
|
||||
.IsUnique()
|
||||
.HasFilter("[IsActive] = 1");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -98,6 +135,47 @@ namespace MoneyMap.Migrations
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MerchantId");
|
||||
|
||||
b.ToTable("CategoryMappings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -430,47 +508,6 @@ namespace MoneyMap.Migrations
|
||||
b.ToTable("Transfers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MerchantId");
|
||||
|
||||
b.ToTable("CategoryMappings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
@@ -481,6 +518,16 @@ namespace MoneyMap.Migrations
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
|
||||
.WithMany("CategoryMappings")
|
||||
.HasForeignKey("MerchantId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Merchant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||
@@ -567,16 +614,6 @@ namespace MoneyMap.Migrations
|
||||
b.Navigation("SourceAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
|
||||
.WithMany("CategoryMappings")
|
||||
.HasForeignKey("MerchantId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Merchant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||
{
|
||||
b.Navigation("Cards");
|
||||
|
||||
62
MoneyMap/Models/Budget.cs
Normal file
62
MoneyMap/Models/Budget.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public enum BudgetPeriod
|
||||
{
|
||||
Weekly = 0,
|
||||
Monthly = 1,
|
||||
Yearly = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a spending budget for a category or total spending.
|
||||
/// When Category is null, this is a "Total" budget that tracks all spending.
|
||||
/// </summary>
|
||||
public class Budget
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category this budget applies to.
|
||||
/// Null means this is a total spending budget (all categories combined).
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The budget limit amount (positive value).
|
||||
/// </summary>
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The time period for this budget (Weekly, Monthly, Yearly).
|
||||
/// </summary>
|
||||
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
||||
|
||||
/// <summary>
|
||||
/// When the budget becomes effective. Used to calculate period boundaries.
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this budget is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional notes about this budget.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Computed properties
|
||||
[NotMapped]
|
||||
public bool IsTotalBudget => Category == null;
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayName => Category ?? "Total Spending";
|
||||
}
|
||||
315
MoneyMap/Pages/Budgets.cshtml
Normal file
315
MoneyMap/Pages/Budgets.cshtml
Normal file
@@ -0,0 +1,315 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.BudgetsModel
|
||||
@using MoneyMap.Models
|
||||
@{
|
||||
ViewData["Title"] = "Budgets";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Budgets</h2>
|
||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
@Model.SuccessMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add New Budget Button -->
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
|
||||
+ Add New Budget
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (Model.BudgetStatuses.Any())
|
||||
{
|
||||
<!-- Active Budgets -->
|
||||
var activeBudgets = Model.BudgetStatuses.Where(s => s.Budget.IsActive).ToList();
|
||||
if (activeBudgets.Any())
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<strong>Active Budgets</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Period</th>
|
||||
<th class="text-end">Budget</th>
|
||||
<th class="text-end">Spent</th>
|
||||
<th class="text-end">Remaining</th>
|
||||
<th style="width: 200px;">Progress</th>
|
||||
<th style="width: 180px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var status in activeBudgets)
|
||||
{
|
||||
var transactionsUrl = status.Budget.IsTotalBudget
|
||||
? Url.Page("/Transactions", new { startDate = status.PeriodStart.ToString("yyyy-MM-dd"), endDate = status.PeriodEnd.ToString("yyyy-MM-dd") })
|
||||
: Url.Page("/Transactions", new { category = status.Budget.Category, startDate = status.PeriodStart.ToString("yyyy-MM-dd"), endDate = status.PeriodEnd.ToString("yyyy-MM-dd") });
|
||||
|
||||
<tr style="cursor: pointer;" onclick="window.location.href='@transactionsUrl'">
|
||||
<td>
|
||||
<a href="@transactionsUrl" class="text-decoration-none">
|
||||
<strong>@status.Budget.DisplayName</strong>
|
||||
</a>
|
||||
@if (status.Budget.IsTotalBudget)
|
||||
{
|
||||
<span class="badge bg-info ms-1">Total</span>
|
||||
}
|
||||
@if (status.IsOverBudget)
|
||||
{
|
||||
<span class="badge bg-danger ms-1">Over Budget</span>
|
||||
}
|
||||
<br />
|
||||
<small class="text-muted">@status.PeriodDisplay</small>
|
||||
</td>
|
||||
<td>@status.Budget.Period</td>
|
||||
<td class="text-end">@status.Budget.Amount.ToString("C")</td>
|
||||
<td class="text-end">@status.Spent.ToString("C")</td>
|
||||
<td class="text-end @(status.IsOverBudget ? "text-danger fw-bold" : "")">
|
||||
@status.Remaining.ToString("C")
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 20px;">
|
||||
@{
|
||||
var percent = Math.Min(status.PercentUsed, 100);
|
||||
var progressClass = status.StatusClass;
|
||||
}
|
||||
<div class="progress-bar bg-@progressClass" role="progressbar"
|
||||
style="width: @percent%"
|
||||
aria-valuenow="@status.PercentUsed" aria-valuemin="0" aria-valuemax="100">
|
||||
@status.PercentUsed.ToString("F0")%
|
||||
</div>
|
||||
</div>
|
||||
@if (status.IsOverBudget)
|
||||
{
|
||||
<small class="text-danger">Over by @(Math.Abs(status.Remaining).ToString("C"))</small>
|
||||
}
|
||||
</td>
|
||||
<td onclick="event.stopPropagation()">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
onclick="openEditModal(@status.Budget.Id, '@(status.Budget.Category?.Replace("'", "\\'") ?? "")', @status.Budget.Amount.ToString(System.Globalization.CultureInfo.InvariantCulture), @((int)status.Budget.Period), '@status.Budget.StartDate.ToString("yyyy-MM-dd")', @status.Budget.IsActive.ToString().ToLower(), '@(status.Budget.Notes?.Replace("'", "\\'").Replace("\n", "\\n") ?? "")')">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post" asp-page-handler="ToggleActive" asp-route-id="@status.Budget.Id" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" title="Deactivate">
|
||||
Pause
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="DeleteBudget" asp-route-id="@status.Budget.Id"
|
||||
onsubmit="return confirm('Delete this budget?')" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Inactive Budgets -->
|
||||
var inactiveBudgets = Model.BudgetStatuses.Where(s => !s.Budget.IsActive).ToList();
|
||||
if (inactiveBudgets.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Inactive Budgets</strong>
|
||||
<small class="text-muted ms-2">Paused budgets are not tracked</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Period</th>
|
||||
<th class="text-end">Budget Amount</th>
|
||||
<th>Notes</th>
|
||||
<th style="width: 180px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var status in inactiveBudgets)
|
||||
{
|
||||
<tr class="text-muted">
|
||||
<td>
|
||||
@status.Budget.DisplayName
|
||||
@if (status.Budget.IsTotalBudget)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Total</span>
|
||||
}
|
||||
</td>
|
||||
<td>@status.Budget.Period</td>
|
||||
<td class="text-end">@status.Budget.Amount.ToString("C")</td>
|
||||
<td>@(status.Budget.Notes ?? "-")</td>
|
||||
<td>
|
||||
<form method="post" asp-page-handler="ToggleActive" asp-route-id="@status.Budget.Id" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success" title="Activate">
|
||||
Resume
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="DeleteBudget" asp-route-id="@status.Budget.Id"
|
||||
onsubmit="return confirm('Delete this budget?')" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>No budgets found</h5>
|
||||
<p>Click "Add New Budget" to create your first budget. You can create budgets for specific categories or a total spending budget.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div class="modal fade" id="addModal" tabindex="-1" aria-labelledby="addModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" asp-page-handler="AddBudget">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addModalLabel">Add New Budget</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="addCategory" class="form-label">Category</label>
|
||||
<select name="model.Category" id="addCategory" class="form-select" asp-items="Model.CategoryOptions">
|
||||
</select>
|
||||
<div class="form-text">Select a category or leave as "Total Spending" to track all expenses</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="addAmount" class="form-label">Budget Amount</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input name="model.Amount" id="addAmount" type="number" step="0.01" min="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="addPeriod" class="form-label">Period</label>
|
||||
<select name="model.Period" id="addPeriod" class="form-select" asp-items="Model.PeriodOptions">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="addStartDate" class="form-label">Start Date</label>
|
||||
<input name="model.StartDate" id="addStartDate" type="date" class="form-control" value="@DateTime.Today.ToString("yyyy-MM-dd")" />
|
||||
<div class="form-text">Used to calculate period boundaries (e.g., if monthly budget starts on the 15th, periods run 15th to 14th)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="addNotes" class="form-label">Notes (optional)</label>
|
||||
<textarea name="model.Notes" id="addNotes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Budget</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" asp-page-handler="UpdateBudget">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editModalLabel">Edit Budget</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="model.Id" id="editId" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="editCategory" class="form-label">Category</label>
|
||||
<select name="model.Category" id="editCategory" class="form-select" asp-items="Model.CategoryOptions">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="editAmount" class="form-label">Budget Amount</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input name="model.Amount" id="editAmount" type="number" step="0.01" min="0.01" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="editPeriod" class="form-label">Period</label>
|
||||
<select name="model.Period" id="editPeriod" class="form-select" asp-items="Model.PeriodOptions">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="editStartDate" class="form-label">Start Date</label>
|
||||
<input name="model.StartDate" id="editStartDate" type="date" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="editNotes" class="form-label">Notes (optional)</label>
|
||||
<textarea name="model.Notes" id="editNotes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" name="model.IsActive" id="editIsActive" class="form-check-input" value="true" />
|
||||
<label for="editIsActive" class="form-check-label">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function openEditModal(id, category, amount, period, startDate, isActive, notes) {
|
||||
document.getElementById('editId').value = id;
|
||||
document.getElementById('editCategory').value = category;
|
||||
document.getElementById('editAmount').value = amount;
|
||||
document.getElementById('editPeriod').value = period;
|
||||
document.getElementById('editStartDate').value = startDate;
|
||||
document.getElementById('editIsActive').checked = isActive;
|
||||
document.getElementById('editNotes').value = notes;
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
}
|
||||
220
MoneyMap/Pages/Budgets.cshtml.cs
Normal file
220
MoneyMap/Pages/Budgets.cshtml.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MoneyMap.Pages;
|
||||
|
||||
public class BudgetsModel : PageModel
|
||||
{
|
||||
private readonly IBudgetService _budgetService;
|
||||
|
||||
public BudgetsModel(IBudgetService budgetService)
|
||||
{
|
||||
_budgetService = budgetService;
|
||||
}
|
||||
|
||||
public List<BudgetStatus> BudgetStatuses { get; set; } = new();
|
||||
public List<SelectListItem> CategoryOptions { get; set; } = new();
|
||||
public List<SelectListItem> PeriodOptions { get; set; } = new();
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAddBudgetAsync(AddBudgetModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
ErrorMessage = "Please fill in all required fields.";
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
Category = string.IsNullOrWhiteSpace(model.Category) ? null : model.Category,
|
||||
Amount = model.Amount,
|
||||
Period = model.Period,
|
||||
StartDate = model.StartDate ?? DateTime.Today,
|
||||
Notes = model.Notes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var result = await _budgetService.CreateBudgetAsync(budget);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SuccessMessage = result.Message;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
ErrorMessage = result.Message;
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUpdateBudgetAsync(UpdateBudgetModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
ErrorMessage = "Please fill in all required fields.";
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
Id = model.Id,
|
||||
Category = string.IsNullOrWhiteSpace(model.Category) ? null : model.Category,
|
||||
Amount = model.Amount,
|
||||
Period = model.Period,
|
||||
StartDate = model.StartDate,
|
||||
Notes = model.Notes,
|
||||
IsActive = model.IsActive
|
||||
};
|
||||
|
||||
var result = await _budgetService.UpdateBudgetAsync(budget);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SuccessMessage = result.Message;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
ErrorMessage = result.Message;
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteBudgetAsync(int id)
|
||||
{
|
||||
var result = await _budgetService.DeleteBudgetAsync(id);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SuccessMessage = result.Message;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.Message;
|
||||
}
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostToggleActiveAsync(int id)
|
||||
{
|
||||
var budget = await _budgetService.GetBudgetByIdAsync(id);
|
||||
if (budget == null)
|
||||
{
|
||||
ErrorMessage = "Budget not found.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
budget.IsActive = !budget.IsActive;
|
||||
var result = await _budgetService.UpdateBudgetAsync(budget);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SuccessMessage = budget.IsActive ? "Budget activated." : "Budget deactivated.";
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.Message;
|
||||
}
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
// Get all budgets (active and inactive) with their status
|
||||
var allBudgets = await _budgetService.GetAllBudgetsAsync(activeOnly: false);
|
||||
BudgetStatuses = new List<BudgetStatus>();
|
||||
|
||||
foreach (var budget in allBudgets)
|
||||
{
|
||||
if (budget.IsActive)
|
||||
{
|
||||
var status = await _budgetService.GetBudgetStatusAsync(budget.Id);
|
||||
if (status != null)
|
||||
BudgetStatuses.Add(status);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For inactive budgets, create a minimal status
|
||||
BudgetStatuses.Add(new BudgetStatus
|
||||
{
|
||||
Budget = budget,
|
||||
PeriodStart = DateTime.Today,
|
||||
PeriodEnd = DateTime.Today,
|
||||
Spent = 0,
|
||||
Remaining = budget.Amount,
|
||||
PercentUsed = 0,
|
||||
IsOverBudget = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load category options
|
||||
var categories = await _budgetService.GetAvailableCategoriesAsync();
|
||||
CategoryOptions = new List<SelectListItem>
|
||||
{
|
||||
new SelectListItem { Value = "", Text = "-- Total Spending --" }
|
||||
};
|
||||
CategoryOptions.AddRange(categories.Select(c => new SelectListItem { Value = c, Text = c }));
|
||||
|
||||
// Load period options
|
||||
PeriodOptions = Enum.GetValues<BudgetPeriod>()
|
||||
.Select(p => new SelectListItem { Value = ((int)p).ToString(), Text = p.ToString() })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Input models
|
||||
public class AddBudgetModel
|
||||
{
|
||||
public string? Category { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Budget amount is required")]
|
||||
[Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
||||
|
||||
public DateTime? StartDate { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateBudgetModel
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
public string? Category { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Budget amount is required")]
|
||||
[Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public BudgetPeriod Period { get; set; }
|
||||
|
||||
public DateTime StartDate { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
<a class="btn btn-primary" asp-page="/Upload">Upload CSV</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/Transactions">View All Transactions</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/CategoryMappings">Categories</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/Budgets">Budgets</a>
|
||||
</div>
|
||||
<div class="row g-3 my-2">
|
||||
<div class="col-lg-6">
|
||||
@@ -76,6 +77,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.BudgetStatuses.Any())
|
||||
{
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Budget Status</span>
|
||||
<a asp-page="/Budgets" class="btn btn-sm btn-outline-secondary">Manage Budgets</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Budget</th>
|
||||
<th>Period</th>
|
||||
<th class="text-end">Limit</th>
|
||||
<th class="text-end">Spent</th>
|
||||
<th class="text-end">Remaining</th>
|
||||
<th style="width: 180px;">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var status in Model.BudgetStatuses)
|
||||
{
|
||||
<tr style="cursor: pointer;" onclick="window.location.href='@Url.Page("/Budgets")'">
|
||||
<td>
|
||||
<strong>@status.Budget.DisplayName</strong>
|
||||
@if (status.Budget.IsTotalBudget)
|
||||
{
|
||||
<span class="badge bg-info ms-1">Total</span>
|
||||
}
|
||||
@if (status.IsOverBudget)
|
||||
{
|
||||
<span class="badge bg-danger ms-1">Over</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@status.Budget.Period
|
||||
<br /><small class="text-muted">@status.PeriodDisplay</small>
|
||||
</td>
|
||||
<td class="text-end">@status.Budget.Amount.ToString("C")</td>
|
||||
<td class="text-end">@status.Spent.ToString("C")</td>
|
||||
<td class="text-end @(status.IsOverBudget ? "text-danger fw-bold" : "")">
|
||||
@status.Remaining.ToString("C")
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 18px;">
|
||||
@{
|
||||
var percent = Math.Min(status.PercentUsed, 100);
|
||||
var progressClass = status.StatusClass;
|
||||
}
|
||||
<div class="progress-bar bg-@progressClass" role="progressbar"
|
||||
style="width: @percent%"
|
||||
aria-valuenow="@status.PercentUsed" aria-valuemin="0" aria-valuemax="100">
|
||||
@status.PercentUsed.ToString("F0")%
|
||||
</div>
|
||||
</div>
|
||||
@if (status.IsOverBudget)
|
||||
{
|
||||
<small class="text-danger">Over by @(Math.Abs(status.Remaining).ToString("C"))</small>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.TopCategories.Any())
|
||||
{
|
||||
<div class="card shadow-sm mb-3">
|
||||
|
||||
@@ -7,10 +7,12 @@ namespace MoneyMap.Pages
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly IDashboardService _dashboardService;
|
||||
private readonly IBudgetService _budgetService;
|
||||
|
||||
public IndexModel(IDashboardService dashboardService)
|
||||
public IndexModel(IDashboardService dashboardService, IBudgetService budgetService)
|
||||
{
|
||||
_dashboardService = dashboardService;
|
||||
_budgetService = budgetService;
|
||||
}
|
||||
|
||||
public DashboardStats Stats { get; set; } = new();
|
||||
@@ -18,6 +20,7 @@ namespace MoneyMap.Pages
|
||||
public List<RecentTransactionRow> Recent { get; set; } = new();
|
||||
public List<string> TrendLabels { get; set; } = new();
|
||||
public List<decimal> TrendRunningBalance { get; set; } = new();
|
||||
public List<BudgetStatus> BudgetStatuses { get; set; } = new();
|
||||
|
||||
public async Task OnGet()
|
||||
{
|
||||
@@ -28,6 +31,9 @@ namespace MoneyMap.Pages
|
||||
Recent = dashboard.RecentTransactions;
|
||||
TrendLabels = dashboard.Trends.Labels;
|
||||
TrendRunningBalance = dashboard.Trends.RunningBalance;
|
||||
|
||||
// Get active budget statuses
|
||||
BudgetStatuses = await _budgetService.GetAllBudgetStatusesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ builder.Services.AddScoped<ITransactionStatisticsService, TransactionStatisticsS
|
||||
builder.Services.AddScoped<IAccountService, AccountService>();
|
||||
builder.Services.AddScoped<ICardService, CardService>();
|
||||
builder.Services.AddScoped<IMerchantService, MerchantService>();
|
||||
builder.Services.AddScoped<IBudgetService, BudgetService>();
|
||||
|
||||
// Receipt services
|
||||
builder.Services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
||||
|
||||
347
MoneyMap/Services/BudgetService.cs
Normal file
347
MoneyMap/Services/BudgetService.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IBudgetService
|
||||
{
|
||||
// CRUD operations
|
||||
Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
|
||||
Task<Budget?> GetBudgetByIdAsync(int id);
|
||||
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
|
||||
|
||||
// Budget status calculations
|
||||
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
|
||||
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
|
||||
|
||||
// Helper methods
|
||||
Task<List<string>> GetAvailableCategoriesAsync();
|
||||
(DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate);
|
||||
}
|
||||
|
||||
public class BudgetService : IBudgetService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public BudgetService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
#region CRUD Operations
|
||||
|
||||
public async Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true)
|
||||
{
|
||||
var query = _db.Budgets.AsQueryable();
|
||||
|
||||
if (activeOnly)
|
||||
query = query.Where(b => b.IsActive);
|
||||
|
||||
return await query
|
||||
.OrderBy(b => b.Category == null) // Total budget last
|
||||
.ThenBy(b => b.Category)
|
||||
.ThenBy(b => b.Period)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Budget?> GetBudgetByIdAsync(int id)
|
||||
{
|
||||
return await _db.Budgets.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> CreateBudgetAsync(Budget budget)
|
||||
{
|
||||
// Validate amount
|
||||
if (budget.Amount <= 0)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget amount must be greater than zero."
|
||||
};
|
||||
}
|
||||
|
||||
// Check for duplicate active budget (same category + period)
|
||||
var existing = await _db.Budgets
|
||||
.Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
var categoryName = budget.Category ?? "Total Spending";
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
||||
};
|
||||
}
|
||||
|
||||
budget.IsActive = true;
|
||||
_db.Budgets.Add(budget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Budget created successfully.",
|
||||
BudgetId = budget.Id
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget)
|
||||
{
|
||||
var existing = await _db.Budgets.FindAsync(budget.Id);
|
||||
if (existing == null)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget not found."
|
||||
};
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
if (budget.Amount <= 0)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget amount must be greater than zero."
|
||||
};
|
||||
}
|
||||
|
||||
// Check for duplicate if category or period changed
|
||||
if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period))
|
||||
{
|
||||
var duplicate = await _db.Budgets
|
||||
.Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (duplicate != null)
|
||||
{
|
||||
var categoryName = budget.Category ?? "Total Spending";
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
existing.Category = budget.Category;
|
||||
existing.Amount = budget.Amount;
|
||||
existing.Period = budget.Period;
|
||||
existing.StartDate = budget.StartDate;
|
||||
existing.IsActive = budget.IsActive;
|
||||
existing.Notes = budget.Notes;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Budget updated successfully.",
|
||||
BudgetId = existing.Id
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> DeleteBudgetAsync(int id)
|
||||
{
|
||||
var budget = await _db.Budgets.FindAsync(id);
|
||||
if (budget == null)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget not found."
|
||||
};
|
||||
}
|
||||
|
||||
_db.Budgets.Remove(budget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Budget deleted successfully."
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Budget Status Calculations
|
||||
|
||||
public async Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
|
||||
{
|
||||
var date = asOfDate ?? DateTime.Today;
|
||||
var budgets = await GetAllBudgetsAsync(activeOnly: true);
|
||||
var statuses = new List<BudgetStatus>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var status = await CalculateBudgetStatusAsync(budget, date);
|
||||
statuses.Add(status);
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public async Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)
|
||||
{
|
||||
var budget = await GetBudgetByIdAsync(budgetId);
|
||||
if (budget == null)
|
||||
return null;
|
||||
|
||||
var date = asOfDate ?? DateTime.Today;
|
||||
return await CalculateBudgetStatusAsync(budget, date);
|
||||
}
|
||||
|
||||
private async Task<BudgetStatus> CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate)
|
||||
{
|
||||
var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate);
|
||||
|
||||
// Calculate spending for the period
|
||||
var query = _db.Transactions
|
||||
.Where(t => t.Date >= periodStart && t.Date <= periodEnd)
|
||||
.Where(t => t.Amount < 0) // Only debits (spending)
|
||||
.Where(t => t.TransferToAccountId == null); // Exclude transfers
|
||||
|
||||
// For category-specific budgets, filter by category (case-insensitive)
|
||||
if (budget.Category != null)
|
||||
{
|
||||
query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower());
|
||||
}
|
||||
|
||||
var spent = await query.SumAsync(t => Math.Abs(t.Amount));
|
||||
var remaining = budget.Amount - spent;
|
||||
var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0;
|
||||
|
||||
return new BudgetStatus
|
||||
{
|
||||
Budget = budget,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
Spent = spent,
|
||||
Remaining = remaining,
|
||||
PercentUsed = percentUsed,
|
||||
IsOverBudget = spent > budget.Amount
|
||||
};
|
||||
}
|
||||
|
||||
public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
return period switch
|
||||
{
|
||||
BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate),
|
||||
BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate),
|
||||
BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(period))
|
||||
};
|
||||
}
|
||||
|
||||
private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
// Find which week we're in relative to the start date
|
||||
var daysSinceStart = (asOfDate - startDate.Date).Days;
|
||||
|
||||
if (daysSinceStart < 0)
|
||||
{
|
||||
// Before start date - use the week containing start date
|
||||
return (startDate.Date, startDate.Date.AddDays(6));
|
||||
}
|
||||
|
||||
var weekNumber = daysSinceStart / 7;
|
||||
var periodStart = startDate.Date.AddDays(weekNumber * 7);
|
||||
var periodEnd = periodStart.AddDays(6);
|
||||
|
||||
return (periodStart, periodEnd);
|
||||
}
|
||||
|
||||
private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
// Use the start date's day of month as the boundary
|
||||
var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month));
|
||||
|
||||
DateTime periodStart;
|
||||
if (asOfDate.Day >= dayOfMonth)
|
||||
{
|
||||
// We're in the current period
|
||||
periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth);
|
||||
}
|
||||
else
|
||||
{
|
||||
// We're before this month's boundary, so use last month
|
||||
var lastMonth = asOfDate.AddMonths(-1);
|
||||
dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month));
|
||||
periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth);
|
||||
}
|
||||
|
||||
// End is the day before the next period starts
|
||||
var nextPeriodStart = periodStart.AddMonths(1);
|
||||
var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month));
|
||||
nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth);
|
||||
var periodEnd = nextPeriodStart.AddDays(-1);
|
||||
|
||||
return (periodStart, periodEnd);
|
||||
}
|
||||
|
||||
private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
// Find which year period we're in
|
||||
var yearsSinceStart = asOfDate.Year - startDate.Year;
|
||||
|
||||
// Check if we're before the anniversary this year
|
||||
var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month,
|
||||
Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month)));
|
||||
|
||||
if (asOfDate < anniversaryThisYear)
|
||||
yearsSinceStart--;
|
||||
|
||||
var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart));
|
||||
var periodEnd = periodStart.AddYears(1).AddDays(-1);
|
||||
|
||||
return (periodStart, periodEnd);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Where(t => !string.IsNullOrEmpty(t.Category))
|
||||
.Select(t => t.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public class BudgetOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public int? BudgetId { get; set; }
|
||||
}
|
||||
|
||||
public class BudgetStatus
|
||||
{
|
||||
public Budget Budget { get; set; } = null!;
|
||||
public DateTime PeriodStart { get; set; }
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
public decimal Spent { get; set; }
|
||||
public decimal Remaining { get; set; }
|
||||
public decimal PercentUsed { get; set; }
|
||||
public bool IsOverBudget { get; set; }
|
||||
|
||||
// Helper for display
|
||||
public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success";
|
||||
public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}";
|
||||
}
|
||||
Reference in New Issue
Block a user