From 8b2f5aad56c1a1b11af9b5153cd893d65b24d3e2 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Mon, 9 Feb 2026 01:07:26 -0500 Subject: [PATCH] Normalize all DateTime values to UTC across the codebase Npgsql requires DateTimeKind.Utc for timestamp with time zone columns. This fixes errors in transaction create/edit, budgets, reports, loans, scheduled transactions, and file imports. Changes: - TransactionService: SpecifyKind on request.Date for create, update, and transfer - BudgetService: UTC kind on all new DateTime(year, month, 1) - ReportService: UTC kind on endOfMonth; rewrite spending-by- category GroupBy to avoid untranslatable navigation properties - LoanService: UTC kind on payoff scenario date construction - ScheduledTransactionService: UTC kind on SemiMonthly date - ImportService: SpecifyKind on parsed.Date when persisting - OFX/CSV/QIF parsers: AssumeUniversal + AdjustToUniversal on all date parsing Co-Authored-By: Claude Opus 4.6 --- src/Purrse.Api/Services/BudgetService.cs | 6 +++--- src/Purrse.Api/Services/ImportService.cs | 2 +- src/Purrse.Api/Services/LoanService.cs | 4 ++-- src/Purrse.Api/Services/ReportService.cs | 21 +++++++++++++------ .../Services/ScheduledTransactionService.cs | 2 +- src/Purrse.Api/Services/TransactionService.cs | 8 +++---- src/Purrse.Plugins.CSV/CSVParserPlugin.cs | 2 +- src/Purrse.Plugins.OFX/OFXParserPlugin.cs | 4 ++-- src/Purrse.Plugins.QIF/QIFParserPlugin.cs | 4 ++-- 9 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/Purrse.Api/Services/BudgetService.cs b/src/Purrse.Api/Services/BudgetService.cs index 42d80f8..532909f 100644 --- a/src/Purrse.Api/Services/BudgetService.cs +++ b/src/Purrse.Api/Services/BudgetService.cs @@ -21,7 +21,7 @@ public class BudgetService : IBudgetService if (budget == null) return null; - var startDate = new DateTime(year, month, 1); + var startDate = new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = startDate.AddMonths(1).AddDays(-1); var actuals = await GetActualSpending(userId, startDate, endDate); @@ -77,7 +77,7 @@ public class BudgetService : IBudgetService await _db.SaveChangesAsync(); - var startDate = new DateTime(budget.Year, budget.Month, 1); + var startDate = new DateTime(budget.Year, budget.Month, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = startDate.AddMonths(1).AddDays(-1); var actuals = await GetActualSpending(userId, startDate, endDate); @@ -98,7 +98,7 @@ public class BudgetService : IBudgetService .FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId) ?? throw new KeyNotFoundException("Budget not found"); - var startDate = new DateTime(budget.Year, budget.Month, 1); + var startDate = new DateTime(budget.Year, budget.Month, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = startDate.AddMonths(1).AddDays(-1); var actuals = await GetActualSpending(userId, startDate, endDate); diff --git a/src/Purrse.Api/Services/ImportService.cs b/src/Purrse.Api/Services/ImportService.cs index d18e767..65f493c 100644 --- a/src/Purrse.Api/Services/ImportService.cs +++ b/src/Purrse.Api/Services/ImportService.cs @@ -215,7 +215,7 @@ public class ImportService : IImportService { Id = Guid.NewGuid(), AccountId = batch.AccountId, - Date = parsed.Date, + Date = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc), Amount = parsed.Amount, PayeeId = matchedPayee?.Id, PayeeName = parsed.PayeeName, diff --git a/src/Purrse.Api/Services/LoanService.cs b/src/Purrse.Api/Services/LoanService.cs index af01581..8692005 100644 --- a/src/Purrse.Api/Services/LoanService.cs +++ b/src/Purrse.Api/Services/LoanService.cs @@ -115,8 +115,8 @@ public class LoanService : ILoanService var monthlyRate = l.InterestRate / 100 / 12; var extraMonthly = request.ExtraMonthlyPayment ?? 0; var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0); - var paymentDate = DateTime.UtcNow.Date; - paymentDate = new DateTime(paymentDate.Year, paymentDate.Month, l.OriginationDate.Day); + var now = DateTime.UtcNow; + var paymentDate = new DateTime(now.Year, now.Month, l.OriginationDate.Day, 0, 0, 0, DateTimeKind.Utc); var newEntries = new List(); int paymentNum = 0; diff --git a/src/Purrse.Api/Services/ReportService.cs b/src/Purrse.Api/Services/ReportService.cs index e7c447f..9ff055c 100644 --- a/src/Purrse.Api/Services/ReportService.cs +++ b/src/Purrse.Api/Services/ReportService.cs @@ -23,15 +23,24 @@ public class ReportService : IReportService query = query.Where(t => t.AccountId == accountId.Value); var groups = await query - .GroupBy(t => new { t.CategoryId, CategoryName = t.Category!.Name, ParentName = t.Category.Parent != null ? t.Category.Parent.Name : (string?)null }) - .Select(g => new { g.Key.CategoryName, g.Key.ParentName, Amount = Math.Abs(g.Sum(t => t.Amount)) }) + .GroupBy(t => t.CategoryId) + .Select(g => new { CategoryId = g.Key, Amount = -g.Sum(t => t.Amount) }) .OrderByDescending(g => g.Amount) .ToListAsync(); + var categoryIds = groups.Select(g => g.CategoryId).ToList(); + var categoryLookup = await _db.Categories + .Include(c => c.Parent) + .Where(c => categoryIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id, c => new { c.Name, ParentName = c.Parent != null ? c.Parent.Name : (string?)null }); + var total = groups.Sum(g => g.Amount); - var categories = groups.Select(g => new CategorySpending( - g.CategoryName, g.Amount, total > 0 ? (g.Amount / total) * 100 : 0, g.ParentName - )).ToList(); + var categories = groups.Select(g => + { + var cat = categoryLookup.GetValueOrDefault(g.CategoryId!.Value); + return new CategorySpending( + cat?.Name ?? "Unknown", g.Amount, total > 0 ? (g.Amount / total) * 100 : 0, cat?.ParentName); + }).ToList(); return new SpendingByCategoryReport(startDate, endDate, total, categories); } @@ -71,7 +80,7 @@ public class ReportService : IReportService while (currentDate <= endDate) { - var endOfMonth = new DateTime(currentDate.Year, currentDate.Month, DateTime.DaysInMonth(currentDate.Year, currentDate.Month)); + var endOfMonth = new DateTime(currentDate.Year, currentDate.Month, DateTime.DaysInMonth(currentDate.Year, currentDate.Month), 0, 0, 0, DateTimeKind.Utc); var txnsToDate = transactions.Where(t => t.Date <= endOfMonth).Sum(t => t.Amount); // Simplified: using current balances adjusted by future transactions diff --git a/src/Purrse.Api/Services/ScheduledTransactionService.cs b/src/Purrse.Api/Services/ScheduledTransactionService.cs index 7231b92..46a6422 100644 --- a/src/Purrse.Api/Services/ScheduledTransactionService.cs +++ b/src/Purrse.Api/Services/ScheduledTransactionService.cs @@ -141,7 +141,7 @@ public class ScheduledTransactionService : IScheduledTransactionService Frequency.Daily => current.AddDays(1), Frequency.Weekly => current.AddDays(7), Frequency.BiWeekly => current.AddDays(14), - Frequency.SemiMonthly => current.Day <= 15 ? new DateTime(current.Year, current.Month, Math.Min(28, DateTime.DaysInMonth(current.Year, current.Month))) : current.AddMonths(1).AddDays(-(current.Day - 1)), + Frequency.SemiMonthly => current.Day <= 15 ? new DateTime(current.Year, current.Month, Math.Min(28, DateTime.DaysInMonth(current.Year, current.Month)), 0, 0, 0, DateTimeKind.Utc) : current.AddMonths(1).AddDays(-(current.Day - 1)), Frequency.Monthly => current.AddMonths(1), Frequency.Quarterly => current.AddMonths(3), Frequency.SemiAnnually => current.AddMonths(6), diff --git a/src/Purrse.Api/Services/TransactionService.cs b/src/Purrse.Api/Services/TransactionService.cs index 82883ef..d90ab6f 100644 --- a/src/Purrse.Api/Services/TransactionService.cs +++ b/src/Purrse.Api/Services/TransactionService.cs @@ -47,7 +47,7 @@ public class TransactionService : ITransactionService { Id = Guid.NewGuid(), AccountId = request.AccountId, - Date = request.Date, + Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc), Amount = request.Amount, PayeeName = request.PayeeName, PayeeId = request.PayeeId, @@ -89,7 +89,7 @@ public class TransactionService : ITransactionService ?? throw new KeyNotFoundException("Transaction not found"); var oldAmount = transaction.Amount; - transaction.Date = request.Date; + transaction.Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc); transaction.Amount = request.Amount; transaction.PayeeName = request.PayeeName; transaction.PayeeId = request.PayeeId; @@ -181,7 +181,7 @@ public class TransactionService : ITransactionService { Id = Guid.NewGuid(), AccountId = request.FromAccountId, - Date = request.Date, + Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc), Amount = -Math.Abs(request.Amount), PayeeName = $"Transfer to {toAccount.Name}", Memo = request.Memo, @@ -193,7 +193,7 @@ public class TransactionService : ITransactionService { Id = Guid.NewGuid(), AccountId = request.ToAccountId, - Date = request.Date, + Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc), Amount = Math.Abs(request.Amount), PayeeName = $"Transfer from {fromAccount.Name}", Memo = request.Memo, diff --git a/src/Purrse.Plugins.CSV/CSVParserPlugin.cs b/src/Purrse.Plugins.CSV/CSVParserPlugin.cs index a0d4fae..a4b4aa4 100644 --- a/src/Purrse.Plugins.CSV/CSVParserPlugin.cs +++ b/src/Purrse.Plugins.CSV/CSVParserPlugin.cs @@ -52,7 +52,7 @@ public class CSVParserPlugin : IFileParser if (mapping.DateColumn >= 0) { var dateStr = csv.GetField(mapping.DateColumn); - if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date)) transaction.Date = date; } diff --git a/src/Purrse.Plugins.OFX/OFXParserPlugin.cs b/src/Purrse.Plugins.OFX/OFXParserPlugin.cs index fcd59af..a32afde 100644 --- a/src/Purrse.Plugins.OFX/OFXParserPlugin.cs +++ b/src/Purrse.Plugins.OFX/OFXParserPlugin.cs @@ -226,9 +226,9 @@ public class OFXParserPlugin : IFileParser var datePart = ofxDate.Substring(0, 8); if (DateTime.TryParseExact(datePart, "yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var date)) + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var date)) return date; } - return DateTime.Parse(ofxDate); + return DateTime.SpecifyKind(DateTime.Parse(ofxDate), DateTimeKind.Utc); } } diff --git a/src/Purrse.Plugins.QIF/QIFParserPlugin.cs b/src/Purrse.Plugins.QIF/QIFParserPlugin.cs index 63be87c..8a26bd2 100644 --- a/src/Purrse.Plugins.QIF/QIFParserPlugin.cs +++ b/src/Purrse.Plugins.QIF/QIFParserPlugin.cs @@ -47,7 +47,7 @@ public class QIFParserPlugin : IFileParser case 'D': current ??= new ParsedTransaction(); if (DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var date)) + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var date)) current.Date = date; else if (TryParseQIFDate(value, out var qifDate)) current.Date = qifDate; @@ -105,6 +105,6 @@ public class QIFParserPlugin : IFileParser return DateTime.TryParseExact(cleaned, formats, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out date); + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out date); } }