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:
2025-12-10 22:24:01 -05:00
parent f5cfd982cd
commit 33a664a3e1
12 changed files with 1963 additions and 64 deletions

View File

@@ -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

View File

@@ -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();
});
}
}
}

View 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
}
}
}

View 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");
}
}
}

View File

@@ -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
View 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";
}

View 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>
}

View 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; }
}
}

View File

@@ -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">

View File

@@ -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();
}
}
}

View File

@@ -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>();

View 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}";
}