Private
Public Access
1
0

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 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-09 01:07:26 -05:00
parent bcb4a74158
commit 8b2f5aad56
9 changed files with 31 additions and 22 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ public class BudgetService : IBudgetService
if (budget == null) return null; 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 endDate = startDate.AddMonths(1).AddDays(-1);
var actuals = await GetActualSpending(userId, startDate, endDate); var actuals = await GetActualSpending(userId, startDate, endDate);
@@ -77,7 +77,7 @@ public class BudgetService : IBudgetService
await _db.SaveChangesAsync(); 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 endDate = startDate.AddMonths(1).AddDays(-1);
var actuals = await GetActualSpending(userId, startDate, endDate); var actuals = await GetActualSpending(userId, startDate, endDate);
@@ -98,7 +98,7 @@ public class BudgetService : IBudgetService
.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId) .FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
?? throw new KeyNotFoundException("Budget not found"); ?? 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 endDate = startDate.AddMonths(1).AddDays(-1);
var actuals = await GetActualSpending(userId, startDate, endDate); var actuals = await GetActualSpending(userId, startDate, endDate);
+1 -1
View File
@@ -215,7 +215,7 @@ public class ImportService : IImportService
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
AccountId = batch.AccountId, AccountId = batch.AccountId,
Date = parsed.Date, Date = DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc),
Amount = parsed.Amount, Amount = parsed.Amount,
PayeeId = matchedPayee?.Id, PayeeId = matchedPayee?.Id,
PayeeName = parsed.PayeeName, PayeeName = parsed.PayeeName,
+2 -2
View File
@@ -115,8 +115,8 @@ public class LoanService : ILoanService
var monthlyRate = l.InterestRate / 100 / 12; var monthlyRate = l.InterestRate / 100 / 12;
var extraMonthly = request.ExtraMonthlyPayment ?? 0; var extraMonthly = request.ExtraMonthlyPayment ?? 0;
var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0); var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0);
var paymentDate = DateTime.UtcNow.Date; var now = DateTime.UtcNow;
paymentDate = new DateTime(paymentDate.Year, paymentDate.Month, l.OriginationDate.Day); var paymentDate = new DateTime(now.Year, now.Month, l.OriginationDate.Day, 0, 0, 0, DateTimeKind.Utc);
var newEntries = new List<AmortizationEntryResponse>(); var newEntries = new List<AmortizationEntryResponse>();
int paymentNum = 0; int paymentNum = 0;
+15 -6
View File
@@ -23,15 +23,24 @@ public class ReportService : IReportService
query = query.Where(t => t.AccountId == accountId.Value); query = query.Where(t => t.AccountId == accountId.Value);
var groups = await query var groups = await query
.GroupBy(t => new { t.CategoryId, CategoryName = t.Category!.Name, ParentName = t.Category.Parent != null ? t.Category.Parent.Name : (string?)null }) .GroupBy(t => t.CategoryId)
.Select(g => new { g.Key.CategoryName, g.Key.ParentName, Amount = Math.Abs(g.Sum(t => t.Amount)) }) .Select(g => new { CategoryId = g.Key, Amount = -g.Sum(t => t.Amount) })
.OrderByDescending(g => g.Amount) .OrderByDescending(g => g.Amount)
.ToListAsync(); .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 total = groups.Sum(g => g.Amount);
var categories = groups.Select(g => new CategorySpending( var categories = groups.Select(g =>
g.CategoryName, g.Amount, total > 0 ? (g.Amount / total) * 100 : 0, g.ParentName {
)).ToList(); 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); return new SpendingByCategoryReport(startDate, endDate, total, categories);
} }
@@ -71,7 +80,7 @@ public class ReportService : IReportService
while (currentDate <= endDate) 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); var txnsToDate = transactions.Where(t => t.Date <= endOfMonth).Sum(t => t.Amount);
// Simplified: using current balances adjusted by future transactions // Simplified: using current balances adjusted by future transactions
@@ -141,7 +141,7 @@ public class ScheduledTransactionService : IScheduledTransactionService
Frequency.Daily => current.AddDays(1), Frequency.Daily => current.AddDays(1),
Frequency.Weekly => current.AddDays(7), Frequency.Weekly => current.AddDays(7),
Frequency.BiWeekly => current.AddDays(14), 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.Monthly => current.AddMonths(1),
Frequency.Quarterly => current.AddMonths(3), Frequency.Quarterly => current.AddMonths(3),
Frequency.SemiAnnually => current.AddMonths(6), Frequency.SemiAnnually => current.AddMonths(6),
@@ -47,7 +47,7 @@ public class TransactionService : ITransactionService
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
AccountId = request.AccountId, AccountId = request.AccountId,
Date = request.Date, Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc),
Amount = request.Amount, Amount = request.Amount,
PayeeName = request.PayeeName, PayeeName = request.PayeeName,
PayeeId = request.PayeeId, PayeeId = request.PayeeId,
@@ -89,7 +89,7 @@ public class TransactionService : ITransactionService
?? throw new KeyNotFoundException("Transaction not found"); ?? throw new KeyNotFoundException("Transaction not found");
var oldAmount = transaction.Amount; var oldAmount = transaction.Amount;
transaction.Date = request.Date; transaction.Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc);
transaction.Amount = request.Amount; transaction.Amount = request.Amount;
transaction.PayeeName = request.PayeeName; transaction.PayeeName = request.PayeeName;
transaction.PayeeId = request.PayeeId; transaction.PayeeId = request.PayeeId;
@@ -181,7 +181,7 @@ public class TransactionService : ITransactionService
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
AccountId = request.FromAccountId, AccountId = request.FromAccountId,
Date = request.Date, Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc),
Amount = -Math.Abs(request.Amount), Amount = -Math.Abs(request.Amount),
PayeeName = $"Transfer to {toAccount.Name}", PayeeName = $"Transfer to {toAccount.Name}",
Memo = request.Memo, Memo = request.Memo,
@@ -193,7 +193,7 @@ public class TransactionService : ITransactionService
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
AccountId = request.ToAccountId, AccountId = request.ToAccountId,
Date = request.Date, Date = DateTime.SpecifyKind(request.Date, DateTimeKind.Utc),
Amount = Math.Abs(request.Amount), Amount = Math.Abs(request.Amount),
PayeeName = $"Transfer from {fromAccount.Name}", PayeeName = $"Transfer from {fromAccount.Name}",
Memo = request.Memo, Memo = request.Memo,
+1 -1
View File
@@ -52,7 +52,7 @@ public class CSVParserPlugin : IFileParser
if (mapping.DateColumn >= 0) if (mapping.DateColumn >= 0)
{ {
var dateStr = csv.GetField(mapping.DateColumn); 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; transaction.Date = date;
} }
+2 -2
View File
@@ -226,9 +226,9 @@ public class OFXParserPlugin : IFileParser
var datePart = ofxDate.Substring(0, 8); var datePart = ofxDate.Substring(0, 8);
if (DateTime.TryParseExact(datePart, "yyyyMMdd", if (DateTime.TryParseExact(datePart, "yyyyMMdd",
System.Globalization.CultureInfo.InvariantCulture, 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 date;
} }
return DateTime.Parse(ofxDate); return DateTime.SpecifyKind(DateTime.Parse(ofxDate), DateTimeKind.Utc);
} }
} }
+2 -2
View File
@@ -47,7 +47,7 @@ public class QIFParserPlugin : IFileParser
case 'D': case 'D':
current ??= new ParsedTransaction(); current ??= new ParsedTransaction();
if (DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, 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; current.Date = date;
else if (TryParseQIFDate(value, out var qifDate)) else if (TryParseQIFDate(value, out var qifDate))
current.Date = qifDate; current.Date = qifDate;
@@ -105,6 +105,6 @@ public class QIFParserPlugin : IFileParser
return DateTime.TryParseExact(cleaned, formats, return DateTime.TryParseExact(cleaned, formats,
System.Globalization.CultureInfo.InvariantCulture, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out date); System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out date);
} }
} }