From 33a664a3e1f99a9009a419f43cb0cfc62381f4e9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 10 Dec 2025 22:24:01 -0500 Subject: [PATCH] Feature: Add budget tracking with period-based spending limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ARCHITECTURE.md | 145 +++- MoneyMap/Data/MoneyMapContext.cs | 15 + .../20251211020503_AddBudgets.Designer.cs | 657 ++++++++++++++++++ .../Migrations/20251211020503_AddBudgets.cs | 47 ++ .../MoneyMapContextModelSnapshot.cs | 139 ++-- MoneyMap/Models/Budget.cs | 62 ++ MoneyMap/Pages/Budgets.cshtml | 315 +++++++++ MoneyMap/Pages/Budgets.cshtml.cs | 220 ++++++ MoneyMap/Pages/Index.cshtml | 71 ++ MoneyMap/Pages/Index.cshtml.cs | 8 +- MoneyMap/Program.cs | 1 + MoneyMap/Services/BudgetService.cs | 347 +++++++++ 12 files changed, 1963 insertions(+), 64 deletions(-) create mode 100644 MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs create mode 100644 MoneyMap/Migrations/20251211020503_AddBudgets.cs create mode 100644 MoneyMap/Models/Budget.cs create mode 100644 MoneyMap/Pages/Budgets.cshtml create mode 100644 MoneyMap/Pages/Budgets.cshtml.cs create mode 100644 MoneyMap/Services/BudgetService.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 06d8a68..633940a 100644 --- a/ARCHITECTURE.md +++ b/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 diff --git a/MoneyMap/Data/MoneyMapContext.cs b/MoneyMap/Data/MoneyMapContext.cs index ebd2bed..079d02a 100644 --- a/MoneyMap/Data/MoneyMapContext.cs +++ b/MoneyMap/Data/MoneyMapContext.cs @@ -18,6 +18,7 @@ namespace MoneyMap.Data public DbSet CategoryMappings => Set(); public DbSet Merchants => Set(); + public DbSet Budgets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -205,6 +206,20 @@ namespace MoneyMap.Data // Receipt duplicate detection and lookup modelBuilder.Entity().HasIndex(x => x.FileHashSha256); modelBuilder.Entity().HasIndex(x => new { x.TransactionId, x.ReceiptDate }); + + // ---------- BUDGET ---------- + modelBuilder.Entity(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(); + }); } } } diff --git a/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs b/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs new file mode 100644 index 0000000..042201e --- /dev/null +++ b/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs @@ -0,0 +1,657 @@ +ο»Ώ// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Period") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/Migrations/20251211020503_AddBudgets.cs b/MoneyMap/Migrations/20251211020503_AddBudgets.cs new file mode 100644 index 0000000..d93ff6a --- /dev/null +++ b/MoneyMap/Migrations/20251211020503_AddBudgets.cs @@ -0,0 +1,47 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddBudgets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Budgets", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + Period = table.Column(type: "int", nullable: false), + StartDate = table.Column(type: "datetime2", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + Notes = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Budgets"); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index 983b59a..2280f3b 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -59,6 +59,43 @@ namespace MoneyMap.Migrations b.ToTable("Accounts"); }); + modelBuilder.Entity("MoneyMap.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Period") + .HasColumnType("int"); + + b.Property("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("Id") @@ -98,6 +135,47 @@ namespace MoneyMap.Migrations b.ToTable("Cards"); }); + modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + modelBuilder.Entity("MoneyMap.Models.Merchant", b => { b.Property("Id") @@ -430,47 +508,6 @@ namespace MoneyMap.Migrations b.ToTable("Transfers"); }); - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("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"); diff --git a/MoneyMap/Models/Budget.cs b/MoneyMap/Models/Budget.cs new file mode 100644 index 0000000..e132a0a --- /dev/null +++ b/MoneyMap/Models/Budget.cs @@ -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 +} + +/// +/// Represents a spending budget for a category or total spending. +/// When Category is null, this is a "Total" budget that tracks all spending. +/// +public class Budget +{ + [Key] + public int Id { get; set; } + + /// + /// The category this budget applies to. + /// Null means this is a total spending budget (all categories combined). + /// + [MaxLength(100)] + public string? Category { get; set; } + + /// + /// The budget limit amount (positive value). + /// + [Column(TypeName = "decimal(18,2)")] + public decimal Amount { get; set; } + + /// + /// The time period for this budget (Weekly, Monthly, Yearly). + /// + public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly; + + /// + /// When the budget becomes effective. Used to calculate period boundaries. + /// + public DateTime StartDate { get; set; } = DateTime.Today; + + /// + /// Whether this budget is currently active. + /// + public bool IsActive { get; set; } = true; + + /// + /// Optional notes about this budget. + /// + [MaxLength(500)] + public string? Notes { get; set; } + + // Computed properties + [NotMapped] + public bool IsTotalBudget => Category == null; + + [NotMapped] + public string DisplayName => Category ?? "Total Spending"; +} diff --git a/MoneyMap/Pages/Budgets.cshtml b/MoneyMap/Pages/Budgets.cshtml new file mode 100644 index 0000000..0d4da38 --- /dev/null +++ b/MoneyMap/Pages/Budgets.cshtml @@ -0,0 +1,315 @@ +@page +@model MoneyMap.Pages.BudgetsModel +@using MoneyMap.Models +@{ + ViewData["Title"] = "Budgets"; +} + +
+

Budgets

+ Back to Dashboard +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + + +
+ +
+ +@if (Model.BudgetStatuses.Any()) +{ + + var activeBudgets = Model.BudgetStatuses.Where(s => s.Budget.IsActive).ToList(); + if (activeBudgets.Any()) + { +
+
+ Active Budgets +
+
+
+ + + + + + + + + + + + + + @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") }); + + + + + + + + + + + } + +
CategoryPeriodBudgetSpentRemainingProgressActions
+ + @status.Budget.DisplayName + + @if (status.Budget.IsTotalBudget) + { + Total + } + @if (status.IsOverBudget) + { + Over Budget + } +
+ @status.PeriodDisplay +
@status.Budget.Period@status.Budget.Amount.ToString("C")@status.Spent.ToString("C") + @status.Remaining.ToString("C") + +
+ @{ + var percent = Math.Min(status.PercentUsed, 100); + var progressClass = status.StatusClass; + } +
+ @status.PercentUsed.ToString("F0")% +
+
+ @if (status.IsOverBudget) + { + Over by @(Math.Abs(status.Remaining).ToString("C")) + } +
+ +
+ +
+
+ +
+
+
+
+
+ } + + + var inactiveBudgets = Model.BudgetStatuses.Where(s => !s.Budget.IsActive).ToList(); + if (inactiveBudgets.Any()) + { +
+
+ Inactive Budgets + Paused budgets are not tracked +
+
+
+ + + + + + + + + + + + @foreach (var status in inactiveBudgets) + { + + + + + + + + } + +
CategoryPeriodBudget AmountNotesActions
+ @status.Budget.DisplayName + @if (status.Budget.IsTotalBudget) + { + Total + } + @status.Budget.Period@status.Budget.Amount.ToString("C")@(status.Budget.Notes ?? "-") +
+ +
+
+ +
+
+
+
+
+ } +} +else +{ +
+
No budgets found
+

Click "Add New Budget" to create your first budget. You can create budgets for specific categories or a total spending budget.

+
+} + + + + + + + +@section Scripts { + +} diff --git a/MoneyMap/Pages/Budgets.cshtml.cs b/MoneyMap/Pages/Budgets.cshtml.cs new file mode 100644 index 0000000..7573a49 --- /dev/null +++ b/MoneyMap/Pages/Budgets.cshtml.cs @@ -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 BudgetStatuses { get; set; } = new(); + public List CategoryOptions { get; set; } = new(); + public List 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 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 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 OnPostDeleteBudgetAsync(int id) + { + var result = await _budgetService.DeleteBudgetAsync(id); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(); + } + + public async Task 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(); + + 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 + { + new SelectListItem { Value = "", Text = "-- Total Spending --" } + }; + CategoryOptions.AddRange(categories.Select(c => new SelectListItem { Value = c, Text = c })); + + // Load period options + PeriodOptions = Enum.GetValues() + .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; } + } +} diff --git a/MoneyMap/Pages/Index.cshtml b/MoneyMap/Pages/Index.cshtml index e8dc3dc..fada101 100644 --- a/MoneyMap/Pages/Index.cshtml +++ b/MoneyMap/Pages/Index.cshtml @@ -56,6 +56,7 @@ Upload CSV View All Transactions Categories + Budgets
@@ -76,6 +77,76 @@
+@if (Model.BudgetStatuses.Any()) +{ +
+
+ Budget Status + Manage Budgets +
+
+
+ + + + + + + + + + + + + @foreach (var status in Model.BudgetStatuses) + { + + + + + + + + + } + +
BudgetPeriodLimitSpentRemainingProgress
+ @status.Budget.DisplayName + @if (status.Budget.IsTotalBudget) + { + Total + } + @if (status.IsOverBudget) + { + Over + } + + @status.Budget.Period +
@status.PeriodDisplay +
@status.Budget.Amount.ToString("C")@status.Spent.ToString("C") + @status.Remaining.ToString("C") + +
+ @{ + var percent = Math.Min(status.PercentUsed, 100); + var progressClass = status.StatusClass; + } +
+ @status.PercentUsed.ToString("F0")% +
+
+ @if (status.IsOverBudget) + { + Over by @(Math.Abs(status.Remaining).ToString("C")) + } +
+
+
+
+} + @if (Model.TopCategories.Any()) {
diff --git a/MoneyMap/Pages/Index.cshtml.cs b/MoneyMap/Pages/Index.cshtml.cs index 8c85e76..fb33793 100644 --- a/MoneyMap/Pages/Index.cshtml.cs +++ b/MoneyMap/Pages/Index.cshtml.cs @@ -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 Recent { get; set; } = new(); public List TrendLabels { get; set; } = new(); public List TrendRunningBalance { get; set; } = new(); + public List 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(); } } } diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index 434fb24..35f7833 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -38,6 +38,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Receipt services builder.Services.AddScoped(); diff --git a/MoneyMap/Services/BudgetService.cs b/MoneyMap/Services/BudgetService.cs new file mode 100644 index 0000000..4be1324 --- /dev/null +++ b/MoneyMap/Services/BudgetService.cs @@ -0,0 +1,347 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +public interface IBudgetService +{ + // CRUD operations + Task> GetAllBudgetsAsync(bool activeOnly = true); + Task GetBudgetByIdAsync(int id); + Task CreateBudgetAsync(Budget budget); + Task UpdateBudgetAsync(Budget budget); + Task DeleteBudgetAsync(int id); + + // Budget status calculations + Task> GetAllBudgetStatusesAsync(DateTime? asOfDate = null); + Task GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null); + + // Helper methods + Task> 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> 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 GetBudgetByIdAsync(int id) + { + return await _db.Budgets.FindAsync(id); + } + + public async Task 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 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 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> GetAllBudgetStatusesAsync(DateTime? asOfDate = null) + { + var date = asOfDate ?? DateTime.Today; + var budgets = await GetAllBudgetsAsync(activeOnly: true); + var statuses = new List(); + + foreach (var budget in budgets) + { + var status = await CalculateBudgetStatusAsync(budget, date); + statuses.Add(status); + } + + return statuses; + } + + public async Task 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 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> 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}"; +}