This commit is contained in:
rossmill76 committed 2026-03-05 18:57:12 +00:00
1 parent 38067df8c3
commit 492d86db9e
30 files changed
+2454 -21

No files matched your search

@@ -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<Transaction>
{
// 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<Transaction>
{
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)
@@ -33,6 +33,12 @@
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="salary">
<span class="bi bi-cash-coin-nav-menu" aria-hidden="true"></span> Salary
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="categories">
<span class="bi bi-tag-nav-menu" aria-hidden="true"></span> Categories
@@ -50,6 +50,10 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-left-right' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z'/%3E%3C/svg%3E");
}
.bi-cash-coin-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M11 15a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm5-4a5 5 0 1 1-10 0 5 5 0 0 1 10 0z'/%3E%3Cpath d='M9.438 11.944c.047.596.518 1.06 1.363 1.116v.44h.375v-.443c.875-.061 1.386-.529 1.386-1.207 0-.618-.39-.936-1.09-1.1l-.296-.07v-1.2c.376.043.614.248.671.532h.658c-.047-.575-.54-1.024-1.329-1.073V8.5h-.375v.45c-.747.073-1.255.522-1.255 1.158 0 .562.378.92 1.007 1.066l.248.061v1.272c-.384-.058-.639-.27-.696-.563h-.668zm1.36-1.354c-.369-.085-.569-.26-.569-.522 0-.294.216-.514.572-.578v1.1h-.003zm.432.746c.449.104.655.272.655.569 0 .339-.257.571-.709.614v-1.195l.054.012z'/%3E%3Cpath d='M1 0a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.083c.058-.344.145-.678.258-1H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3.528c.38.34.717.728 1 1.154V1a1 1 0 0 0-1-1H1z'/%3E%3Cpath d='M9.998 5.083 10 5a2 2 0 1 0-3.132 1.65 5.982 5.982 0 0 1 3.13-1.567z'/%3E%3C/svg%3E");
}
.bi-tag-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tag-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 1a1 1 0 0 0-1 1v4.586a1 1 0 0 0 .293.707l7 7a1 1 0 0 0 1.414 0l4.586-4.586a1 1 0 0 0 0-1.414l-7-7A1 1 0 0 0 6.586 1H2zm4 3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'/%3E%3C/svg%3E");
}
+5 -1
View File
@@ -69,7 +69,11 @@ else
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<div class="d-flex gap-1 justify-content-center">
@if (editingCategoryId == category.Id)
@if (category.IsDefault)
{
<span class="badge bg-secondary" title="Default categories cannot be edited or deleted">Default</span>
}
else if (editingCategoryId == category.Id)
{
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
+38 -2
View File
@@ -21,7 +21,10 @@
<strong>Create an account</strong> — Head to <a href="/accounts">Accounts</a> and add at least one financial account (e.g. "Current Account", "Savings"). Set the opening balance and choose a currency.
</li>
<li class="mb-2">
<strong>Set up categories</strong> — Visit <a href="/categories">Categories</a> to organise your spending. Pick an emoji icon for each category to make them easy to spot.
<strong>Set up categories</strong> — Visit <a href="/categories">Categories</a> to organise your spending. A set of default categories (including Salary) are provided automatically.
</li>
<li class="mb-2">
<strong>Configure your salary</strong> — Visit <a href="/salary">Salary</a> to add your regular income sources. Each salary generates recurring Income transactions automatically and highlights paydays on the chart.
</li>
<li class="mb-2">
<strong>Record transactions</strong> — Go to <a href="/transactions">Transactions</a> or use the <strong>+ Add</strong> button on the <a href="/">Dashboard</a> to log income and expenses.
@@ -42,7 +45,7 @@
<p>The <a href="/">Dashboard</a> is your home screen and gives you an at-a-glance view of your finances.</p>
<h6>Balance Trend Chart</h6>
<p>The chart shows projected balances for each account over a time window starting from today. Use the preset buttons (<strong>7d</strong>, <strong>30d</strong>, <strong>90d</strong>, <strong>1y</strong>) to look ahead, or pick custom dates with the date pickers.</p>
<p>The chart shows projected balances for each account over a time window starting from today. Use the preset buttons (<strong>7d</strong>, <strong>30d</strong>, <strong>90d</strong>, <strong>1y</strong>) to look ahead, or pick custom dates with the date pickers. Payday dates from your <a href="/salary">Salary</a> sources are highlighted with a 💰 marker and a subtle green band.</p>
<h6>Recent &amp; Upcoming Tabs</h6>
<ul>
@@ -109,6 +112,36 @@
</div>
</div>
@* ── Salary ── *@
<div class="card mb-4" id="salary">
<div class="card-header">
<h5 class="mb-0">💰 Salary</h5>
</div>
<div class="card-body">
<p>The <a href="/salary">Salary</a> page lets you define regular income sources that generate recurring transactions automatically.</p>
<h6>Adding a Salary</h6>
<ul>
<li><strong>Name</strong> — A label for this income source (e.g. "Main Job", "Freelance").</li>
<li><strong>Amount</strong> — The net amount credited each payday.</li>
<li><strong>Account</strong> — Which account receives the payment.</li>
<li><strong>Recurrence</strong> — How often you are paid: <strong>Weekly</strong>, <strong>Monthly</strong> or <strong>Yearly</strong>.</li>
<li><strong>First Payday</strong> — The date of the first payment. The day of month (or week) is derived from this and repeats indefinitely.</li>
</ul>
<h6>How Salary Transactions Work</h6>
<ul>
<li>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.</li>
<li>They appear on the <a href="/transactions">Transactions</a> page with a 💰 badge and cannot be deleted from there — manage them on the Salary page instead.</li>
<li>Payday dates are highlighted on the <a href="/">Dashboard</a> balance chart with a 💰 marker and subtle green band so you can see upcoming income at a glance.</li>
<li>The category is automatically set to <strong>Salary</strong>.</li>
</ul>
<h6>Deleting a Salary</h6>
<p class="mb-0">Deleting a salary removes all future unpaid transactions for that income source. Past transactions remain in your history for accurate record-keeping.</p>
</div>
</div>
@* ── Categories ── *@
<div class="card mb-4" id="categories">
<div class="card-header">
@@ -120,6 +153,7 @@
<li><strong>Add a category</strong> — Enter a name and pick an emoji icon from the icon picker.</li>
<li><strong>Edit</strong> — Click the Edit button on a category card to change its name or icon.</li>
<li><strong>Delete</strong> — Removes the category. Transactions using it will become uncategorised.</li>
<li><strong>Default categories</strong> — A set of built-in categories (Groceries, Salary, Rent / Mortgage, etc.) are created automatically and shown with a <strong>Default</strong> badge. These cannot be edited or deleted.</li>
</ul>
<p class="mb-0">Categories are optional — you can assign them to transactions to get better insight into your spending patterns.</p>
</div>
@@ -175,6 +209,7 @@
<ul class="mb-0">
<li>Use the Dashboard <strong>+ Add</strong> button for rapid entry — the modal stays open after each save so you can batch-add transactions.</li>
<li>Set up recurring transactions for regular bills and income to keep your upcoming view accurate.</li>
<li>Use the <a href="/salary">Salary</a> page for regular pay — it is protected from accidental deletion and highlights paydays on the chart. Use the <strong>90d</strong> or <strong>1y</strong> window to plan ahead.</li>
<li>Assign colours to your accounts to easily distinguish them on the chart and in transaction lists.</li>
<li>Hover over elements for tooltips with additional context throughout the app.</li>
<li>Click your account balances in the sidebar to quickly navigate to account management.</li>
@@ -193,6 +228,7 @@
<a href="/help#dashboard" class="list-group-item list-group-item-action">📊 Dashboard</a>
<a href="/help#accounts" class="list-group-item list-group-item-action">🏦 Accounts</a>
<a href="/help#transactions" class="list-group-item list-group-item-action">💸 Transactions</a>
<a href="/help#salary" class="list-group-item list-group-item-action">💰 Salary</a>
<a href="/help#categories" class="list-group-item list-group-item-action">🏷️ Categories</a>
<a href="/help#reports" class="list-group-item list-group-item-action">📈 Reports</a>
<a href="/help#settings" class="list-group-item list-group-item-action">⚙️ Settings</a>
+47 -2
View File
@@ -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
{
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
}
@if (t.IsPaid)
{
<span class="badge bg-success bg-opacity-10 text-success" style="font-size: 0.7rem;">✓ Paid</span>
}
</div>
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
@@ -214,10 +219,20 @@ else
</div>
@if (selectedTransactionId == t.Id)
{
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
@if (t.IsPaid)
{
<button class="btn btn-outline-secondary btn-sm" title="Revert to upcoming" @onclick="() => MarkAsPaid(t.Id, false)" @onclick:stopPropagation="true">↩ Unpaid</button>
}
else
{
<span></span>
}
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
</div>
}
</div>
}
@@ -282,10 +297,13 @@ else
</div>
@if (selectedTransactionId == t.Id)
{
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
<button class="btn btn-outline-success btn-sm" title="Mark as paid and move to Recent" @onclick="() => MarkAsPaid(t.Id, true)" @onclick:stopPropagation="true">✅ Mark as Paid</button>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
</div>
}
</div>
}
@@ -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();
+2
View File
@@ -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
+264
View File
@@ -0,0 +1,264 @@
@page "/salary"
@attribute [Authorize]
@inject SalaryService SalaryService
@inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
<PageTitle>Salary</PageTitle>
<h3 class="mb-2">Salary</h3>
<p class="text-muted mb-4">Define your regular income sources. Each salary generates recurring transactions at the set interval, forever.</p>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Add Income Source</h5>
</div>
<div class="card-body">
@if (accountsList is not null && accountsList.Count == 0)
{
<div class="alert alert-warning mb-0">
You need to <a href="/accounts">create an account</a> before adding a salary.
</div>
}
else
{
<EditForm Model="formModel" OnValidSubmit="AddSalary" FormName="addSalary">
<ToastValidator />
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Name</label>
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Main Job" />
</div>
<div class="col-md-2">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" />
</div>
<div class="col-md-2">
<label class="form-label">Account</label>
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
@if (accountsList is not null)
{
@foreach (var account in accountsList)
{
<option value="@account.Id">@account.Name</option>
}
}
</InputSelect>
</div>
<div class="col-md-2">
<label class="form-label">Recurrence</label>
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select">
<option value="@RecurrenceType.Weekly">Weekly</option>
<option value="@RecurrenceType.Monthly">Monthly</option>
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
<div class="col-md-3">
<label class="form-label">First Payday</label>
<InputDate @bind-Value="formModel.StartDate" class="form-control" title="The date of the first payment — the interval repeats from here indefinitely" />
</div>
<div class="col-md-12 col-lg-auto d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</div>
</EditForm>
}
</div>
</div>
@if (salaries is null)
{
<p><em>Loading...</em></p>
}
else if (salaries.Count == 0)
{
<div class="alert alert-info">No income sources yet. Add one above to start tracking your salary.</div>
}
else
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">Income Sources</h5>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Amount</th>
<th>Account</th>
<th class="d-none d-md-table-cell">Schedule</th>
<th>Next Payday</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var s in salaries)
{
<tr>
<td class="fw-semibold">💰 @s.Name</td>
<td class="text-success fw-semibold text-nowrap">+@s.Account.FormatAmount(s.Amount)</td>
<td>
<span class="badge" style="background-color: @s.Account.Color;">
@s.Account.Name
</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-info text-nowrap">🔁 @s.GetPaydaySummary()</span>
</td>
<td class="text-nowrap">
@if (nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue)
{
<span>@next.Value.ToString("MMM dd, yyyy")</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-end text-nowrap">
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(s)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card-footer">
<small class="text-muted">@salaries.Count income source@(salaries.Count == 1 ? "" : "s") · Transactions are generated automatically and appear on the <a href="/transactions">Transactions</a> page.</small>
</div>
</div>
}
@if (showDeleteModal && pendingDeleteSalary is not null)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Income Source</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>@pendingDeleteSalary.Name</strong>?</p>
<p>All future unpaid salary transactions will be removed. Past transactions remain in your history.</p>
<p class="text-danger mb-0">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
<button type="button" class="btn btn-danger" @onclick="ExecuteDelete">Delete</button>
</div>
</div>
</div>
</div>
}
@code {
private List<BudgetApp.Data.Salary>? salaries;
private List<Account>? accountsList;
private Dictionary<int, DateTime?> 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<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
{
yield break;
}
}
}
@@ -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;
}
}
+57 -7
View File
@@ -3,6 +3,7 @@
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject SalaryService SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
@@ -177,10 +178,27 @@ else
{
<tr>
<td>
@if (t.SalaryId is null)
{
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
}
else
{
<span class="text-muted" style="font-size: 0.85rem;" title="Salary transactions cannot be bulk deleted — manage them on the Salary page">🔒</span>
}
</td>
<td class="text-nowrap">@t.Date.ToString("MMM dd, yyyy")</td>
<td>@t.Description</td>
<td>
@t.Description
@if (t.SalaryId is not null)
{
<span class="badge bg-primary ms-1" style="font-size: 0.7rem;">💰 Salary</span>
}
@if (t.IsPaid && t.Date.Date > DateTime.Today)
{
<span class="badge bg-success ms-1" style="font-size: 0.7rem;">✓ Paid</span>
}
</td>
<td>
<span class="badge" style="background-color: @t.Account.Color;">
@t.Account.Name
@@ -215,8 +233,21 @@ else
</td>
<td class="text-end text-nowrap">
<div class="btn-group btn-group-sm">
@if (t.Date.Date > DateTime.Today)
{
<button class="btn @(t.IsPaid ? "btn-success" : "btn-outline-success")"
title="@(t.IsPaid ? "Mark as unpaid" : "Mark as paid")"
@onclick="() => TogglePaid(t)">✓</button>
}
<button class="btn btn-outline-primary" @onclick="() => OpenEditModal(t)">Edit</button>
@if (t.SalaryId is null)
{
<button class="btn btn-outline-danger" @onclick="() => ConfirmDelete(t)">Delete</button>
}
else
{
<a href="/salary" class="btn btn-outline-secondary" title="Salary transactions are managed on the Salary page">🔒</a>
}
</div>
</td>
</tr>
@@ -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")]
+21
View File
@@ -13,6 +13,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
public DbSet<Account> Accounts => Set<Account>();
public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Salary> Salaries => Set<Salary>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -45,6 +46,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
.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<ApplicationUser>
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<Salary>(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)");
});
}
}
+2
View File
@@ -17,5 +17,7 @@ public class Category
public ApplicationUser User { get; set; } = default!;
public bool IsDefault { get; set; }
public List<Transaction> Transactions { get; set; } = [];
}
+43
View File
@@ -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"
};
}
+5
View File
@@ -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; }
}
@@ -0,0 +1,436 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<decimal>("CurrentBalance")
.HasColumnType("decimal(18,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Icon")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Categories");
});
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsPaid")
.HasColumnType("INTEGER");
b.Property<int?>("ParentTransactionId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("RecurrenceEndDate")
.HasColumnType("TEXT");
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<int>("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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("BudgetApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("BudgetApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BudgetApp.Migrations
{
/// <inheritdoc />
public partial class AddIsPaidToTransaction : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPaid",
table: "Transactions",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPaid",
table: "Transactions");
}
}
}
+514
View File
@@ -0,0 +1,514 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<decimal>("CurrentBalance")
.HasColumnType("decimal(18,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Icon")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Categories");
});
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsPaid")
.HasColumnType("INTEGER");
b.Property<int?>("ParentTransactionId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("RecurrenceEndDate")
.HasColumnType("TEXT");
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<int?>("SalaryId")
.HasColumnType("INTEGER");
b.Property<int>("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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("BudgetApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("BudgetApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}
@@ -0,0 +1,104 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BudgetApp.Migrations
{
/// <inheritdoc />
public partial class AddSalary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SalaryId",
table: "Transactions",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "Salaries",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
RecurrenceType = table.Column<int>(type: "INTEGER", nullable: false),
StartDate = table.Column<DateTime>(type: "TEXT", nullable: false),
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
CategoryId = table.Column<int>(type: "INTEGER", nullable: true),
UserId = table.Column<string>(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);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,517 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<decimal>("CurrentBalance")
.HasColumnType("decimal(18,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Icon")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Categories");
});
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsPaid")
.HasColumnType("INTEGER");
b.Property<int?>("ParentTransactionId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("RecurrenceEndDate")
.HasColumnType("TEXT");
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<int?>("SalaryId")
.HasColumnType("INTEGER");
b.Property<int>("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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("BudgetApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("BudgetApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BudgetApp.Migrations
{
/// <inheritdoc />
public partial class AddIsDefaultToCategory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDefault",
table: "Categories",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsDefault",
table: "Categories");
}
}
}
@@ -127,6 +127,9 @@ namespace BudgetApp.Migrations
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@@ -143,6 +146,47 @@ namespace BudgetApp.Migrations
b.ToTable("Categories");
});
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
@@ -166,6 +210,9 @@ namespace BudgetApp.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsPaid")
.HasColumnType("INTEGER");
b.Property<int?>("ParentTransactionId")
.HasColumnType("INTEGER");
@@ -175,6 +222,9 @@ namespace BudgetApp.Migrations
b.Property<int>("RecurrenceType")
.HasColumnType("INTEGER");
b.Property<int?>("SalaryId")
.HasColumnType("INTEGER");
b.Property<int>("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<string>", b =>
+1
View File
@@ -81,6 +81,7 @@ builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<TransactionService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<DashboardService>();
builder.Services.AddScoped<SalaryService>();
builder.Services.AddScoped<ToastService>();
builder.Services.AddScoped<AppState>();
+9 -1
View File
@@ -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();
}
+8 -5
View File
@@ -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;
+117
View File
@@ -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<List<Salary>> 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<Transaction>();
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<DateTime?> 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)
};
}
+12
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30 -3
View File
@@ -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();
}