Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
51 changes: 51 additions & 0 deletions EstateReportingAPI.BusinessLogic/ReportingDatePolicy.cs
Original file line number Diff line number Diff line change
@@ -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));
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
}

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,25 @@ public interface IEstateReportingService
public sealed class EstateReportingService : IEstateReportingService
{
private readonly IDbContextResolver<EstateManagementContext> Resolver;
private readonly ReportingDatePolicy DatePolicy;
private const string EstateManagementDatabaseName = "TransactionProcessorReadModel";

public EstateReportingService(IDbContextResolver<EstateManagementContext> resolver)
public EstateReportingService(IDbContextResolver<EstateManagementContext> resolver, ReportingDatePolicy? datePolicy = null)
{
Resolver = resolver;
DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System);
}

public async Task<Result<List<Calendar>>> GetCalendarComparisonDates(CalendarQueries.GetComparisonDatesQuery request,
CancellationToken cancellationToken) {
using ResolvedDbContext<EstateManagementContext>? 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);
Expand Down Expand Up @@ -71,7 +73,7 @@ public async Task<Result<List<Calendar>>> 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);
Expand Down Expand Up @@ -102,7 +104,7 @@ public async Task<Result<List<Int32>>> GetCalendarYears(CalendarQueries.GetYears
using ResolvedDbContext<EstateManagementContext>? 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ public interface IMerchantReportingService
public sealed class MerchantReportingService : IMerchantReportingService
{
private readonly IDbContextResolver<EstateManagementContext> Resolver;
private readonly ReportingDatePolicy DatePolicy;
private const string EstateManagementDatabaseName = "TransactionProcessorReadModel";

public MerchantReportingService(IDbContextResolver<EstateManagementContext> resolver)
public MerchantReportingService(IDbContextResolver<EstateManagementContext> resolver, ReportingDatePolicy? datePolicy = null)
{
Resolver = resolver;
DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System);
}

private static async Task<Result<T>> ExecuteQuerySafeSum<T>(IQueryable query,
Expand Down Expand Up @@ -367,7 +369,7 @@ public async Task<Result<MerchantKpi>> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@ public interface ISettlementReportingService
public sealed class SettlementReportingService : ISettlementReportingService
{
private readonly IDbContextResolver<EstateManagementContext> Resolver;
private readonly ReportingDatePolicy DatePolicy;
private const string EstateManagementDatabaseName = "TransactionProcessorReadModel";
public SettlementReportingService(IDbContextResolver<EstateManagementContext> resolver) => Resolver = resolver;
public SettlementReportingService(IDbContextResolver<EstateManagementContext> resolver, ReportingDatePolicy? datePolicy = null)
{
Resolver = resolver;
DatePolicy = datePolicy ?? new ReportingDatePolicy(TimeProvider.System);
}

public async Task<Result<TodaysSettlement>> GetTodaysSettlement(SettlementQueries.TodaysSettlementQuery request,
CancellationToken cancellationToken) {
using ResolvedDbContext<EstateManagementContext>? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString());
await using EstateManagementContext context = resolvedContext.Context;

IQueryable<DatabaseProjections.TodaySettlementTransactionProjection> todaySettlementData = this.BuildTodaySettlementQuery(context, DateTime.Now);
IQueryable<DatabaseProjections.TodaySettlementTransactionProjection> todaySettlementData = this.BuildTodaySettlementQuery(context, DatePolicy.Today.ToDateTime(TimeOnly.MinValue));
IQueryable<DatabaseProjections.ComparisonSettlementTransactionProjection> comparisonSettlementData = this.BuildComparisonSettlementQuery(context, request.ComparisonDate);

DatabaseProjections.SettlementGroupProjection todaySettlement = await this.GetSettlementSummary(todaySettlementData, cancellationToken);
Expand Down Expand Up @@ -92,4 +97,4 @@ public async Task<Result<TodaysSettlement>> GetTodaysSettlement(SettlementQuerie
IQueryable<DatabaseProjections.ComparisonSettlementTransactionProjection> 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;
}
}
}
Loading
Loading