diff --git a/BudgetApp.Tests/Services/DashboardServiceTests.cs b/BudgetApp.Tests/Services/DashboardServiceTests.cs index d11252e..4cadb1e 100644 --- a/BudgetApp.Tests/Services/DashboardServiceTests.cs +++ b/BudgetApp.Tests/Services/DashboardServiceTests.cs @@ -107,6 +107,59 @@ public class DashboardServiceTests Assert.Empty(result.Datasets); } + [Fact] + public void BuildChartData_PaidFutureTransaction_ExcludedFromProjection() + { + var today = DateTime.Today; + var start = today.AddDays(-1); + var end = today.AddDays(5); + + // CurrentBalance is manually maintained by the user — MarkAsPaidAsync never touches it + var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP", CurrentBalance = 1000 }; + var transactions = new List + { + // Unpaid future expense — should reduce the projected balance + new() { Description = "Unpaid Bill", Amount = 100, Date = today.AddDays(3), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = false }, + // Paid future expense — excluded from projection; CurrentBalance already reflects it (user-managed) + new() { Description = "Paid Bill", Amount = 200, Date = today.AddDays(4), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = true } + }; + + var result = DashboardService.BuildChartData([account], transactions, start, end); + + var data = result.Datasets[0].Data; + // Today's index = 1 (start is today-1) + int todayIndex = 1; + // Balance is flat at CurrentBalance up to the unpaid bill + Assert.Equal(1000m, data[todayIndex]); + // Day +3 (index 4): unpaid bill applied → 900 + Assert.Equal(900m, data[todayIndex + 3]); + // Day +4 (index 5): paid bill is excluded from projection → stays at 900 + Assert.Equal(900m, data[todayIndex + 4]); + } + + [Fact] + public void BuildChartData_PaidFutureTransaction_ExcludedFromDayTransactionLabels() + { + var today = DateTime.Today; + var start = today; + var end = today.AddDays(2); + + var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP", CurrentBalance = 500 }; + var transactions = new List + { + new() { Description = "Unpaid", Amount = 50, Date = today.AddDays(1), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = false }, + new() { Description = "Paid", Amount = 100, Date = today.AddDays(2), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = true } + }; + + var result = DashboardService.BuildChartData([account], transactions, start, end); + + // Day +1: unpaid transaction appears in labels + Assert.Single(result.DayTransactionLabels[1]); + Assert.Contains("Unpaid", result.DayTransactionLabels[1][0]); + // Day +2: paid transaction is excluded from labels + Assert.Empty(result.DayTransactionLabels[2]); + } + #endregion #region GetDashboardDataAsync (integration tests) diff --git a/BudgetApp/Components/Layout/NavMenu.razor b/BudgetApp/Components/Layout/NavMenu.razor index a205ebf..a8a16b8 100644 --- a/BudgetApp/Components/Layout/NavMenu.razor +++ b/BudgetApp/Components/Layout/NavMenu.razor @@ -33,6 +33,12 @@ + + + @* ── Salary ── *@ +
+
+
💰 Salary
+
+
+

The Salary page lets you define regular income sources that generate recurring transactions automatically.

+ +
Adding a Salary
+
    +
  • Name — A label for this income source (e.g. "Main Job", "Freelance").
  • +
  • Amount — The net amount credited each payday.
  • +
  • Account — Which account receives the payment.
  • +
  • Recurrence — How often you are paid: Weekly, Monthly or Yearly.
  • +
  • First Payday — The date of the first payment. The day of month (or week) is derived from this and repeats indefinitely.
  • +
+ +
How Salary Transactions Work
+
    +
  • Salary transactions are generated automatically as Income entries up to the end of the current time window, and refreshed whenever you visit or change the date range on any page.
  • +
  • They appear on the Transactions page with a 💰 badge and cannot be deleted from there — manage them on the Salary page instead.
  • +
  • Payday dates are highlighted on the Dashboard balance chart with a 💰 marker and subtle green band so you can see upcoming income at a glance.
  • +
  • The category is automatically set to Salary.
  • +
+ +
Deleting a Salary
+

Deleting a salary removes all future unpaid transactions for that income source. Past transactions remain in your history for accurate record-keeping.

+
+
+ @* ── Categories ── *@
@@ -120,6 +153,7 @@
  • Add a category — Enter a name and pick an emoji icon from the icon picker.
  • Edit — Click the Edit button on a category card to change its name or icon.
  • Delete — Removes the category. Transactions using it will become uncategorised.
  • +
  • Default categories — A set of built-in categories (Groceries, Salary, Rent / Mortgage, etc.) are created automatically and shown with a Default badge. These cannot be edited or deleted.
  • Categories are optional — you can assign them to transactions to get better insight into your spending patterns.

    @@ -175,6 +209,7 @@
    • Use the Dashboard + Add button for rapid entry — the modal stays open after each save so you can batch-add transactions.
    • Set up recurring transactions for regular bills and income to keep your upcoming view accurate.
    • +
    • Use the Salary page for regular pay — it is protected from accidental deletion and highlights paydays on the chart. Use the 90d or 1y window to plan ahead.
    • Assign colours to your accounts to easily distinguish them on the chart and in transaction lists.
    • Hover over elements for tooltips with additional context throughout the app.
    • Click your account balances in the sidebar to quickly navigate to account management.
    • @@ -193,6 +228,7 @@ 📊 Dashboard 🏦 Accounts 💸 Transactions + 💰 Salary 🏷️ Categories 📈 Reports ⚙️ Settings diff --git a/BudgetApp/Components/Pages/Home.razor b/BudgetApp/Components/Pages/Home.razor index cfb5f75..38a6f3d 100644 --- a/BudgetApp/Components/Pages/Home.razor +++ b/BudgetApp/Components/Pages/Home.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject DashboardService DashboardService @inject TransactionService TransactionService +@inject SalaryService SalaryService @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JS @implements IAsyncDisposable @@ -195,6 +196,10 @@ else { 🔁 @GetRecurrenceLabel(t) } + @if (t.IsPaid) + { + ✓ Paid + }
    @t.Date.ToString("MMM dd, yyyy") @@ -209,20 +214,30 @@ else
    @(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
    - @(t.Type == TransactionType.Income ? "Income" : "Expense") -
    - - @if (selectedTransactionId == t.Id) - { -
    - - -
    - } - - } - - @if (RecentTotalPages > 1) + @(t.Type == TransactionType.Income ? "Income" : "Expense") + + + @if (selectedTransactionId == t.Id) + { +
    + @if (t.IsPaid) + { + + } + else + { + + } +
    + + +
    +
    + } + + } + + @if (RecentTotalPages > 1) {
    @@ -282,9 +297,12 @@ else
    @if (selectedTransactionId == t.Id) { -
    - - +
    + +
    + + +
    }
    @@ -446,6 +464,7 @@ else datasets, dayLabels, highlightIndex, + GetSalaryDayIndices(), data.CurrencySymbol); } } @@ -462,6 +481,23 @@ else return data.ChartData.Labels.IndexOf(label); } + private int[] GetSalaryDayIndices() + { + if (data is null) return []; + + var salaryDates = data.RecentTransactions + .Concat(data.UpcomingTransactions) + .Where(t => t.SalaryId is not null) + .Select(t => t.Date.ToString("MMM dd")) + .ToHashSet(); + + return data.ChartData.Labels + .Select((label, index) => (label, index)) + .Where(x => salaryDates.Contains(x.label)) + .Select(x => x.index) + .ToArray(); + } + private async Task SelectTransaction(int id) { selectedTransactionId = selectedTransactionId == id ? null : id; @@ -583,6 +619,14 @@ else pendingDeleteTransaction = null; } + private async Task MarkAsPaid(int id, bool isPaid) + { + if (userId is null) return; + await TransactionService.MarkAsPaidAsync(id, userId, isPaid); + selectedTransactionId = null; + await RefreshData(); + } + private static string GetRecurrenceLabel(Transaction t) { if (t.RecurrenceType != RecurrenceType.None) @@ -609,6 +653,7 @@ else private async Task RefreshData() { if (userId is null) return; + await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd); data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd); chartDataVersion++; ComputeProjectedBalances(); diff --git a/BudgetApp/Components/Pages/Reports.razor b/BudgetApp/Components/Pages/Reports.razor index 33b9ac8..26f586e 100644 --- a/BudgetApp/Components/Pages/Reports.razor +++ b/BudgetApp/Components/Pages/Reports.razor @@ -3,6 +3,7 @@ @inject TransactionService TransactionService @inject AccountService AccountService @inject CategoryService CategoryService +@inject SalaryService SalaryService @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JS @implements IAsyncDisposable @@ -220,6 +221,7 @@ else { if (userId is null) return; + await SalaryService.MaterializeSalaryTransactionsAsync(userId, dateTo); var transactions = await TransactionService.GetTransactionsInRangeAsync(userId, dateFrom, dateTo); var filtered = transactions diff --git a/BudgetApp/Components/Pages/Salary.razor b/BudgetApp/Components/Pages/Salary.razor new file mode 100644 index 0000000..68dcc42 --- /dev/null +++ b/BudgetApp/Components/Pages/Salary.razor @@ -0,0 +1,264 @@ +@page "/salary" +@attribute [Authorize] +@inject SalaryService SalaryService +@inject AccountService AccountService +@inject AuthenticationStateProvider AuthStateProvider +@rendermode InteractiveServer + +Salary + +

    Salary

    +

    Define your regular income sources. Each salary generates recurring transactions at the set interval, forever.

    + +
    +
    +
    Add Income Source
    +
    +
    + @if (accountsList is not null && accountsList.Count == 0) + { +
    + You need to create an account before adding a salary. +
    + } + else + { + + +
    +
    + + +
    +
    + + +
    +
    + + + @if (accountsList is not null) + { + @foreach (var account in accountsList) + { + + } + } + +
    +
    + + + + + + +
    +
    + + +
    +
    + +
    +
    +
    + } +
    +
    + +@if (salaries is null) +{ +

    Loading...

    +} +else if (salaries.Count == 0) +{ +
    No income sources yet. Add one above to start tracking your salary.
    +} +else +{ +
    +
    +
    Income Sources
    +
    +
    + + + + + + + + + + + + + @foreach (var s in salaries) + { + + + + + + + + + } + +
    NameAmountAccountScheduleNext Payday
    💰 @s.Name+@s.Account.FormatAmount(s.Amount) + + @s.Account.Name + + + 🔁 @s.GetPaydaySummary() + + @if (nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue) + { + @next.Value.ToString("MMM dd, yyyy") + } + else + { + — + } + + +
    +
    + +
    +} + +@if (showDeleteModal && pendingDeleteSalary is not null) +{ + +} + +@code { + private List? salaries; + private List? accountsList; + private Dictionary nextPaydates = []; + private string? userId; + + private SalaryForm formModel = new(); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + await LoadData(); + } + + private async Task LoadData() + { + if (userId is null) return; + + var windowEnd = DateTime.Today.AddDays(90); + await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd); + + salaries = await SalaryService.GetSalariesAsync(userId); + accountsList = await AccountService.GetAccountsAsync(userId); + + nextPaydates.Clear(); + foreach (var sal in salaries) + { + nextPaydates[sal.Id] = await SalaryService.GetNextPaydateAsync(sal.Id); + } + + if (accountsList.Count > 0 && formModel.AccountId == 0) + { + formModel.AccountId = accountsList[0].Id; + } + } + + private async Task AddSalary() + { + if (userId is null) return; + + var entry = new BudgetApp.Data.Salary + { + Name = formModel.Name, + Amount = formModel.Amount, + AccountId = formModel.AccountId, + RecurrenceType = formModel.RecurrenceType, + StartDate = formModel.StartDate, + UserId = userId + }; + + await SalaryService.CreateSalaryAsync(entry, DateTime.Today.AddDays(90)); + + formModel = new SalaryForm + { + AccountId = accountsList?.FirstOrDefault()?.Id ?? 0 + }; + + await LoadData(); + } + + // Delete state + private bool showDeleteModal; + private BudgetApp.Data.Salary? pendingDeleteSalary; + + private void ConfirmDelete(BudgetApp.Data.Salary s) + { + pendingDeleteSalary = s; + showDeleteModal = true; + } + + private void CancelDelete() + { + showDeleteModal = false; + pendingDeleteSalary = null; + } + + private async Task ExecuteDelete() + { + if (userId is null || pendingDeleteSalary is null) return; + showDeleteModal = false; + await SalaryService.DeleteSalaryAsync(pendingDeleteSalary.Id, userId); + pendingDeleteSalary = null; + await LoadData(); + } + + private sealed class SalaryForm : System.ComponentModel.DataAnnotations.IValidatableObject + { + [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")] + [System.ComponentModel.DataAnnotations.MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")] + public decimal Amount { get; set; } + + public int AccountId { get; set; } + + public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly; + + public DateTime StartDate { get; set; } = DateTime.Today; + + public IEnumerable Validate( + System.ComponentModel.DataAnnotations.ValidationContext validationContext) + { + yield break; + } + } +} diff --git a/BudgetApp/Components/Pages/Salary.razor.css b/BudgetApp/Components/Pages/Salary.razor.css new file mode 100644 index 0000000..d4c4301 --- /dev/null +++ b/BudgetApp/Components/Pages/Salary.razor.css @@ -0,0 +1,17 @@ +.card { + border-radius: 1rem; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.07); + border: none; +} + +.card-header { + border-radius: 1rem 1rem 0 0 !important; + padding: 1rem 1.25rem; +} + +@media (max-width: 767.98px) { + ::deep .table > :not(caption) > * > * { + padding: 0.3rem 0.4rem; + font-size: 0.8125rem; + } +} diff --git a/BudgetApp/Components/Pages/Transactions.razor b/BudgetApp/Components/Pages/Transactions.razor index 983be29..72b1c4e 100644 --- a/BudgetApp/Components/Pages/Transactions.razor +++ b/BudgetApp/Components/Pages/Transactions.razor @@ -3,6 +3,7 @@ @inject TransactionService TransactionService @inject AccountService AccountService @inject CategoryService CategoryService +@inject SalaryService SalaryService @inject AuthenticationStateProvider AuthStateProvider @rendermode InteractiveServer @@ -176,11 +177,28 @@ else @foreach (var t in pagedTransactions) { - - - + + @if (t.SalaryId is null) + { + + } + else + { + 🔒 + } + @t.Date.ToString("MMM dd, yyyy") - @t.Description + + @t.Description + @if (t.SalaryId is not null) + { + 💰 Salary + } + @if (t.IsPaid && t.Date.Date > DateTime.Today) + { + ✓ Paid + } + @t.Account.Name @@ -215,8 +233,21 @@ else
    + @if (t.Date.Date > DateTime.Today) + { + + } - + @if (t.SalaryId is null) + { + + } + else + { + 🔒 + }
    @@ -448,18 +479,20 @@ else private void OnSearchChanged() => currentPage = 1; - private void SetWindow(int days) + private async Task SetWindow(int days) { windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null }; windowStart = DateTime.Today; windowEnd = DateTime.Today.AddDays(days); currentPage = 1; + await LoadData(); } - private void OnWindowDateChanged() + private async Task OnWindowDateChanged() { windowPreset = null; currentPage = 1; + await LoadData(); } private void OnPageSizeChanged(ChangeEventArgs e) @@ -501,6 +534,7 @@ else { if (userId is null) return; + await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd); transactions = await TransactionService.GetTransactionsAsync(userId); accountsList = await AccountService.GetAccountsAsync(userId); categoriesList = await CategoryService.GetCategoriesAsync(userId); @@ -539,22 +573,29 @@ else private void ToggleSelect(int id) { + var t = transactions?.FirstOrDefault(x => x.Id == id); + if (t?.SalaryId is not null) return; if (!selectedIds.Remove(id)) selectedIds.Add(id); } - private bool IsAllPageSelected() => pagedTransactions.Count > 0 && pagedTransactions.All(t => selectedIds.Contains(t.Id)); + private bool IsAllPageSelected() + { + var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList(); + return selectable.Count > 0 && selectable.All(t => selectedIds.Contains(t.Id)); + } private void ToggleSelectAll(ChangeEventArgs e) { + var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList(); if (e.Value is true) { - foreach (var t in pagedTransactions) + foreach (var t in selectable) selectedIds.Add(t.Id); } else { - foreach (var t in pagedTransactions) + foreach (var t in selectable) selectedIds.Remove(t.Id); } } @@ -649,8 +690,10 @@ else { if (userId is null) return; showDeleteModal = false; - foreach (var id in selectedIds) + foreach (var id in selectedIds.ToList()) { + var t = transactions?.FirstOrDefault(x => x.Id == id); + if (t?.SalaryId is not null) continue; await TransactionService.DeleteTransactionAsync(id, userId); } selectedIds.Clear(); @@ -664,6 +707,13 @@ else bulkDeleteCount = 0; } + private async Task TogglePaid(Transaction t) + { + if (userId is null) return; + await TransactionService.MarkAsPaidAsync(t.Id, userId, !t.IsPaid); + await LoadData(); + } + private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject { [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")] diff --git a/BudgetApp/Data/ApplicationDbContext.cs b/BudgetApp/Data/ApplicationDbContext.cs index 6baa4d0..9edeb55 100644 --- a/BudgetApp/Data/ApplicationDbContext.cs +++ b/BudgetApp/Data/ApplicationDbContext.cs @@ -13,6 +13,7 @@ public class ApplicationDbContext : IdentityDbContext public DbSet Accounts => Set(); public DbSet Transactions => Set(); public DbSet Categories => Set(); + public DbSet Salaries => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -45,6 +46,11 @@ public class ApplicationDbContext : IdentityDbContext .HasForeignKey(t => t.ParentTransactionId) .OnDelete(DeleteBehavior.SetNull); + e.HasOne(t => t.Salary) + .WithMany() + .HasForeignKey(t => t.SalaryId) + .OnDelete(DeleteBehavior.SetNull); + e.Property(t => t.Amount).HasColumnType("decimal(18,2)"); }); @@ -55,5 +61,20 @@ public class ApplicationDbContext : IdentityDbContext .HasForeignKey(c => c.UserId) .OnDelete(DeleteBehavior.Cascade); }); + + builder.Entity(e => + { + e.HasOne(s => s.User) + .WithMany() + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(s => s.Account) + .WithMany() + .HasForeignKey(s => s.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + e.Property(s => s.Amount).HasColumnType("decimal(18,2)"); + }); } } diff --git a/BudgetApp/Data/Category.cs b/BudgetApp/Data/Category.cs index 6e99e45..85037e6 100644 --- a/BudgetApp/Data/Category.cs +++ b/BudgetApp/Data/Category.cs @@ -17,5 +17,7 @@ public class Category public ApplicationUser User { get; set; } = default!; + public bool IsDefault { get; set; } + public List Transactions { get; set; } = []; } diff --git a/BudgetApp/Data/Salary.cs b/BudgetApp/Data/Salary.cs new file mode 100644 index 0000000..c26fc6a --- /dev/null +++ b/BudgetApp/Data/Salary.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace BudgetApp.Data; + +public class Salary +{ + public int Id { get; set; } + + [Required, MaxLength(200)] + public string Name { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly; + + public DateTime StartDate { get; set; } = DateTime.Today; + + public int AccountId { get; set; } + public Account Account { get; set; } = default!; + + public int? CategoryId { get; set; } + public Category? Category { get; set; } + + [Required] + public string UserId { get; set; } = string.Empty; + public ApplicationUser User { get; set; } = default!; + + public string GetPaydaySummary() => RecurrenceType switch + { + RecurrenceType.Weekly => $"Every week on {StartDate:dddd}", + RecurrenceType.Monthly => $"Monthly on the {GetOrdinal(StartDate.Day)}", + RecurrenceType.Yearly => $"Annually on {StartDate:MMMM d}", + _ => RecurrenceType.ToString() + }; + + private static string GetOrdinal(int day) => day switch + { + 1 or 21 or 31 => $"{day}st", + 2 or 22 => $"{day}nd", + 3 or 23 => $"{day}rd", + _ => $"{day}th" + }; +} diff --git a/BudgetApp/Data/Transaction.cs b/BudgetApp/Data/Transaction.cs index 167c2a0..8cfaf05 100644 --- a/BudgetApp/Data/Transaction.cs +++ b/BudgetApp/Data/Transaction.cs @@ -42,4 +42,9 @@ public class Transaction public int? CategoryId { get; set; } public Category? Category { get; set; } + + public bool IsPaid { get; set; } = false; + + public int? SalaryId { get; set; } + public Salary? Salary { get; set; } } diff --git a/BudgetApp/Migrations/20260305164332_AddIsPaidToTransaction.Designer.cs b/BudgetApp/Migrations/20260305164332_AddIsPaidToTransaction.Designer.cs new file mode 100644 index 0000000..877119d --- /dev/null +++ b/BudgetApp/Migrations/20260305164332_AddIsPaidToTransaction.Designer.cs @@ -0,0 +1,436 @@ +// +using System; +using BudgetApp.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BudgetApp.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260305164332_AddIsPaidToTransaction")] + partial class AddIsPaidToTransaction + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("BudgetApp.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsPaid") + .HasColumnType("INTEGER"); + + b.Property("ParentTransactionId") + .HasColumnType("INTEGER"); + + b.Property("RecurrenceEndDate") + .HasColumnType("TEXT"); + + b.Property("RecurrenceType") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ParentTransactionId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Transaction", b => + { + b.HasOne("BudgetApp.Data.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BudgetApp.Data.Transaction", "ParentTransaction") + .WithMany() + .HasForeignKey("ParentTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ParentTransaction"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BudgetApp/Migrations/20260305164332_AddIsPaidToTransaction.cs b/BudgetApp/Migrations/20260305164332_AddIsPaidToTransaction.cs new file mode 100644 index 0000000..9c016c4 --- /dev/null +++ b/BudgetApp/Migrations/20260305164332_AddIsPaidToTransaction.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BudgetApp.Migrations +{ + /// + public partial class AddIsPaidToTransaction : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPaid", + table: "Transactions", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPaid", + table: "Transactions"); + } + } +} diff --git a/BudgetApp/Migrations/20260305175841_AddSalary.Designer.cs b/BudgetApp/Migrations/20260305175841_AddSalary.Designer.cs new file mode 100644 index 0000000..951ae4a --- /dev/null +++ b/BudgetApp/Migrations/20260305175841_AddSalary.Designer.cs @@ -0,0 +1,514 @@ +// +using System; +using BudgetApp.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BudgetApp.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260305175841_AddSalary")] + partial class AddSalary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("BudgetApp.Data.Salary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecurrenceType") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("UserId"); + + b.ToTable("Salaries"); + }); + + modelBuilder.Entity("BudgetApp.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsPaid") + .HasColumnType("INTEGER"); + + b.Property("ParentTransactionId") + .HasColumnType("INTEGER"); + + b.Property("RecurrenceEndDate") + .HasColumnType("TEXT"); + + b.Property("RecurrenceType") + .HasColumnType("INTEGER"); + + b.Property("SalaryId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ParentTransactionId"); + + b.HasIndex("SalaryId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Salary", b => + { + b.HasOne("BudgetApp.Data.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Transaction", b => + { + b.HasOne("BudgetApp.Data.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BudgetApp.Data.Transaction", "ParentTransaction") + .WithMany() + .HasForeignKey("ParentTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BudgetApp.Data.Salary", "Salary") + .WithMany() + .HasForeignKey("SalaryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ParentTransaction"); + + b.Navigation("Salary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BudgetApp/Migrations/20260305175841_AddSalary.cs b/BudgetApp/Migrations/20260305175841_AddSalary.cs new file mode 100644 index 0000000..358e263 --- /dev/null +++ b/BudgetApp/Migrations/20260305175841_AddSalary.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BudgetApp.Migrations +{ + /// + public partial class AddSalary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SalaryId", + table: "Transactions", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "Salaries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + RecurrenceType = table.Column(type: "INTEGER", nullable: false), + StartDate = table.Column(type: "TEXT", nullable: false), + AccountId = table.Column(type: "INTEGER", nullable: false), + CategoryId = table.Column(type: "INTEGER", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Salaries", x => x.Id); + table.ForeignKey( + name: "FK_Salaries_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Salaries_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Salaries_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_SalaryId", + table: "Transactions", + column: "SalaryId"); + + migrationBuilder.CreateIndex( + name: "IX_Salaries_AccountId", + table: "Salaries", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Salaries_CategoryId", + table: "Salaries", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Salaries_UserId", + table: "Salaries", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Salaries_SalaryId", + table: "Transactions", + column: "SalaryId", + principalTable: "Salaries", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Salaries_SalaryId", + table: "Transactions"); + + migrationBuilder.DropTable( + name: "Salaries"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_SalaryId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "SalaryId", + table: "Transactions"); + } + } +} diff --git a/BudgetApp/Migrations/20260305181148_AddIsDefaultToCategory.Designer.cs b/BudgetApp/Migrations/20260305181148_AddIsDefaultToCategory.Designer.cs new file mode 100644 index 0000000..a622392 --- /dev/null +++ b/BudgetApp/Migrations/20260305181148_AddIsDefaultToCategory.Designer.cs @@ -0,0 +1,517 @@ +// +using System; +using BudgetApp.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BudgetApp.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260305181148_AddIsDefaultToCategory")] + partial class AddIsDefaultToCategory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("TEXT"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("BudgetApp.Data.Salary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecurrenceType") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("UserId"); + + b.ToTable("Salaries"); + }); + + modelBuilder.Entity("BudgetApp.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsPaid") + .HasColumnType("INTEGER"); + + b.Property("ParentTransactionId") + .HasColumnType("INTEGER"); + + b.Property("RecurrenceEndDate") + .HasColumnType("TEXT"); + + b.Property("RecurrenceType") + .HasColumnType("INTEGER"); + + b.Property("SalaryId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ParentTransactionId"); + + b.HasIndex("SalaryId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Salary", b => + { + b.HasOne("BudgetApp.Data.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BudgetApp.Data.Transaction", b => + { + b.HasOne("BudgetApp.Data.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BudgetApp.Data.Transaction", "ParentTransaction") + .WithMany() + .HasForeignKey("ParentTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BudgetApp.Data.Salary", "Salary") + .WithMany() + .HasForeignKey("SalaryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ParentTransaction"); + + b.Navigation("Salary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BudgetApp.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BudgetApp.Data.Account", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("BudgetApp.Data.Category", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BudgetApp/Migrations/20260305181148_AddIsDefaultToCategory.cs b/BudgetApp/Migrations/20260305181148_AddIsDefaultToCategory.cs new file mode 100644 index 0000000..1e96a91 --- /dev/null +++ b/BudgetApp/Migrations/20260305181148_AddIsDefaultToCategory.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BudgetApp.Migrations +{ + /// + public partial class AddIsDefaultToCategory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsDefault", + table: "Categories", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsDefault", + table: "Categories"); + } + } +} diff --git a/BudgetApp/Migrations/ApplicationDbContextModelSnapshot.cs b/BudgetApp/Migrations/ApplicationDbContextModelSnapshot.cs index 7348fe2..92d0001 100644 --- a/BudgetApp/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BudgetApp/Migrations/ApplicationDbContextModelSnapshot.cs @@ -127,6 +127,9 @@ namespace BudgetApp.Migrations .HasMaxLength(10) .HasColumnType("TEXT"); + b.Property("IsDefault") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(100) @@ -143,6 +146,47 @@ namespace BudgetApp.Migrations b.ToTable("Categories"); }); + modelBuilder.Entity("BudgetApp.Data.Salary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecurrenceType") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("UserId"); + + b.ToTable("Salaries"); + }); + modelBuilder.Entity("BudgetApp.Data.Transaction", b => { b.Property("Id") @@ -166,6 +210,9 @@ namespace BudgetApp.Migrations .HasMaxLength(200) .HasColumnType("TEXT"); + b.Property("IsPaid") + .HasColumnType("INTEGER"); + b.Property("ParentTransactionId") .HasColumnType("INTEGER"); @@ -175,6 +222,9 @@ namespace BudgetApp.Migrations b.Property("RecurrenceType") .HasColumnType("INTEGER"); + b.Property("SalaryId") + .HasColumnType("INTEGER"); + b.Property("Type") .HasColumnType("INTEGER"); @@ -186,6 +236,8 @@ namespace BudgetApp.Migrations b.HasIndex("ParentTransactionId"); + b.HasIndex("SalaryId"); + b.ToTable("Transactions"); }); @@ -339,6 +391,31 @@ namespace BudgetApp.Migrations b.Navigation("User"); }); + modelBuilder.Entity("BudgetApp.Data.Salary", b => + { + b.HasOne("BudgetApp.Data.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BudgetApp.Data.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("BudgetApp.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + modelBuilder.Entity("BudgetApp.Data.Transaction", b => { b.HasOne("BudgetApp.Data.Account", "Account") @@ -357,11 +434,18 @@ namespace BudgetApp.Migrations .HasForeignKey("ParentTransactionId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("BudgetApp.Data.Salary", "Salary") + .WithMany() + .HasForeignKey("SalaryId") + .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Account"); b.Navigation("Category"); b.Navigation("ParentTransaction"); + + b.Navigation("Salary"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => diff --git a/BudgetApp/Program.cs b/BudgetApp/Program.cs index 607f44a..f34415c 100644 --- a/BudgetApp/Program.cs +++ b/BudgetApp/Program.cs @@ -81,6 +81,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/BudgetApp/Services/CategoryService.cs b/BudgetApp/Services/CategoryService.cs index 4687073..e7cc5af 100644 --- a/BudgetApp/Services/CategoryService.cs +++ b/BudgetApp/Services/CategoryService.cs @@ -49,6 +49,7 @@ public class CategoryService(ApplicationDbContext db) { Name = d.Name, Icon = d.Icon, + IsDefault = true, UserId = userId }); @@ -57,7 +58,7 @@ public class CategoryService(ApplicationDbContext db) return; } - // Backfill icons on existing default categories that have empty icons + // Backfill icons and IsDefault on existing default categories var defaultLookup = DefaultCategories.ToDictionary(d => d.Name, d => d.Icon); bool updated = false; @@ -68,6 +69,11 @@ public class CategoryService(ApplicationDbContext db) category.Icon = icon; updated = true; } + if (!category.IsDefault && defaultLookup.ContainsKey(category.Name)) + { + category.IsDefault = true; + updated = true; + } } if (updated) @@ -88,6 +94,7 @@ public class CategoryService(ApplicationDbContext db) .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId); if (category is not null) { + if (category.IsDefault) return; category.Name = name; category.Icon = icon; await _db.SaveChangesAsync(); @@ -100,6 +107,7 @@ public class CategoryService(ApplicationDbContext db) .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId); if (category is not null) { + if (category.IsDefault) return; _db.Categories.Remove(category); await _db.SaveChangesAsync(); } diff --git a/BudgetApp/Services/DashboardService.cs b/BudgetApp/Services/DashboardService.cs index b260721..836c9b3 100644 --- a/BudgetApp/Services/DashboardService.cs +++ b/BudgetApp/Services/DashboardService.cs @@ -24,12 +24,13 @@ public class DashboardService(TransactionService transactionService, AccountServ var allTransactions = await _transactionService.GetTransactionsInRangeAsync(userId, fetchStart, fetchEnd); var recent = allTransactions - .Where(t => t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today) + .Where(t => (t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today) + || (t.IsPaid && t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date)) .OrderByDescending(t => t.Date) .ToList(); var upcoming = allTransactions - .Where(t => t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date) + .Where(t => t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date && !t.IsPaid) .OrderBy(t => t.Date) .ToList(); @@ -57,8 +58,9 @@ public class DashboardService(TransactionService transactionService, AccountServ { labels.Add(d.ToString("MMM dd")); + // Paid future transactions are excluded — their effect is already in CurrentBalance var dayTxns = transactions - .Where(t => t.Date.Date == d.Date) + .Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today)) .Select(t => $"{(t.Type == TransactionType.Income ? "+" : "-")}{t.Account.FormatAmount(t.Amount)} {t.Description} ({t.Account.Name})") .ToList(); dayTransactionLabels.Add(dayTxns); @@ -85,8 +87,9 @@ public class DashboardService(TransactionService transactionService, AccountServ } else { + // Future-only window: project forward from CurrentBalance using only unpaid transactions foreach (var t in accountTransactions - .Where(t => t.Date.Date > today && t.Date.Date < startDate.Date) + .Where(t => t.Date.Date > today && t.Date.Date < startDate.Date && !t.IsPaid) .OrderBy(t => t.Date)) { runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount; @@ -95,7 +98,7 @@ public class DashboardService(TransactionService transactionService, AccountServ for (var d = startDate; d <= endDate; d = d.AddDays(1)) { - var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date); + var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today)); foreach (var t in dayTxns) { runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount; diff --git a/BudgetApp/Services/SalaryService.cs b/BudgetApp/Services/SalaryService.cs new file mode 100644 index 0000000..1e1bc37 --- /dev/null +++ b/BudgetApp/Services/SalaryService.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using BudgetApp.Data; + +namespace BudgetApp.Services; + +public class SalaryService(ApplicationDbContext db) +{ + private readonly ApplicationDbContext _db = db; + + public async Task> GetSalariesAsync(string userId) + { + return await _db.Salaries + .Include(s => s.Account) + .Include(s => s.Category) + .Where(s => s.UserId == userId) + .OrderBy(s => s.Name) + .ToListAsync(); + } + + public async Task CreateSalaryAsync(Salary salary, DateTime windowEnd) + { + var salaryCategoryId = await _db.Categories + .Where(c => c.UserId == salary.UserId && c.Name == "Salary") + .Select(c => (int?)c.Id) + .FirstOrDefaultAsync(); + salary.CategoryId = salaryCategoryId; + + _db.Salaries.Add(salary); + await _db.SaveChangesAsync(); + await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd); + } + + public async Task DeleteSalaryAsync(int id, string userId) + { + var exists = await _db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId); + if (!exists) return; + + await using var transaction = await _db.Database.BeginTransactionAsync(); + + // Delete future unpaid transactions generated by this salary + await _db.Transactions + .Where(t => t.SalaryId == id && t.Date.Date > DateTime.Today && !t.IsPaid) + .ExecuteDeleteAsync(); + + // Unlink past / paid transactions so they stay in history without the salary reference + await _db.Transactions + .Where(t => t.SalaryId == id) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.SalaryId, (int?)null)); + + await _db.Salaries + .Where(s => s.Id == id && s.UserId == userId) + .ExecuteDeleteAsync(); + + await transaction.CommitAsync(); + } + + public async Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd) + { + var salaries = await _db.Salaries + .Where(s => s.UserId == userId) + .ToListAsync(); + + var newTransactions = new List(); + + foreach (var salary in salaries) + { + var latestDate = await _db.Transactions + .Where(t => t.SalaryId == salary.Id) + .OrderByDescending(t => t.Date) + .Select(t => (DateTime?)t.Date) + .FirstOrDefaultAsync(); + + var nextDate = latestDate.HasValue + ? GetNextDate(latestDate.Value, salary) + : salary.StartDate.Date; + + while (nextDate <= windowEnd) + { + newTransactions.Add(new Transaction + { + Description = salary.Name, + Amount = salary.Amount, + Date = nextDate, + Type = TransactionType.Income, + AccountId = salary.AccountId, + CategoryId = salary.CategoryId, + RecurrenceType = RecurrenceType.None, + SalaryId = salary.Id + }); + nextDate = GetNextDate(nextDate, salary); + } + } + + if (newTransactions.Count > 0) + { + _db.Transactions.AddRange(newTransactions); + await _db.SaveChangesAsync(); + } + } + + public async Task GetNextPaydateAsync(int salaryId) + { + return await _db.Transactions + .Where(t => t.SalaryId == salaryId && t.Date.Date >= DateTime.Today) + .OrderBy(t => t.Date) + .Select(t => (DateTime?)t.Date) + .FirstOrDefaultAsync(); + } + + private static DateTime GetNextDate(DateTime from, Salary salary) => salary.RecurrenceType switch + { + RecurrenceType.Weekly => from.AddDays(7), + RecurrenceType.Monthly => from.AddMonths(1), + RecurrenceType.Yearly => from.AddYears(1), + _ => from.AddMonths(1) + }; +} diff --git a/BudgetApp/Services/TransactionService.cs b/BudgetApp/Services/TransactionService.cs index b631057..6bb0eb0 100644 --- a/BudgetApp/Services/TransactionService.cs +++ b/BudgetApp/Services/TransactionService.cs @@ -220,6 +220,18 @@ public class TransactionService(ApplicationDbContext db) await _db.SaveChangesAsync(); } + public async Task MarkAsPaidAsync(int id, string userId, bool isPaid = true) + { + var transaction = await _db.Transactions + .Include(t => t.Account) + .FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId); + + if (transaction is null) return; + + transaction.IsPaid = isPaid; + await _db.SaveChangesAsync(); + } + public async Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null) { var recurringParents = await _db.Transactions diff --git a/BudgetApp/budgetapp.db b/BudgetApp/budgetapp.db index cbaf4a9..61a2e29 100644 Binary files a/BudgetApp/budgetapp.db and b/BudgetApp/budgetapp.db differ diff --git a/BudgetApp/budgetapp.db-shm b/BudgetApp/budgetapp.db-shm index 38f52a3..a92c423 100644 Binary files a/BudgetApp/budgetapp.db-shm and b/BudgetApp/budgetapp.db-shm differ diff --git a/BudgetApp/budgetapp.db-wal b/BudgetApp/budgetapp.db-wal index abb10ff..864c4c0 100644 Binary files a/BudgetApp/budgetapp.db-wal and b/BudgetApp/budgetapp.db-wal differ diff --git a/BudgetApp/wwwroot/js/chart-interop.js b/BudgetApp/wwwroot/js/chart-interop.js index b733338..4418a3b 100644 --- a/BudgetApp/wwwroot/js/chart-interop.js +++ b/BudgetApp/wwwroot/js/chart-interop.js @@ -2,7 +2,7 @@ window.chartInterop = { chartInstances: {}, transactionLabels: {}, - createOrUpdateChart: function (canvasId, labels, datasets, dayTransactionLabels, highlightIndex, currencySymbol) { + createOrUpdateChart: function (canvasId, labels, datasets, dayTransactionLabels, highlightIndex, salaryDayIndices, currencySymbol) { const canvas = document.getElementById(canvasId); if (!canvas) return; @@ -88,6 +88,32 @@ window.chartInterop = { }; const storedLabels = this.transactionLabels[canvasId]; + const salaryIndices = salaryDayIndices || []; + + // Salary payday highlight plugin — subtle green band + 💰 marker + const salaryPlugin = { + id: 'salaryDays', + afterDraw: (chart) => { + const indices = chart.config._salaryDayIndices; + if (!indices || indices.length === 0) return; + const meta = chart.getDatasetMeta(0); + if (!meta) return; + const ctx = chart.ctx; + const top = chart.chartArea.top; + const bottom = chart.chartArea.bottom; + indices.forEach(idx => { + if (!meta.data[idx]) return; + const x = meta.data[idx].x; + ctx.save(); + ctx.fillStyle = 'rgba(25, 135, 84, 0.10)'; + ctx.fillRect(x - 6, top, 12, bottom - top); + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('💰', x, top + 14); + ctx.restore(); + }); + } + }; const chartInstance = new Chart(canvas, { type: 'line', @@ -140,11 +166,12 @@ window.chartInterop = { } } }, - plugins: [highlightPlugin, crosshairPlugin] + plugins: [highlightPlugin, crosshairPlugin, salaryPlugin] }); chartInstance.config._highlightIndex = (highlightIndex != null && highlightIndex >= 0) ? highlightIndex : null; - if (highlightIndex != null && highlightIndex >= 0) { + chartInstance.config._salaryDayIndices = salaryIndices; + if ((highlightIndex != null && highlightIndex >= 0) || salaryIndices.length > 0) { chartInstance.update(); }