diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingDatePolicyTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingDatePolicyTests.cs new file mode 100644 index 0000000..1353396 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingDatePolicyTests.cs @@ -0,0 +1,52 @@ +using EstateReportingAPI.BusinessLogic; +using Shouldly; + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class ReportingDatePolicyTests +{ + [Fact] + public void Today_UsesEuropeLondonAtUtcMidnightBoundary() + { + ReportingDatePolicy policy = CreatePolicy(new DateTimeOffset(2026, 10, 25, 0, 30, 0, TimeSpan.Zero)); + + policy.Today.ShouldBe(new DateOnly(2026, 10, 25)); + } + + [Fact] + public void GetRange_UsesInclusiveStartAndExclusiveDayAfterEnd() + { + ReportingDatePolicy policy = CreatePolicy(new DateTimeOffset(2026, 9, 30, 12, 0, 0, TimeSpan.Zero)); + + ReportingDateRange range = policy.GetRange(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30)); + + range.StartInclusive.ShouldBe(new DateTime(2026, 9, 1)); + range.EndExclusive.ShouldBe(new DateTime(2026, 10, 1)); + } + + [Fact] + public void Today_UsesBritishSummerTimeWhenUtcDateDiffersFromLocalDate() + { + ReportingDatePolicy policy = CreatePolicy(new DateTimeOffset(2026, 6, 30, 23, 30, 0, TimeSpan.Zero)); + + policy.Today.ShouldBe(new DateOnly(2026, 7, 1)); + } + + [Fact] + public void ToDateTimeOffset_AppliesBritishSummerTimeOffset() + { + ReportingDatePolicy policy = CreatePolicy(new DateTimeOffset(2026, 6, 30, 12, 0, 0, TimeSpan.Zero)); + + policy.ToDateTimeOffset(new DateTime(2026, 6, 30, 13, 0, 0)).Offset.ShouldBe(TimeSpan.FromHours(1)); + } + + private static ReportingDatePolicy CreatePolicy(DateTimeOffset utcNow) + { + return new ReportingDatePolicy(new FixedTimeProvider(utcNow)); + } + + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } +} diff --git a/EstateReportingAPI.BusinessLogic/ReportingDatePolicy.cs b/EstateReportingAPI.BusinessLogic/ReportingDatePolicy.cs new file mode 100644 index 0000000..7824f5b --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/ReportingDatePolicy.cs @@ -0,0 +1,51 @@ +namespace EstateReportingAPI.BusinessLogic; + +public readonly record struct ReportingDateRange(DateTime StartInclusive, DateTime EndExclusive); + +public sealed class ReportingDatePolicy +{ + private const string IanaTimeZoneId = "Europe/London"; + private const string WindowsTimeZoneId = "GMT Standard Time"; + + private readonly TimeProvider Clock; + private readonly TimeZoneInfo TimeZone; + + public ReportingDatePolicy(TimeProvider clock) + { + Clock = clock ?? throw new ArgumentNullException(nameof(clock)); + TimeZone = ResolveTimeZone(); + } + + public DateOnly Today => DateOnly.FromDateTime(LocalNow); + + public DateTime LocalNow => TimeZoneInfo.ConvertTime(Clock.GetUtcNow(), TimeZone).DateTime; + + public DateTimeOffset ToDateTimeOffset(DateTime localDateTime) + { + DateTime unspecified = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified); + return new DateTimeOffset(unspecified, TimeZone.GetUtcOffset(unspecified)); + } + + public ReportingDateRange GetRange(DateOnly startDate, DateOnly endDate) + { + if (startDate > endDate) + throw new ArgumentException("The start date must be less than or equal to the end date.", nameof(startDate)); + + DateTime startInclusive = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified); + DateTime endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified); + + return new ReportingDateRange(startInclusive, endExclusive); + } + + private static TimeZoneInfo ResolveTimeZone() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(IanaTimeZoneId); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.FindSystemTimeZoneById(WindowsTimeZoneId); + } + } +} diff --git a/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs index 35fe469..dbe6025 100644 --- a/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs +++ b/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs @@ -20,11 +20,13 @@ public interface IEstateReportingService public sealed class EstateReportingService : IEstateReportingService { private readonly IDbContextResolver Resolver; + private readonly ReportingDatePolicy DatePolicy; private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; - public EstateReportingService(IDbContextResolver resolver) + public EstateReportingService(IDbContextResolver resolver, ReportingDatePolicy? datePolicy = null) { Resolver = resolver; + DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System); } public async Task>> GetCalendarComparisonDates(CalendarQueries.GetComparisonDatesQuery request, @@ -32,11 +34,11 @@ public async Task>> GetCalendarComparisonDates(CalendarQue using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - DateTime today = DateTime.Today; + DateTime today = DatePolicy.Today.ToDateTime(TimeOnly.MinValue); DateTime startDate = today.AddYears(-1); DateTime endDate = today.AddDays(-1); // yesterday - var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date >= startDate && c.Date < endDate).OrderByDescending(c => c.Date), cancellationToken, "Error retrieving calendar comparison dates"); + var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date >= startDate && c.Date <= endDate).OrderByDescending(c => c.Date), cancellationToken, "Error retrieving calendar comparison dates"); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -71,7 +73,7 @@ public async Task>> GetCalendarDates(CalendarQueries.GetAl await using EstateManagementContext context = resolvedContext.Context; - var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date <= DateTime.Now.Date), cancellationToken, "Error retrieving calendar dates"); + var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date <= DatePolicy.Today.ToDateTime(TimeOnly.MinValue)), cancellationToken, "Error retrieving calendar dates"); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -102,7 +104,7 @@ public async Task>> GetCalendarYears(CalendarQueries.GetYears using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date <= DateTime.Now.Date).GroupBy(c => c.Year).Select(y => y.Key), cancellationToken, "Error retrieving calendar years"); + var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date <= DatePolicy.Today.ToDateTime(TimeOnly.MinValue)).GroupBy(c => c.Year).Select(y => y.Key), cancellationToken, "Error retrieving calendar years"); if (result.IsFailed) return ResultHelpers.CreateFailure(result); diff --git a/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs index b5adc47..cfaca40 100644 --- a/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs +++ b/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs @@ -33,7 +33,7 @@ from esu in esuJoin.DefaultIfEmpty() join m in context.Merchants on f.MerchantId equals m.MerchantId into mJoin from m in mJoin.DefaultIfEmpty() join fl in context.FileLines on f.FileId equals fl.FileId - where fil.ImportLogDate >= request.StartDate && fil.ImportLogDate <= request.EndDate + where fil.ImportLogDate >= request.StartDate.Date && fil.ImportLogDate < request.EndDate.Date.AddDays(1) && (request.MerchantId == null || f.MerchantId == request.MerchantId) select new FileImportFlatData { FileImportLogId = fil.FileImportLogId, diff --git a/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs index 1195049..26e75f9 100644 --- a/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs +++ b/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs @@ -42,11 +42,13 @@ public interface IMerchantReportingService public sealed class MerchantReportingService : IMerchantReportingService { private readonly IDbContextResolver Resolver; + private readonly ReportingDatePolicy DatePolicy; private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; - public MerchantReportingService(IDbContextResolver resolver) + public MerchantReportingService(IDbContextResolver resolver, ReportingDatePolicy? datePolicy = null) { Resolver = resolver; + DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System); } private static async Task> ExecuteQuerySafeSum(IQueryable query, @@ -367,7 +369,7 @@ public async Task> GetMerchantsTransactionKpis(MerchantQueri var merchants = merchantsQueryResult.Data; - DateTime now = DateTime.Now; + DateTime now = DatePolicy.LocalNow; Int32 merchantsWithSaleInLastHour = (from m in merchants where m.LastSale >= now.AddHours(-1) && m.LastSale <= now select m).Count(); diff --git a/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs index 62b06c1..498620e 100644 --- a/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs +++ b/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs @@ -15,15 +15,20 @@ public interface ISettlementReportingService public sealed class SettlementReportingService : ISettlementReportingService { private readonly IDbContextResolver Resolver; + private readonly ReportingDatePolicy DatePolicy; private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; - public SettlementReportingService(IDbContextResolver resolver) => Resolver = resolver; + public SettlementReportingService(IDbContextResolver resolver, ReportingDatePolicy? datePolicy = null) + { + Resolver = resolver; + DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System); + } public async Task> GetTodaysSettlement(SettlementQueries.TodaysSettlementQuery request, CancellationToken cancellationToken) { using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - IQueryable todaySettlementData = this.BuildTodaySettlementQuery(context, DateTime.Now); + IQueryable todaySettlementData = this.BuildTodaySettlementQuery(context, DatePolicy.Today.ToDateTime(TimeOnly.MinValue)); IQueryable comparisonSettlementData = this.BuildComparisonSettlementQuery(context, request.ComparisonDate); DatabaseProjections.SettlementGroupProjection todaySettlement = await this.GetSettlementSummary(todaySettlementData, cancellationToken); @@ -92,4 +97,4 @@ public async Task> GetTodaysSettlement(SettlementQuerie IQueryable settlementData = from s in context.Settlements join f in context.MerchantSettlementFees on s.SettlementId equals f.SettlementId join t in context.TransactionHistory on f.TransactionId equals t.TransactionId where s.SettlementDate == settlementDate.Date select new DatabaseProjections.ComparisonSettlementTransactionProjection { Fee = f, Txn = t }; return settlementData; } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs index 864f130..d59db9d 100644 --- a/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs +++ b/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs @@ -32,11 +32,13 @@ public interface ITransactionReportingService public sealed class TransactionReportingService : ITransactionReportingService { private readonly IDbContextResolver Resolver; + private readonly ReportingDatePolicy DatePolicy; private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; - public TransactionReportingService(IDbContextResolver resolver) + public TransactionReportingService(IDbContextResolver resolver, ReportingDatePolicy? datePolicy = null) { Resolver = resolver; + DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System); } public async Task> GetTodaysFailedSales(TransactionQueries.TodaysFailedSales request, @@ -44,8 +46,9 @@ public async Task> GetTodaysFailedSales(TransactionQueries.T using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - IQueryable todaysSalesQuery = this.BuildTodaysFailedSalesQuery(context, request.ResponseCode); - IQueryable comparisonSalesQuery = this.BuildComparisonFailedSalesQuery(context, request.ComparisonDate, request.ResponseCode); + DateTime now = DatePolicy.LocalNow; + IQueryable todaysSalesQuery = this.BuildTodaysFailedSalesQuery(context, request.ResponseCode, now); + IQueryable comparisonSalesQuery = this.BuildComparisonFailedSalesQuery(context, request.ComparisonDate, request.ResponseCode, now); var todaysSalesQueryResult = await ReportingQueryExecutor.ToListAsync(todaysSalesQuery, cancellationToken, "Error retrieving todays failed sales"); if (todaysSalesQueryResult.IsFailed) @@ -72,8 +75,9 @@ public async Task> GetTodaysSales(TransactionQueries.TodaysS using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - IQueryable todaysSales = this.BuildTodaySalesQuery(context); - IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.ComparisonDate); + DateTime now = DatePolicy.LocalNow; + IQueryable todaysSales = this.BuildTodaySalesQuery(context, now); + IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.ComparisonDate, now); todaysSales = todaysSales.ApplyMerchantFilter(request.MerchantReportingId).ApplyOperatorFilter(request.OperatorReportingId); comparisonSales = comparisonSales.ApplyMerchantFilter(request.MerchantReportingId).ApplyOperatorFilter(request.OperatorReportingId); @@ -145,7 +149,7 @@ from msf in msfJoin.DefaultIfEmpty() // left join Settlements (msf may be null) join s in context.Settlements on msf.SettlementId equals s.SettlementId into sJoin from s in sJoin.DefaultIfEmpty() - where t.TransactionType != "Logon" && t.TransactionDate >= request.StartDate && t.TransactionDate <= request.EndDate + where t.TransactionType != "Logon" && t.TransactionDate >= request.StartDate.Date && t.TransactionDate < request.EndDate.Date.AddDays(1) select new TransactionDetailQueryResult { TransactionId = t.TransactionId, TransactionDateTime = t.TransactionDateTime, @@ -255,7 +259,7 @@ private static IQueryable BuildMerchantTrans return from t in context.Transactions join m in context.Merchants on t.MerchantId equals m.MerchantId join o in context.Operators on t.OperatorId equals o.OperatorId - where t.TransactionType == "Sale" && t.TransactionDate >= startDate && t.TransactionDate <= endDate + where t.TransactionType == "Sale" && t.TransactionDate >= startDate.Date && t.TransactionDate < endDate.Date.AddDays(1) group t by new { t.MerchantId, m.MerchantReportingId, MerchantName = m.Name, t.OperatorId, o.OperatorReportingId } into g select new MerchantTransactionGroupProjection { @@ -314,7 +318,7 @@ private static IQueryable BuildOperatorTransactionBaseQ var query = from t in context.Transactions join m in context.Merchants on t.MerchantId equals m.MerchantId join o in context.Operators on t.OperatorId equals o.OperatorId - where t.TransactionType == "Sale" && t.TransactionDate >= request.Request.StartDate && t.TransactionDate <= request.Request.EndDate + where t.TransactionType == "Sale" && t.TransactionDate >= request.Request.StartDate.Date && t.TransactionDate < request.Request.EndDate.Date.AddDays(1) group t by new { t.MerchantId, m.MerchantReportingId, @@ -395,7 +399,7 @@ public async Task> GetProductPerformanceRepor using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - var grandTotalAmountQuery = (from t in context.Transactions where t.TransactionType == "Sale" && t.TransactionDate >= request.StartDate && t.TransactionDate <= request.EndDate select t.TransactionAmount); + var grandTotalAmountQuery = (from t in context.Transactions where t.TransactionType == "Sale" && t.TransactionDate >= request.StartDate.Date && t.TransactionDate < request.EndDate.Date.AddDays(1) select t.TransactionAmount); var grandTotalAmountResult = await ReportingQueryExecutor.SumAsync(grandTotalAmountQuery, cancellationToken); if (grandTotalAmountResult.IsFailed) return ResultHelpers.CreateFailure(grandTotalAmountResult); @@ -448,8 +452,8 @@ public async Task> GetTransactionMixSummar { return Result.Success(new TransactionMixSummaryResponse { - FromDate = request.Request.StartDate, - ToDate = request.Request.EndDate, + FromDate = DateOnly.FromDateTime(request.Request.StartDate), + ToDate = DateOnly.FromDateTime(request.Request.EndDate), Breakdown = request.Request.Breakdown, Measure = request.Request.Measure, TotalCount = 0, @@ -488,7 +492,7 @@ public async Task> GetRecentActiv private static RecentActivityReceiptRequestParameters BuildRecentActivityReceiptRequestParameters(GetRecentActivityReceiptReportRequest request) { return new RecentActivityReceiptRequestParameters { - ReportDate = request.ReportDate.Date, + ReportDate = DateOnly.FromDateTime(request.ReportDate.Date), MerchantReportingId = request.MerchantReportingId, SearchText = request.SearchText?.Trim(), PageNumber = request.PageNumber > 0 ? request.PageNumber : 1, @@ -502,7 +506,7 @@ private static IQueryable BuildRecentActivityR join m in context.Merchants on t.MerchantId equals m.MerchantId join o in context.Operators on t.OperatorId equals o.OperatorId join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - where t.TransactionDate == parameters.ReportDate + where t.TransactionDate == parameters.ReportDate.ToDateTime(TimeOnly.MinValue) select new RecentActivityReceiptQueryResult { TransactionDateTime = t.TransactionDateTime, MerchantReportingId = m.MerchantReportingId, @@ -577,7 +581,7 @@ private static IQueryable BuildProductPerformanceQue return from t in context.Transactions join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } join c in context.Contracts on t.ContractId equals c.ContractId - where t.TransactionType == "Sale" && t.TransactionDate >= startDate && t.TransactionDate <= endDate + where t.TransactionType == "Sale" && t.TransactionDate >= startDate.Date && t.TransactionDate < endDate.Date.AddDays(1) group t by new { cp.ProductName, cp.ContractProductId, cp.ContractProductReportingId, c.ContractId, c.ContractReportingId } into g select new ProductPerformanceItemData { @@ -649,8 +653,8 @@ private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse( return new TransactionMixSummaryResponse { - FromDate = request.StartDate, - ToDate = request.EndDate, + FromDate = DateOnly.FromDateTime(request.StartDate), + ToDate = DateOnly.FromDateTime(request.EndDate), Breakdown = request.Breakdown, Measure = request.Measure, TotalCount = queryResults.Count, @@ -687,8 +691,9 @@ public async Task>> GetTodaysSalesByHour(Transact using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - IQueryable todaysSales = this.BuildTodaySalesQuery(context); - IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.comparisonDate); + DateTime now = DatePolicy.LocalNow; + IQueryable todaysSales = this.BuildTodaySalesQuery(context, now); + IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.comparisonDate, now); // First we need to get a value of todays sales var todaysSalesByHourQuery = (from t in todaysSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }); @@ -758,27 +763,30 @@ public async Task> GetMerchantDa }); } - private IQueryable BuildTodaySalesQuery(EstateManagementContext context) { - return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay select t; + private IQueryable BuildTodaySalesQuery(EstateManagementContext context, DateTime now) { + return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == now.Date && t.TransactionTime <= now.TimeOfDay select t; } private IQueryable BuildTodaysFailedSalesQuery(EstateManagementContext context, - String responseCode) { + String responseCode, + DateTime now) { return from t in context.TodayTransactions - where t.IsAuthorised == false && t.TransactionType == "Sale" && t.ResponseCode == responseCode + where t.IsAuthorised == false && t.TransactionType == "Sale" && t.TransactionDate == now.Date && t.TransactionTime <= now.TimeOfDay && t.ResponseCode == responseCode select t.TransactionAmount; } private IQueryable BuildComparisonSalesQuery(EstateManagementContext context, - DateTime comparisonDate) { - return from t in context.TransactionHistory where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay select t; + DateTime comparisonDate, + DateTime now) { + return from t in context.TransactionHistory where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate.Date && t.TransactionTime <= now.TimeOfDay select t; } private IQueryable BuildComparisonFailedSalesQuery(EstateManagementContext context, DateTime comparisonDate, - String responseCode) { + String responseCode, + DateTime now) { return from t in context.TransactionHistory - where t.IsAuthorised == false && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay && t.ResponseCode == responseCode + where t.IsAuthorised == false && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate.Date && t.TransactionTime <= now.TimeOfDay && t.ResponseCode == responseCode select t.TransactionAmount; } @@ -914,7 +922,7 @@ join op in context.Operators on t.OperatorId equals op.OperatorId into opJoin from op in opJoin.DefaultIfEmpty() where t.TransactionType == "Sale" && t.TransactionDate >= startDate - && t.TransactionDate <= endDate + && t.TransactionDate < endDate.Date.AddDays(1) && m.MerchantReportingId == request.Request.MerchantReportingId group t by new { @@ -950,7 +958,7 @@ join op in context.Operators on t.OperatorId equals op.OperatorId into opJoin from op in opJoin.DefaultIfEmpty() where t.TransactionType == "Sale" && t.TransactionDate >= startDate - && t.TransactionDate <= endDate + && t.TransactionDate < endDate.Date.AddDays(1) && m.MerchantReportingId == request.Request.MerchantReportingId orderby t.TransactionDateTime descending select new MerchantDailyPerformanceRecentSaleProjection @@ -990,7 +998,7 @@ private sealed class RecentActivityReceiptQueryResult { } private sealed class RecentActivityReceiptRequestParameters { - public DateTime ReportDate { get; init; } + public DateOnly ReportDate { get; init; } public int? MerchantReportingId { get; init; } public string? SearchText { get; init; } public int PageNumber { get; init; } diff --git a/EstateReportingAPI.DataTrasferObjects/ComparisonDate.cs b/EstateReportingAPI.DataTrasferObjects/ComparisonDate.cs index 3de80f1..e0de881 100644 --- a/EstateReportingAPI.DataTrasferObjects/ComparisonDate.cs +++ b/EstateReportingAPI.DataTrasferObjects/ComparisonDate.cs @@ -3,7 +3,7 @@ public class ComparisonDate{ public Int32 OrderValue{ get; set; } - public DateTime Date { get; set; } + public DateOnly Date { get; set; } public String Description { get; set; } } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.DataTrasferObjects/FileDetails.cs b/EstateReportingAPI.DataTrasferObjects/FileDetails.cs index 364e637..551735e 100644 --- a/EstateReportingAPI.DataTrasferObjects/FileDetails.cs +++ b/EstateReportingAPI.DataTrasferObjects/FileDetails.cs @@ -8,10 +8,10 @@ public class FileDetails public Guid FileId { get; set; } public string FileName { get; set; } public string FileProfile { get; set; } - public DateTime DateTimeUploaded { get; set; } + public DateTimeOffset DateTimeUploaded { get; set; } public Guid UserId { get; set; } public string UploadedBy { get; set; } public Guid MerchantId { get; set; } public string MerchantName { get; set; } public List FileLines { get; set; } = new List(); -} \ No newline at end of file +} diff --git a/EstateReportingAPI.DataTrasferObjects/FileImportLog.cs b/EstateReportingAPI.DataTrasferObjects/FileImportLog.cs index a4eb3c0..dceb309 100644 --- a/EstateReportingAPI.DataTrasferObjects/FileImportLog.cs +++ b/EstateReportingAPI.DataTrasferObjects/FileImportLog.cs @@ -6,6 +6,6 @@ namespace EstateReportingAPI.DataTransferObjects; public class FileImportLog { public Guid FileImportLogId { get; set; } - public DateTime ImportLogDateTime { get; set; } + public DateTimeOffset ImportLogDateTime { get; set; } public List FileDetailsList { get; set; } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs index b2ee95b..cca821c 100644 --- a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs @@ -4,7 +4,7 @@ namespace EstateReportingAPI.DataTransferObjects; public class GetRecentActivityReceiptReportRequest { - public DateTime ReportDate { get; set; } + public DateOnly ReportDate { get; set; } public int? MerchantReportingId { get; set; } public string? SearchText { get; set; } public int PageNumber { get; set; } = 1; diff --git a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs index 48ededc..87678c7 100644 --- a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs @@ -5,7 +5,7 @@ namespace EstateReportingAPI.DataTransferObjects; public class GetRecentActivityReceiptReportResponse { - public DateTime ReportDate { get; set; } + public DateOnly ReportDate { get; set; } public int PageNumber { get; set; } public int PageSize { get; set; } public int TotalCount { get; set; } @@ -20,6 +20,6 @@ public class RecentActivityReceiptItemDto public string? Operator { get; set; } public string? Status { get; set; } public decimal Amount { get; set; } - public DateTime TransactionDateTime { get; set; } + public DateTimeOffset TransactionDateTime { get; set; } public string? ReceiptReference { get; set; } } diff --git a/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs b/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs index 2cbdbab..c5ab6d0 100644 --- a/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/MerchantDailyPerformanceSummaryRequest.cs @@ -7,8 +7,8 @@ namespace EstateReportingAPI.DataTransferObjects public class MerchantDailyPerformanceSummaryRequest { public Int32 MerchantReportingId { get; set; } - public DateTime StartDate { get; set; } - public DateTime EndDate { get; set; } + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } } public sealed class MerchantDailyPerformanceSummaryResponse @@ -40,6 +40,6 @@ public sealed class DrillDownTransaction public Decimal Amount { get; set; } - public DateTime TransactionDateTime { get; set; } + public DateTimeOffset TransactionDateTime { get; set; } } } diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionDetail.cs b/EstateReportingAPI.DataTrasferObjects/TransactionDetail.cs index 29fcb97..1ba3c02 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionDetail.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionDetail.cs @@ -5,7 +5,7 @@ namespace EstateReportingAPI.DataTransferObjects; public class TransactionDetail { public Guid Id { get; set; } - public DateTime DateTime { get; set; } + public DateTimeOffset DateTime { get; set; } public String Merchant { get; set; } public Guid MerchantId { get; set; } public Int32 MerchantReportingId { get; set; } @@ -21,4 +21,4 @@ public class TransactionDetail public Decimal TotalFees { get; set; } public String SettlementReference { get; set; } public Int32 TransactionNumber { get; set; } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs index 82a76a4..f768f91 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs @@ -8,6 +8,6 @@ public class TransactionDetailReportRequest public List? Operators { get; set; } public List? Merchants { get; set; } public List? Products { get; set; } - public DateTime StartDate { get; set; } - public DateTime EndDate { get; set; } -} \ No newline at end of file + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs index f89a212..3c2e3f9 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs @@ -22,8 +22,8 @@ public enum TransactionMixMeasure public class TransactionMixSummaryRequest { public int? MerchantReportingId { get; set; } - public DateTime StartDate { get; set; } - public DateTime EndDate { get; set; } + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } public TransactionMixBreakdown Breakdown { get; set; } public TransactionMixMeasure Measure { get; set; } public int TopN { get; set; } = 5; diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs index 2109cc4..9fdb02a 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs @@ -5,8 +5,8 @@ namespace EstateReportingAPI.DataTransferObjects; public class TransactionMixSummaryResponse { - public DateTime FromDate { get; set; } - public DateTime ToDate { get; set; } + public DateOnly FromDate { get; set; } + public DateOnly ToDate { get; set; } public TransactionMixBreakdown Breakdown { get; set; } public TransactionMixMeasure Measure { get; set; } public int TotalCount { get; set; } @@ -26,7 +26,7 @@ public class TransactionMixSummaryGroup public class TransactionMixSummaryTransaction { public Guid Id { get; set; } - public DateTime DateTime { get; set; } + public DateTimeOffset DateTime { get; set; } public string? Merchant { get; set; } public Guid MerchantId { get; set; } public int MerchantReportingId { get; set; } diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs index b4183b6..dae9ed3 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs @@ -7,6 +7,6 @@ public class TransactionSummaryByMerchantRequest { public List? Operators { get; set; } public List? Merchants { get; set; } - public DateTime StartDate { get; set; } - public DateTime EndDate { get; set; } -} \ No newline at end of file + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs index 7cf4b1b..4852088 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs @@ -6,6 +6,6 @@ public class TransactionSummaryByOperatorRequest { public List? Operators { get; set; } public List? Merchants { get; set; } - public DateTime StartDate { get; set; } - public DateTime EndDate { get; set; } -} \ No newline at end of file + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } +} diff --git a/EstateReportingAPI.IntegrationTests/CalendarEndpointTests.cs b/EstateReportingAPI.IntegrationTests/CalendarEndpointTests.cs index 0ff753f..566c9e8 100644 --- a/EstateReportingAPI.IntegrationTests/CalendarEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/CalendarEndpointTests.cs @@ -37,7 +37,7 @@ public async Task CalendarEndpoint_GetComparisonDates_DatesReturned() { List expectedDates = datesInYear.Where(d => d <= DateTime.Now.Date.AddDays(-1)).ToList(); dates.ShouldNotBeNull(); foreach (DateTime date in expectedDates) { - dates.Select(d => d.Date).Contains(date.Date).ShouldBeTrue(); + dates.Select(d => d.Date).Contains(DateOnly.FromDateTime(date.Date)).ShouldBeTrue(); } dates.Select(d => d.Description).Contains("Yesterday").ShouldBeTrue(); diff --git a/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs b/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs index b901d50..4a6261c 100644 --- a/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs +++ b/EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs @@ -36,6 +36,30 @@ protected static void AssertDecimalMatchesTo4Places(decimal actual, decimal expe .ShouldBe(Math.Round(expected, 4, MidpointRounding.AwayFromZero)); } + protected static DateTime GetUkLocalNow() + { + return TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, GetReportingTimeZone()).DateTime; + } + + protected static DateTimeOffset ToUkDateTimeOffset(DateTime localDateTime) + { + DateTime unspecified = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified); + TimeZoneInfo timeZone = GetReportingTimeZone(); + return new DateTimeOffset(unspecified, timeZone.GetUtcOffset(unspecified)); + } + + private static TimeZoneInfo GetReportingTimeZone() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById("Europe/London"); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.FindSystemTimeZoneById("GMT Standard Time"); + } + } + public virtual async ValueTask InitializeAsync() { IntegrationTestEnvironment environment = await IntegrationTestEnvironment.GetAsync(); diff --git a/EstateReportingAPI.IntegrationTests/FileImportLogsEndpointTests.cs b/EstateReportingAPI.IntegrationTests/FileImportLogsEndpointTests.cs index b0e83db..26331bb 100644 --- a/EstateReportingAPI.IntegrationTests/FileImportLogsEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/FileImportLogsEndpointTests.cs @@ -45,7 +45,7 @@ private void AssertFileDetailsMatchesSource(FileDetails actual, actual.FileId.ShouldBe(fileId); actual.FileName.ShouldBe(fileName); actual.FileProfile.ShouldBe(fileProfileId.ToString()); - actual.DateTimeUploaded.ShouldBe(uploadedAt, TimeSpan.FromSeconds(1)); + actual.DateTimeUploaded.ShouldBe(ToUkDateTimeOffset(uploadedAt), TimeSpan.FromSeconds(1)); actual.UserId.ShouldBe(userId); actual.UploadedBy.ShouldBe(uploadedBy); actual.MerchantId.ShouldBe(merchantId); @@ -98,7 +98,7 @@ public async Task FileImportEndpoint_GetFileImportLogs_WithMerchantFilter_Return var match = list.Single(); match.FileImportLogId.ShouldBe(sourceLog.FileImportLogId); - match.ImportLogDateTime.ShouldBe(sourceLog.ImportLogDateTime, TimeSpan.FromSeconds(1)); + match.ImportLogDateTime.ShouldBe(ToUkDateTimeOffset(sourceLog.ImportLogDateTime), TimeSpan.FromSeconds(1)); match.FileDetailsList.Count.ShouldBe(1); var fileDetail = match.FileDetailsList.Single(); @@ -146,7 +146,7 @@ public async Task FileImportEndpoint_GetFileImportLog_WithMerchantFilter_Returns var sourceLine = this.context.FileLines.Single(x => x.FileId == fileId); item.FileImportLogId.ShouldBe(sourceLog.FileImportLogId); - item.ImportLogDateTime.ShouldBe(sourceLog.ImportLogDateTime, TimeSpan.FromSeconds(1)); + item.ImportLogDateTime.ShouldBe(ToUkDateTimeOffset(sourceLog.ImportLogDateTime), TimeSpan.FromSeconds(1)); item.FileDetailsList.Count.ShouldBe(1); var fileDetail = item.FileDetailsList.Single(); @@ -192,7 +192,7 @@ public async Task FileImportEndpoint_GetFileImportLog_ReturnsInsertedData() var sourceLine = this.context.FileLines.Single(x => x.FileId == fileId); item.FileImportLogId.ShouldBe(sourceLog.FileImportLogId); - item.ImportLogDateTime.ShouldBe(sourceLog.ImportLogDateTime, TimeSpan.FromSeconds(1)); + item.ImportLogDateTime.ShouldBe(ToUkDateTimeOffset(sourceLog.ImportLogDateTime), TimeSpan.FromSeconds(1)); item.FileDetailsList.Count.ShouldBe(1); var fileDetail = item.FileDetailsList.Single(); @@ -277,7 +277,7 @@ public async Task FileImportEndpoint_GetFileImportLogs_ReturnsInsertedData() var match = list.Single(); match.FileImportLogId.ShouldBe(sourceLog.FileImportLogId); - match.ImportLogDateTime.ShouldBe(sourceLog.ImportLogDateTime, TimeSpan.FromSeconds(1)); + match.ImportLogDateTime.ShouldBe(ToUkDateTimeOffset(sourceLog.ImportLogDateTime), TimeSpan.FromSeconds(1)); match.FileDetailsList.Count.ShouldBe(1); var fileDetail = match.FileDetailsList.Single(); diff --git a/EstateReportingAPI.IntegrationTests/MerchantEndpointTests.cs b/EstateReportingAPI.IntegrationTests/MerchantEndpointTests.cs index c2d9949..83162aa 100644 --- a/EstateReportingAPI.IntegrationTests/MerchantEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/MerchantEndpointTests.cs @@ -283,7 +283,7 @@ public async Task MerchantEndpoint_GetMerchantSchedule_MerchantScheduleReturned( [Fact] public async Task MerchantEndpoint_GetMerchantKpis_MerchantKpisReturned() { - DateTime now = DateTime.Now; + DateTime now = GetUkLocalNow(); await this.helper.AddEstate("Test Estate", "Ref1"); diff --git a/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs index 18c777e..10616cb 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs @@ -31,7 +31,7 @@ private void AssertTransactionMixSummaryTransactionMatches(TransactionMixSummary var product = this.context.ContractProducts.Single(p => p.ContractProductId == source.ContractProductId && p.ContractId == source.ContractId); actual.Id.ShouldBe(source.TransactionId); - actual.DateTime.ShouldBe(source.TransactionDateTime); + actual.DateTime.ShouldBe(ToUkDateTimeOffset(source.TransactionDateTime)); actual.Merchant.ShouldBe(merchant.Name); actual.MerchantId.ShouldBe(merchant.MerchantId); actual.MerchantReportingId.ShouldBe(merchant.MerchantReportingId); @@ -101,8 +101,8 @@ public async Task TransactionMixSummary_ProductBreakdown_ReturnsGroupedResults() var request = new TransactionMixSummaryRequest { MerchantReportingId = 1, - StartDate = startDate, - EndDate = endDate, + StartDate = DateOnly.FromDateTime(startDate), + EndDate = DateOnly.FromDateTime(endDate), Breakdown = TransactionMixBreakdown.Product, Measure = TransactionMixMeasure.Count, TopN = 5 @@ -113,8 +113,8 @@ public async Task TransactionMixSummary_ProductBreakdown_ReturnsGroupedResults() result.IsSuccess.ShouldBeTrue(); result.Data.ShouldNotBeNull(); - result.Data.FromDate.ShouldBe(startDate); - result.Data.ToDate.ShouldBe(endDate); + result.Data.FromDate.ShouldBe(DateOnly.FromDateTime(startDate)); + result.Data.ToDate.ShouldBe(DateOnly.FromDateTime(endDate)); result.Data.Breakdown.ShouldBe(TransactionMixBreakdown.Product); result.Data.Measure.ShouldBe(TransactionMixMeasure.Count); result.Data.TotalCount.ShouldBe(3); @@ -138,8 +138,8 @@ public async Task TransactionMixSummary_InvalidDateRange_ReturnsBadRequest() var request = new TransactionMixSummaryRequest { MerchantReportingId = 1, - StartDate = DateTime.Now.Date, - EndDate = DateTime.Now.Date.AddDays(-1), + StartDate = DateOnly.FromDateTime(DateTime.Now.Date), + EndDate = DateOnly.FromDateTime(DateTime.Now.Date.AddDays(-1)), Breakdown = TransactionMixBreakdown.Operator, Measure = TransactionMixMeasure.Value, TopN = 5 diff --git a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs index 9a4452c..7346d13 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs @@ -90,7 +90,7 @@ private static void AssertDrillDownTransactionMatches(DrillDownTransaction actua actual.Product.ShouldBe(expectedProduct); actual.Status.ShouldBe(source.IsAuthorised ? "Successful" : "Failed"); actual.Amount.ShouldBe(source.TransactionAmount); - actual.TransactionDateTime.ShouldBe(source.TransactionDateTime); + actual.TransactionDateTime.ShouldBe(ToUkDateTimeOffset(source.TransactionDateTime)); } private static void AssertRecentActivityReceiptItemMatches(JsonElement actual, @@ -104,7 +104,7 @@ private static void AssertRecentActivityReceiptItemMatches(JsonElement actual, GetPropertyCaseInsensitive(actual, "operator").GetString().ShouldBe(expectedOperator); GetPropertyCaseInsensitive(actual, "status").GetString().ShouldBe(source.IsAuthorised ? "Successful" : "Failed"); GetPropertyCaseInsensitive(actual, "amount").GetDecimal().ShouldBe(source.TransactionAmount); - DateTime.Parse(GetPropertyCaseInsensitive(actual, "transactionDateTime").GetString()!).ShouldBe(source.TransactionDateTime); + DateTimeOffset.Parse(GetPropertyCaseInsensitive(actual, "transactionDateTime").GetString()!).ShouldBe(ToUkDateTimeOffset(source.TransactionDateTime)); GetPropertyCaseInsensitive(actual, "receiptReference").GetString().ShouldBe(expectedReceiptReference); } @@ -114,7 +114,7 @@ private void AssertTransactionDetailMatches(TransactionDetail actual, Transactio var product = this.context.ContractProducts.Single(p => p.ContractProductId == source.ContractProductId && p.ContractId == source.ContractId); actual.Id.ShouldBe(source.TransactionId); - actual.DateTime.ShouldBe(source.TransactionDateTime); + actual.DateTime.ShouldBe(ToUkDateTimeOffset(source.TransactionDateTime)); actual.Merchant.ShouldBe(merchant.Name); actual.MerchantId.ShouldBe(merchant.MerchantId); actual.MerchantReportingId.ShouldBe(merchant.MerchantReportingId); @@ -515,8 +515,8 @@ public async Task TransactionsEndpoint_TransactionDetailReport_NoFilters_Transac List orderedDates = transactionDates.OrderBy(x => x).ToList(); TransactionDetailReportRequest request = new TransactionDetailReportRequest { - StartDate = orderedDates.First(), - EndDate = orderedDates.Last(), + StartDate = DateOnly.FromDateTime(orderedDates.First()), + EndDate = DateOnly.FromDateTime(orderedDates.Last()), Merchants = [], Operators = [], Products = [] @@ -586,8 +586,8 @@ public async Task TransactionsEndpoint_TransactionDetailReport_MerchantFilter_Tr List orderedDates = transactionDates.OrderBy(x => x).ToList(); TransactionDetailReportRequest request = new TransactionDetailReportRequest { - StartDate = orderedDates.First(), - EndDate = orderedDates.Last(), + StartDate = DateOnly.FromDateTime(orderedDates.First()), + EndDate = DateOnly.FromDateTime(orderedDates.Last()), Merchants = merchantsForFilter.Select(m=> m.MerchantReportingId).ToList(), Operators = [], Products = [] @@ -660,8 +660,8 @@ public async Task TransactionsEndpoint_TransactionDetailReport_OperatorFilter_Tr List orderedDates = transactionDates.OrderBy(x => x).ToList(); TransactionDetailReportRequest request = new TransactionDetailReportRequest { - StartDate = orderedDates.First(), - EndDate = orderedDates.Last(), + StartDate = DateOnly.FromDateTime(orderedDates.First()), + EndDate = DateOnly.FromDateTime(orderedDates.Last()), Merchants = [], Operators = operatorsForFilter.Select(o=> o.OperatorReportingId).ToList(), Products = [] @@ -733,8 +733,8 @@ public async Task TransactionsEndpoint_TransactionDetailReport_ProductFilter_Tra List orderedDates = transactionDates.OrderBy(x => x).ToList(); TransactionDetailReportRequest request = new TransactionDetailReportRequest { - StartDate = orderedDates.First(), - EndDate = orderedDates.Last(), + StartDate = DateOnly.FromDateTime(orderedDates.First()), + EndDate = DateOnly.FromDateTime(orderedDates.Last()), Merchants = [], Operators = [], Products = productsForFilter.Select(c => c.contractProductReportingId).ToList(), @@ -856,8 +856,8 @@ public async Task TransactionsEndpoint_TransactionSummaryByMerchantReport_NoFilt List orderedDates = transactionDates.OrderBy(x => x).ToList(); TransactionDetailReportRequest request = new TransactionDetailReportRequest { - StartDate = orderedDates.First(), - EndDate = orderedDates.Last(), + StartDate = DateOnly.FromDateTime(orderedDates.First()), + EndDate = DateOnly.FromDateTime(orderedDates.Last()), Merchants = [], Operators = [], Products = [] @@ -994,8 +994,8 @@ public async Task TransactionsEndpoint_TransactionSummaryByOperatorReport_NoFilt List orderedDates = transactionDates.OrderBy(x => x).ToList(); TransactionDetailReportRequest request = new TransactionDetailReportRequest { - StartDate = orderedDates.First(), - EndDate = orderedDates.Last(), + StartDate = DateOnly.FromDateTime(orderedDates.First()), + EndDate = DateOnly.FromDateTime(orderedDates.Last()), Merchants = [], Operators = [], Products = [] @@ -1300,8 +1300,8 @@ public async Task TransactionsEndpoint_MerchantDailyPerformanceSummary_ReturnsMe MerchantDailyPerformanceSummaryRequest request = new() { MerchantReportingId = merchant.MerchantReportingId, - StartDate = transactionDate, - EndDate = transactionDate + StartDate = DateOnly.FromDateTime(transactionDate), + EndDate = DateOnly.FromDateTime(transactionDate) }; var result = await this.CreateAndSendHttpRequestMessage( @@ -1435,8 +1435,8 @@ await this.helper.BuildTransactionX( MerchantDailyPerformanceSummaryRequest request = new() { MerchantReportingId = merchant.MerchantReportingId, - StartDate = transactionDate, - EndDate = transactionDate + StartDate = DateOnly.FromDateTime(transactionDate), + EndDate = DateOnly.FromDateTime(transactionDate) }; var result = await this.CreateAndSendHttpRequestMessage( @@ -1500,8 +1500,8 @@ await this.helper.BuildTransactionX( MerchantDailyPerformanceSummaryRequest request = new() { MerchantReportingId = merchant.MerchantReportingId, - StartDate = transactionDate, - EndDate = transactionDate + StartDate = DateOnly.FromDateTime(transactionDate), + EndDate = DateOnly.FromDateTime(transactionDate) }; var result = await this.CreateAndSendHttpRequestMessage( @@ -1530,8 +1530,8 @@ public async Task TransactionsEndpoint_MerchantDailyPerformanceSummary_NoSales_R MerchantDailyPerformanceSummaryRequest request = new() { - StartDate = startDate, - EndDate = endDate, + StartDate = DateOnly.FromDateTime(startDate), + EndDate = DateOnly.FromDateTime(endDate), MerchantReportingId = merchant.MerchantReportingId }; @@ -1629,7 +1629,7 @@ await this.helper.BuildTransactionX(reportDate.AddHours(9), otherMerchant.Mercha await this.helper.RunHistoricTransactionsSummaryProcessing(reportDate); var payload = JsonSerializer.Serialize(new Dictionary { - ["report_date"] = reportDate, + ["report_date"] = reportDate.ToString("yyyy-MM-dd"), ["merchant_reporting_id"] = merchant.MerchantReportingId, ["search_text"] = null, ["page_number"] = 1, @@ -1674,7 +1674,7 @@ static JsonElement GetPropertyCaseInsensitive(JsonElement element, string proper throw new KeyNotFoundException($"Property '{propertyName}' was not found."); } - DateTime.Parse(GetPropertyCaseInsensitive(document.RootElement, "reportDate").GetString()!).Date.ShouldBe(reportDate); + DateOnly.Parse(GetPropertyCaseInsensitive(document.RootElement, "reportDate").GetString()!).ShouldBe(DateOnly.FromDateTime(reportDate)); GetPropertyCaseInsensitive(document.RootElement, "pageNumber").GetInt32().ShouldBe(1); GetPropertyCaseInsensitive(document.RootElement, "pageSize").GetInt32().ShouldBe(10); GetPropertyCaseInsensitive(document.RootElement, "totalCount").GetInt32().ShouldBe(2); @@ -1708,7 +1708,7 @@ await this.helper.BuildTransactionX(reportDate.AddHours(9), otherMerchant.Mercha await this.helper.RunHistoricTransactionsSummaryProcessing(reportDate); var payload = JsonSerializer.Serialize(new Dictionary { - ["report_date"] = reportDate, + ["report_date"] = reportDate.ToString("yyyy-MM-dd"), ["merchant_reporting_id"] = merchant.MerchantReportingId, ["search_text"] = "receipt", ["page_number"] = 1, diff --git a/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs b/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs index b517b96..967000b 100644 --- a/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs +++ b/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs @@ -5,7 +5,7 @@ namespace EstateReportingAPI.Models; public class GetRecentActivityReceiptReportResponse { - public DateTime ReportDate { get; set; } + public DateOnly ReportDate { get; set; } public int PageNumber { get; set; } public int PageSize { get; set; } public int TotalCount { get; set; } diff --git a/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs b/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs index fbc6c91..e4a2c2b 100644 --- a/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs +++ b/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs @@ -2,8 +2,8 @@ namespace EstateReportingAPI.Models; public class TransactionMixSummaryResponse { - public DateTime FromDate { get; set; } - public DateTime ToDate { get; set; } + public DateOnly FromDate { get; set; } + public DateOnly ToDate { get; set; } public TransactionMixBreakdown Breakdown { get; set; } public TransactionMixMeasure Measure { get; set; } public int TotalCount { get; set; } diff --git a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs index 7932655..de064b6 100644 --- a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs +++ b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs @@ -16,6 +16,7 @@ namespace EstateReportingAPI.Bootstrapper; [ExcludeFromCodeCoverage] public class RepositoryRegistry : ServiceRegistry{ public RepositoryRegistry() { + this.AddSingleton(_ => new ReportingDatePolicy(TimeProvider.System)); this.AddSingleton(); this.AddSingleton(); this.AddSingleton(); diff --git a/EstateReportingAPI/Handlers/CalenderHandler.cs b/EstateReportingAPI/Handlers/CalenderHandler.cs index a66deee..6ae0c6d 100644 --- a/EstateReportingAPI/Handlers/CalenderHandler.cs +++ b/EstateReportingAPI/Handlers/CalenderHandler.cs @@ -1,4 +1,5 @@ -using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic; +using EstateReportingAPI.BusinessLogic.Queries; using EstateReportingAPI.Common; using EstateReportingAPI.DataTrasferObjects; using EstateReportingAPI.Models; @@ -9,22 +10,31 @@ namespace EstateReportingAPI.Handlers; -public static class CalenderHandler { +public static class CalenderHandler +{ public static async Task GetCalendarComparisonDates(IEstateContext estateContext, - IMediator mediator, - CancellationToken cancellationToken) { - CalendarQueries.GetComparisonDatesQuery query = new CalendarQueries.GetComparisonDatesQuery(estateContext.EstateId); + ReportingDatePolicy datePolicy, + IMediator mediator, + CancellationToken cancellationToken) + { + CalendarQueries.GetComparisonDatesQuery query = new(estateContext.EstateId); Result> result = await mediator.Send(query, cancellationToken); - return ResponseFactory.FromResult(result, r => { - List response = new() { new() { Date = DateTime.Now.Date.AddDays(-1), Description = "Yesterday", OrderValue = 0 }, new() { Date = DateTime.Now.Date.AddDays(-7), Description = "Last Week", OrderValue = 1 }, new() { Date = DateTime.Now.Date.AddMonths(-1), Description = "Last Month", OrderValue = 2 } }; + return ResponseFactory.FromResult(result, r => + { + DateTime today = datePolicy.Today.ToDateTime(TimeOnly.MinValue); + List response = new() + { + new() { Date = DateOnly.FromDateTime(today.AddDays(-1)), Description = "Yesterday", OrderValue = 0 }, + new() { Date = DateOnly.FromDateTime(today.AddDays(-7)), Description = "Last Week", OrderValue = 1 }, + new() { Date = DateOnly.FromDateTime(today.AddMonths(-1)), Description = "Last Month", OrderValue = 2 } + }; int orderValue = 3; - foreach (Calendar d in r) { - response.Add(new ComparisonDate { Date = d.Date, Description = d.Date.ToString("yyyy-MM-dd"), OrderValue = orderValue++ }); - } + foreach (Calendar d in r) + response.Add(new ComparisonDate { Date = DateOnly.FromDateTime(d.Date), Description = d.Date.ToString("yyyy-MM-dd"), OrderValue = orderValue++ }); return response.OrderBy(d => d.OrderValue); }); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI/Handlers/FileImportHandler.cs b/EstateReportingAPI/Handlers/FileImportHandler.cs index f61e09b..e0b9e01 100644 --- a/EstateReportingAPI/Handlers/FileImportHandler.cs +++ b/EstateReportingAPI/Handlers/FileImportHandler.cs @@ -34,20 +34,21 @@ public static class FileImportHandler { public static async Task GetFileImportLogList(IEstateContext estateContext, [FromQuery] Guid? merchantId, - [FromQuery] DateTime startDate, - [FromQuery] DateTime endDate, + [FromQuery] DateOnly startDate, + [FromQuery] DateOnly endDate, + BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { - FileImportLogQueries.GetFileImportLogListQuery query = new(estateContext.EstateId, merchantId, startDate, endDate); + FileImportLogQueries.GetFileImportLogListQuery query = new(estateContext.EstateId, merchantId, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue)); Result> result = await mediator.Send(query, cancellationToken); return ResponseFactory.FromResult(result, r => r.Select(m => new DataTransferObjects.FileImportLog { FileImportLogId = m.FileImportLogId, - ImportLogDateTime = m.ImportLogDateTime, + ImportLogDateTime = datePolicy.ToDateTimeOffset(m.ImportLogDateTime), FileDetailsList = m.FileDetailsList.Select(fd => new DataTransferObjects.FileDetails { - DateTimeUploaded = fd.DateTimeUploaded, + DateTimeUploaded = datePolicy.ToDateTimeOffset(fd.DateTimeUploaded), FileId = fd.FileId, FileName = fd.FileName, FileProfile = fd.FileProfile, @@ -67,6 +68,7 @@ public static async Task GetFileImportLogList(IEstateContext estateCont public static async Task GetFileImportLog(IEstateContext estateContext, [FromRoute] Guid fileImportLogId, [FromQuery] Guid? merchantId, + BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { @@ -76,10 +78,10 @@ public static async Task GetFileImportLog(IEstateContext estateContext, return ResponseFactory.FromResult(result, r => new DataTransferObjects.FileImportLog { FileImportLogId = r.FileImportLogId, - ImportLogDateTime = r.ImportLogDateTime, + ImportLogDateTime = datePolicy.ToDateTimeOffset(r.ImportLogDateTime), FileDetailsList = r.FileDetailsList.Select(fd => new DataTransferObjects.FileDetails { - DateTimeUploaded = fd.DateTimeUploaded, + DateTimeUploaded = datePolicy.ToDateTimeOffset(fd.DateTimeUploaded), FileId = fd.FileId, FileName = Path.GetFileName(fd.FileName), FileProfile = fd.FileProfile, diff --git a/EstateReportingAPI/Handlers/SettlementHandler.cs b/EstateReportingAPI/Handlers/SettlementHandler.cs index b25f87b..6ca9413 100644 --- a/EstateReportingAPI/Handlers/SettlementHandler.cs +++ b/EstateReportingAPI/Handlers/SettlementHandler.cs @@ -9,10 +9,10 @@ namespace EstateReportingAPI.Handlers; public static class SettlementHandler { public static async Task TodaysSettlements(IEstateContext estateContext, - [FromQuery] DateTime comparisonDate, + [FromQuery] DateOnly comparisonDate, IMediator mediator, CancellationToken cancellationToken) { - var query = new SettlementQueries.TodaysSettlementQuery(estateContext.EstateId, comparisonDate); + var query = new SettlementQueries.TodaysSettlementQuery(estateContext.EstateId, comparisonDate.ToDateTime(TimeOnly.MinValue)); var result = await mediator.Send(query, cancellationToken); return ResponseFactory.FromResult(result, r => new TodaysSettlement() { @@ -26,4 +26,4 @@ public static async Task TodaysSettlements(IEstateContext estateContext TodaysSettlementValue = r.TodaysSettlementValue }); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI/Handlers/TransactionHandler.cs b/EstateReportingAPI/Handlers/TransactionHandler.cs index 39f5215..bfeca40 100644 --- a/EstateReportingAPI/Handlers/TransactionHandler.cs +++ b/EstateReportingAPI/Handlers/TransactionHandler.cs @@ -14,11 +14,11 @@ public static class TransactionHandler { public static async Task TodaysSales(IEstateContext estateContext, [FromQuery] int? merchantReportingId, [FromQuery] int? operatorReportingId, - [FromQuery] DateTime comparisonDate, + [FromQuery] DateOnly comparisonDate, IMediator mediator, CancellationToken cancellationToken) { - var query = new TransactionQueries.TodaysSalesQuery(estateContext.EstateId, merchantReportingId.GetValueOrDefault(), operatorReportingId.GetValueOrDefault(), comparisonDate); + var query = new TransactionQueries.TodaysSalesQuery(estateContext.EstateId, merchantReportingId.GetValueOrDefault(), operatorReportingId.GetValueOrDefault(), comparisonDate.ToDateTime(TimeOnly.MinValue)); var result = await mediator.Send(query, cancellationToken); return ResponseFactory.FromResult(result, r => new TodaysSales @@ -33,12 +33,12 @@ public static async Task TodaysSales(IEstateContext estateContext, } public static async Task TodaysFailedSales(IEstateContext estateContext, - [FromQuery] DateTime comparisonDate, + [FromQuery] DateOnly comparisonDate, [FromQuery] string responseCode, IMediator mediator, CancellationToken cancellationToken) { - var query = new TransactionQueries.TodaysFailedSales(estateContext.EstateId, comparisonDate, responseCode); + var query = new TransactionQueries.TodaysFailedSales(estateContext.EstateId, comparisonDate.ToDateTime(TimeOnly.MinValue), responseCode); var result = await mediator.Send(query, cancellationToken); return ResponseFactory.FromResult(result, r => new TodaysSales @@ -54,13 +54,14 @@ public static async Task TodaysFailedSales(IEstateContext estateContext public static async Task TransactionDetailReport(IEstateContext estateContext, [FromBody] TransactionDetailReportRequest request, + BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { Models.TransactionDetailReportRequest queryRequest = new() { Merchants = request.Merchants, Operators = request.Operators, Products = request.Products, - StartDate = request.StartDate, - EndDate = request.EndDate + StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue) }; var query = new TransactionQueries.TransactionDetailReportQuery(estateContext.EstateId, queryRequest); @@ -71,7 +72,7 @@ TransactionDetailReportResponse SuccessFactory (Models.TransactionDetailReportRe Summary = new TransactionDetailSummary { TotalFees = r.Summary.TotalFees, TotalValue = r.Summary.TotalValue, TransactionCount = r.Summary.TransactionCount }, Transactions = r.Transactions.Select(t => new TransactionDetail { TotalFees = t.TotalFees, - DateTime = t.DateTime, + DateTime = datePolicy.ToDateTimeOffset(t.DateTime), Id = t.Id, Merchant = t.Merchant, Operator = t.Operator, @@ -101,8 +102,8 @@ public static async Task TransactionSummaryByMerchantReport(IEstateCont Models.TransactionSummaryByMerchantRequest queryRequest = new() { Merchants = request.Merchants, Operators = request.Operators, - StartDate = request.StartDate, - EndDate = request.EndDate + StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue) }; var query = new TransactionQueries.TransactionSummaryByMerchantQuery(estateContext.EstateId, queryRequest); var result = await mediator.Send(query, cancellationToken); @@ -126,11 +127,11 @@ TransactionSummaryByMerchantResponse SuccessFactory (Models.TransactionSummaryBy } public static async Task ProductPerformanceReport(IEstateContext estateContext, - [FromQuery] DateTime startDate, - [FromQuery] DateTime endDate, + [FromQuery] DateOnly startDate, + [FromQuery] DateOnly endDate, IMediator mediator, CancellationToken cancellationToken) { - var query = new TransactionQueries.ProductPerformanceQuery(estateContext.EstateId, startDate, endDate); + var query = new TransactionQueries.ProductPerformanceQuery(estateContext.EstateId, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue)); var result = await mediator.Send(query, cancellationToken); ProductPerformanceResponse SuccessFactory(Models.ProductPerformanceResponse r) => new ProductPerformanceResponse @@ -160,8 +161,8 @@ public static async Task TransactionSummaryByOperatorReport(IEstateCont { Merchants = request.Merchants, Operators = request.Operators, - StartDate = request.StartDate, - EndDate = request.EndDate + StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue) }; var query = new TransactionQueries.TransactionSummaryByOperatorQuery(estateContext.EstateId, queryRequest); var result = await mediator.Send(query, cancellationToken); @@ -188,6 +189,7 @@ TransactionSummaryByOperatorResponse SuccessFactory(Models.TransactionSummaryByO public static async Task TransactionMixSummary(IEstateContext estateContext, [FromBody] TransactionMixSummaryRequest request, + BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { @@ -197,8 +199,8 @@ public static async Task TransactionMixSummary(IEstateContext estateCon Models.TransactionMixSummaryRequest queryRequest = new() { MerchantReportingId = request.MerchantReportingId, - StartDate = request.StartDate, - EndDate = request.EndDate, + StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), Breakdown = (Models.TransactionMixBreakdown)request.Breakdown, Measure = (Models.TransactionMixMeasure)request.Measure, TopN = request.TopN @@ -226,7 +228,7 @@ TransactionMixSummaryResponse SuccessFactory(Models.TransactionMixSummaryRespons Transactions = r.Transactions.Select(t => new TransactionMixSummaryTransaction { Id = t.Id, - DateTime = t.DateTime, + DateTime = datePolicy.ToDateTimeOffset(t.DateTime), Merchant = t.Merchant, MerchantId = t.MerchantId, MerchantReportingId = t.MerchantReportingId, @@ -250,12 +252,13 @@ TransactionMixSummaryResponse SuccessFactory(Models.TransactionMixSummaryRespons public static async Task RecentActivityReceiptReport(IEstateContext estateContext, [FromBody] GetRecentActivityReceiptReportRequest request, + BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { Models.GetRecentActivityReceiptReportRequest queryRequest = new() { - ReportDate = request.ReportDate, + ReportDate = request.ReportDate.ToDateTime(TimeOnly.MinValue), MerchantReportingId = request.MerchantReportingId, SearchText = request.SearchText, PageNumber = request.PageNumber, @@ -280,7 +283,7 @@ GetRecentActivityReceiptReportResponse SuccessFactory(Models.GetRecentActivityRe Operator = item.Operator, Status = item.Status, Amount = item.Amount, - TransactionDateTime = item.TransactionDateTime, + TransactionDateTime = datePolicy.ToDateTimeOffset(item.TransactionDateTime), ReceiptReference = item.ReceiptReference }).ToList() }; @@ -289,11 +292,11 @@ GetRecentActivityReceiptReportResponse SuccessFactory(Models.GetRecentActivityRe } public static async Task TodaysSalesByHour(IEstateContext estateContext, - [FromQuery] DateTime comparisonDate, + [FromQuery] DateOnly comparisonDate, IMediator mediator, CancellationToken cancellationToken) { - var query = new TransactionQueries.TodaysSalesByHour(estateContext.EstateId, comparisonDate); + var query = new TransactionQueries.TodaysSalesByHour(estateContext.EstateId, comparisonDate.ToDateTime(TimeOnly.MinValue)); var result = await mediator.Send(query, cancellationToken); List SuccessFactory(List r) => @@ -312,12 +315,13 @@ public static async Task TodaysSalesByHour(IEstateContext estateContext public static async Task MerchantDailyPerformanceSummary(IEstateContext estateContext, [FromBody] MerchantDailyPerformanceSummaryRequest request, + BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { TransactionQueries.MerchantDailyPerformanceSummaryQuery query = new(estateContext.EstateId, new Models.MerchantDailyPerformanceSummaryRequest { - EndDate = request.EndDate, + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), MerchantReportingId = request.MerchantReportingId, - StartDate = request.StartDate + StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue) }); Result result = await mediator.Send(query, cancellationToken); @@ -338,7 +342,7 @@ MerchantDailyPerformanceSummaryResponse SuccessFactory(Models.MerchantDailyPerfo Product = $"{transaction.Operator} {transaction.Product}", Status = transaction.Status, Amount = transaction.Amount, - TransactionDateTime = transaction.TransactionDateTime + TransactionDateTime = datePolicy.ToDateTimeOffset(transaction.TransactionDateTime) }).ToList() ?? [] }; diff --git a/docs/superpowers/plans/2026-09-30-uk-report-date-time-policy.md b/docs/superpowers/plans/2026-09-30-uk-report-date-time-policy.md new file mode 100644 index 0000000..8f553c1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-uk-report-date-time-policy.md @@ -0,0 +1,84 @@ +# UK Report Date and Time Policy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Standardize report date handling around `Europe/London`, make clock-dependent behavior injectable, and expose explicit date/time API contracts. + +**Architecture:** Add a small reporting date policy around `TimeProvider` and `TimeZoneInfo`. Handlers normalize `DateOnly` request values into domain ranges; services use the normalized boundaries and the policy for current-day calculations. Existing database business-date columns remain UK-local values. + +**Tech Stack:** .NET 10, ASP.NET Minimal APIs, MediatR, EF Core, xUnit v3, Shouldly. + +**Spec:** `docs/superpowers/specs/2026-09-30-uk-report-date-time-policy-design.md` + +## Global Constraints + +- Use `Europe/London`; do not introduce estate-specific timezone storage. +- Preserve persisted UK-local reporting-date semantics. +- UI/client updates are out of scope. +- Use injected `TimeProvider`; no direct system-clock calls in reporting production code. + +## Review Focus + +- Midnight rollover must select the UK business day, not the server day. +- DST spring and autumn transitions must retain the correct UK business date. +- Inclusive end dates must include the entire requested day. +- Date-only report inputs must not depend on an implicit time component. +- Settlement “today” comparisons must use a normalized UK business date. + +--- + +### Task 1: Add and test the UK reporting date policy + +**Files:** +- Create: `EstateReportingAPI.BusinessLogic/ReportingDatePolicy.cs` +- Test: `EstateReportingAPI.BusinessLogic.UnitTests/ReportingDatePolicyTests.cs` + +- [ ] Write failing tests for `Today`, inclusive range normalization, and GMT/BST boundary behavior using `TimeProvider`. +- [ ] Run the focused tests and verify they fail because the policy does not exist. +- [ ] Implement `ReportingDatePolicy` with `Europe/London`, `Today`, `GetDateRange(DateOnly, DateOnly)`, and `GetCurrentLocalTime()`. +- [ ] Run the focused tests and verify they pass. + +### Task 2: Register the policy and migrate API date contracts + +**Files:** +- Modify: `EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs` +- Modify: report request DTOs under `EstateReportingAPI.DataTrasferObjects` +- Modify: corresponding models and MediatR queries under `EstateReportingAPI.Models` and `EstateReportingAPI.BusinessLogic/Queries` +- Test: request/handler contract coverage in existing unit and integration test projects + +- [ ] Add failing contract tests proving report date inputs are date-only and timestamp outputs are offset-aware. +- [ ] Run the focused tests and verify the expected contract failures. +- [ ] Register `TimeProvider.System` and `ReportingDatePolicy` through DI, then migrate report input types and timestamp output types. +- [ ] Run focused tests and update all compile-time callers to use explicit date values. + +### Task 3: Migrate current-day and range-based reporting queries + +**Files:** +- Modify: `EstateReportingAPI/Handlers/CalenderHandler.cs` +- Modify: `EstateReportingAPI/Handlers/TransactionHandler.cs` +- Modify: `EstateReportingAPI/Handlers/SettlementHandler.cs` +- Modify: `EstateReportingAPI/Handlers/FileImportHandler.cs` +- Modify: reporting services under `EstateReportingAPI.BusinessLogic/Services` + +- [ ] Add failing tests for inclusive range end behavior, UK current-day behavior, and normalized settlement dates. +- [ ] Run the focused tests and verify they fail against the current implementation. +- [ ] Replace direct `DateTime.Now`/`DateTime.Today` calls and `<= EndDate` range predicates with the policy and exclusive end boundaries. +- [ ] Run the focused unit tests and verify they pass. + +### Task 4: Update integration coverage and remove direct clock calls + +**Files:** +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs` +- Modify: relevant files under `EstateReportingAPI.IntegrationTests` +- Modify: any remaining reporting production files returned by `rg "DateTime\\.(Now|Today)"` + +- [ ] Add integration cases for midnight and both UK DST transitions. +- [ ] Run the integration tests and correct any contract or boundary regressions. +- [ ] Run a final search for direct system-clock calls in reporting production code and remove remaining occurrences. + +### Task 5: Verify the complete solution + +- [ ] Run the focused unit tests. +- [ ] Run the complete solution test suite. +- [ ] Run `dotnet build EstateReportingAPI.sln`. +- [ ] Review the diff for unintended API or persistence changes. diff --git a/docs/superpowers/specs/2026-09-30-uk-report-date-time-policy-design.md b/docs/superpowers/specs/2026-09-30-uk-report-date-time-policy-design.md new file mode 100644 index 0000000..7099d64 --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-uk-report-date-time-policy-design.md @@ -0,0 +1,23 @@ +# UK Report Date and Time Policy + +## Goal + +Standardize report date handling around the UK business timezone while making clock-dependent logic deterministic and making API date contracts explicit. + +## Decisions + +- Business/reporting dates are interpreted in `Europe/London`, including GMT/BST transitions. +- Existing persisted reporting date fields remain UK-local business values. +- Report date inputs use `DateOnly` semantics. +- Report ranges are inclusive to callers and normalized internally to an exclusive end date. +- Instant timestamps returned by the API use `DateTimeOffset`. +- Clock-dependent logic uses an injected `TimeProvider`. +- Estate-specific timezones are out of scope for this change. + +## Scope + +The policy applies to calendar defaults, transaction reports, settlement reports, file-import ranges, merchant KPI windows, and their tests and API contracts. UI/client changes are a follow-up job. + +## Testing + +Tests must cover fixed-clock behavior around midnight, UK daylight-saving transitions, inclusive report boundaries, calendar comparison dates, and settlement “today” handling.