Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
71583fe
test(frontend): restore SearchForm showPicker teardown
May 16, 2026
8e93b54
feat(phase10): close payment/reservation module thresholds, start adm…
May 16, 2026
b0d7c82
fix(tests): codex review fixes - reservation overlap mock param + sho…
May 16, 2026
6e3ee3e
test(phase10): expand admin reservations coverage
May 16, 2026
60f6187
Merge remote-tracking branch 'origin/main' into feat/phase10-public-p…
May 16, 2026
642a5ee
docs(phase10): add admin reservations PR handoff
May 16, 2026
483ab7d
test(phase10): lift frontend coverage past 25 percent
May 17, 2026
e3d37d8
merge(main): resolve phase10 coverage docs
May 17, 2026
8d5ec08
test(phase10): expand admin coverage toward launch gate
May 17, 2026
9508f25
merge(main): resolve phase10 coverage docs
May 17, 2026
1cce367
docs(phase10): add pr230 coverage handoff
May 17, 2026
14e756a
test(phase10): close frontend coverage gate
May 17, 2026
ae0ccda
docs(phase10): align frontend coverage completion notes
May 17, 2026
e116787
docs(phase10): add frontend coverage PR handoff
May 17, 2026
aaae296
fix(phase10): stabilize local docker load validation
May 17, 2026
c6a7ba2
merge(main): resolve phase10 docs conflicts
May 17, 2026
8810f07
fix(phase10): restore reservation service unit tests
May 17, 2026
3ef8ddc
fix(phase10): scope reservations and stabilize smoke checks
May 17, 2026
c35c252
docs(phase10): verify local docker load validation
May 17, 2026
2e7e646
Merge remote-tracking branch 'origin/main' into feat/phase10-public-p…
May 17, 2026
e893539
fix(phase10): address codex review comments
May 17, 2026
c06911e
fix(phase10): preserve reservation tracking on by-id lookup
May 17, 2026
fbe0597
docs(phase10): close local load baseline
May 17, 2026
ce167b7
merge: origin/main into feat/phase10-public-page-coverage
May 17, 2026
382dd09
fix(test): align rate limiting reflection test
May 17, 2026
28da0ae
fix(phase10): move concurrent booking seed to startup
May 17, 2026
8a90b70
fix(phase10): address load-baseline review follow-up
May 18, 2026
544613c
merge: resolve origin/main conflicts for PR #259
Jun 2, 2026
46735ea
docs(phase10): archive PR #259 load-baseline closure body and record …
Jun 2, 2026
5f4c406
docs: restructure CLAUDE.md to delegate to AGENTS.md
Jun 2, 2026
8d57e52
docs(handoff): archive 2026-06-02 paperwork + CLAUDE.md restructure s…
Jun 2, 2026
c8def7d
docs(phase10): archive PR #260 body and record Dependabot vitest CVE fix
Jun 2, 2026
cb7b345
chore(phase10): finalize preserved working-tree state and ignore loca…
Jun 2, 2026
c01f766
docs(phase10): sync PR #260 paperwork, add session handoff, refresh l…
Jun 2, 2026
80e7777
docs(phase10): clarify gate #11 references main HEAD not PR branch state
Jun 2, 2026
7957132
feat(phase10): ship Wave 4 admin Reports backend (Wave 4.1)
claude Jun 2, 2026
9f92584
docs(phase10): record Wave 4 closure evidence + session handoff
claude Jun 2, 2026
46d57b1
docs(phase10): resolve Wave 4 PR conflict and sync architecture evidence
Jun 2, 2026
82510bd
fix(phase10): address Wave 4 reports review feedback
Jun 2, 2026
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
Expand Up @@ -50,6 +50,7 @@ public static IServiceCollection AddApiApplicationServices(
services.AddScoped<IPaymentService>(serviceProvider => serviceProvider.GetRequiredService<PaymentService>());
services.AddScoped<IAuditLogService, AuditLogService>();
services.AddScoped<IFeatureFlagService, FeatureFlagService>();
services.AddScoped<IReportsService, ReportsService>();
services.AddPaymentIntegration(configuration);
services.AddHostedService<QueuedPaymentWebhookHostedService>();
services.AddJwtAuthentication(configuration, environment);
Expand Down
31 changes: 31 additions & 0 deletions backend/src/RentACar.API/Contracts/Reports/ReportDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace RentACar.API.Contracts.Reports;

public sealed record RevenueReportBreakdownItemResponse(
DateOnly Date,
decimal Revenue,
int Reservations);

public sealed record RevenueReportResponse(
string Period,
decimal TotalRevenue,
int TotalReservations,
decimal AverageOrderValue,
IReadOnlyList<RevenueReportBreakdownItemResponse> DailyBreakdown);

public sealed record OccupancyReportBreakdownItemResponse(
DateOnly Date,
int OccupiedVehicles,
int TotalVehicles,
decimal OccupancyRate);

public sealed record OccupancyReportResponse(
string Period,
int TotalVehicles,
int OccupiedVehicles,
decimal OccupancyRate,
IReadOnlyList<OccupancyReportBreakdownItemResponse> DailyBreakdown);

public sealed record PopularVehicleReportItemResponse(
string VehicleName,
int RentalCount,
decimal Revenue);
40 changes: 40 additions & 0 deletions backend/src/RentACar.API/Controllers/AdminReportsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using RentACar.API.Configuration;
using RentACar.API.Services;

namespace RentACar.API.Controllers;

[Route("api/admin/v1/reports")]
[Authorize(Policy = AuthPolicyNames.AdminOnly)]
[EnableRateLimiting(RateLimitPolicyNames.Standard)]
public sealed class AdminReportsController(IReportsService reportsService) : BaseApiController
{
[HttpGet("revenue")]
public async Task<IActionResult> GetRevenue(
[FromQuery] string period,
CancellationToken cancellationToken)
{
var result = await reportsService.GetRevenueReportAsync(period, cancellationToken);
return OkResponse(result);
}

[HttpGet("occupancy")]
public async Task<IActionResult> GetOccupancy(
[FromQuery] string period,
CancellationToken cancellationToken)
{
var result = await reportsService.GetOccupancyReportAsync(period, cancellationToken);
return OkResponse(result);
}

[HttpGet("popular-vehicles")]
public async Task<IActionResult> GetPopularVehicles(
[FromQuery] string period,
CancellationToken cancellationToken)
{
var result = await reportsService.GetPopularVehiclesAsync(period, cancellationToken);
return OkResponse(result);
}
}
12 changes: 12 additions & 0 deletions backend/src/RentACar.API/Services/IReportsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using RentACar.API.Contracts.Reports;

namespace RentACar.API.Services;

public interface IReportsService
{
Task<RevenueReportResponse> GetRevenueReportAsync(string period, CancellationToken cancellationToken = default);

Task<OccupancyReportResponse> GetOccupancyReportAsync(string period, CancellationToken cancellationToken = default);

Task<IReadOnlyList<PopularVehicleReportItemResponse>> GetPopularVehiclesAsync(string period, CancellationToken cancellationToken = default);
}
249 changes: 249 additions & 0 deletions backend/src/RentACar.API/Services/ReportsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
using Microsoft.EntityFrameworkCore;
using RentACar.API.Contracts.Reports;
using RentACar.Core.Entities;
using RentACar.Core.Enums;
using RentACar.Core.Interfaces;

namespace RentACar.API.Services;

public sealed class ReportsService(IApplicationDbContext dbContext) : IReportsService
{
private const int PopularVehiclesTopN = 5;

private static readonly HashSet<ReservationStatus> RevenueEligibleStatuses = new()
{
ReservationStatus.Paid,
ReservationStatus.Active,
ReservationStatus.Completed
};

public async Task<RevenueReportResponse> GetRevenueReportAsync(
string period,
CancellationToken cancellationToken = default)
{
var range = ResolvePeriod(period);
if (range is null)
{
return EmptyRevenueReport(period);
}

var (startUtc, endUtc, days) = range.Value;

var reservations = await dbContext.Reservations
.AsNoTracking()
.Where(r => RevenueEligibleStatuses.Contains(r.Status)
&& r.PickupDateTime >= startUtc
&& r.PickupDateTime < endUtc)
.Select(r => new { r.Id, r.PickupDateTime })
.ToListAsync(cancellationToken);

var reservationIds = reservations.Select(r => r.Id).ToList();
var reservationPickupLookup = reservations.ToDictionary(r => r.Id, r => r.PickupDateTime);

var paymentIntents = await dbContext.PaymentIntents
.AsNoTracking()
.Where(p => p.Status == PaymentStatus.Succeeded
&& reservationIds.Contains(p.ReservationId))
.Select(p => new { p.Amount, p.ReservationId })
.ToListAsync(cancellationToken);

var totalRevenue = paymentIntents.Sum(p => p.Amount);
var totalReservations = reservations.Count;
var averageOrderValue = totalReservations > 0
? Math.Round(totalRevenue / totalReservations, 2)
: 0m;

var breakdown = days
.Select(day =>
{
var dayStart = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var dayEnd = dayStart.AddDays(1);

var dayRevenue = paymentIntents
.Where(p => reservationPickupLookup[p.ReservationId] >= dayStart
&& reservationPickupLookup[p.ReservationId] < dayEnd)
.Sum(p => p.Amount);

var dayReservations = reservations
.Count(r => r.PickupDateTime >= dayStart && r.PickupDateTime < dayEnd);

return new RevenueReportBreakdownItemResponse(day, dayRevenue, dayReservations);
})
.ToList();

return new RevenueReportResponse(
period,
totalRevenue,
totalReservations,
averageOrderValue,
breakdown);
}

public async Task<OccupancyReportResponse> GetOccupancyReportAsync(
string period,
CancellationToken cancellationToken = default)
{
var range = ResolvePeriod(period);
if (range is null)
{
return EmptyOccupancyReport(period);
}

var (startUtc, endUtc, days) = range.Value;

var totalVehicles = await dbContext.Vehicles
.AsNoTracking()
.CountAsync(cancellationToken);

var reservations = await dbContext.Reservations
.AsNoTracking()
.Where(r => RevenueEligibleStatuses.Contains(r.Status)
&& r.PickupDateTime < endUtc
&& r.ReturnDateTime > startUtc)
.Select(r => new { r.PickupDateTime, r.ReturnDateTime })
.ToListAsync(cancellationToken);
Comment on lines +98 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter occupancy reservations to the requested period

For weekly/monthly/yearly occupancy reports after the database has historical completed rentals, this query materializes every paid/active/completed reservation because it never applies the resolved startUtc/endUtc overlap window. The later per-day loop only needs reservations where PickupDateTime < endUtc && ReturnDateTime > startUtc; without that predicate the endpoint does days * allHistoricalReservations in memory and can become slow or memory-heavy as booking history grows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82510bd: occupancy reservations are now filtered by the requested report-window overlap before per-day aggregation. Added service coverage for out-of-period reservations.


var breakdown = days
.Select(day =>
{
var dayStart = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var dayEnd = dayStart.AddDays(1);

var occupied = reservations.Count(r =>
r.PickupDateTime < dayEnd && r.ReturnDateTime > dayStart);

var rate = totalVehicles > 0
? Math.Round((decimal)occupied / totalVehicles * 100m, 2)
: 0m;

return new OccupancyReportBreakdownItemResponse(day, occupied, totalVehicles, rate);
})
.ToList();

var lastBucket = breakdown[^1];
var overallRate = lastBucket.OccupancyRate;
var totalOccupied = lastBucket.OccupiedVehicles;

return new OccupancyReportResponse(
period,
totalVehicles,
totalOccupied,
overallRate,
breakdown);
}

public async Task<IReadOnlyList<PopularVehicleReportItemResponse>> GetPopularVehiclesAsync(
string period,
CancellationToken cancellationToken = default)
{
var range = ResolvePeriod(period);
if (range is null)
{
return Array.Empty<PopularVehicleReportItemResponse>();
}

var (startUtc, endUtc, _) = range.Value;

var scopedReservations = await dbContext.Reservations
.AsNoTracking()
.Where(r => RevenueEligibleStatuses.Contains(r.Status)
&& r.PickupDateTime >= startUtc
&& r.PickupDateTime < endUtc)
.Select(r => new { r.Id, r.VehicleId })
.ToListAsync(cancellationToken);

var grouped = scopedReservations
.GroupBy(r => r.VehicleId)
.Select(g => new
{
VehicleId = g.Key,
RentalCount = g.Count()
})
.ToList();

if (grouped.Count == 0)
{
return Array.Empty<PopularVehicleReportItemResponse>();
}

var vehicleIds = grouped.Select(g => g.VehicleId).ToList();

var vehicles = await dbContext.Vehicles
.AsNoTracking()
.Where(v => vehicleIds.Contains(v.Id))
.Select(v => new { v.Id, v.Brand, v.Model })
.ToListAsync(cancellationToken);

var reservationVehicleLookup = scopedReservations.ToDictionary(r => r.Id, r => r.VehicleId);
var reservationIds = reservationVehicleLookup.Keys.ToList();

var paymentAmounts = await dbContext.PaymentIntents
.AsNoTracking()
.Where(p => p.Status == PaymentStatus.Succeeded
&& reservationIds.Contains(p.ReservationId))
.Select(p => new { p.ReservationId, p.Amount })
.ToListAsync(cancellationToken);

var revenueLookup = paymentAmounts
.GroupBy(p => reservationVehicleLookup[p.ReservationId])
.ToDictionary(g => g.Key, g => g.Sum(p => p.Amount));

var result = grouped
.OrderByDescending(g => g.RentalCount)
.ThenBy(g => g.VehicleId)
.Take(PopularVehiclesTopN)
.Select(g =>
{
var vehicle = vehicles.FirstOrDefault(v => v.Id == g.VehicleId);
var name = vehicle is null
? "Unknown"
: $"{vehicle.Brand} {vehicle.Model}".Trim();
var revenue = revenueLookup.TryGetValue(g.VehicleId, out var r) ? r : 0m;
return new PopularVehicleReportItemResponse(name, g.RentalCount, revenue);
})
.ToList();

return result;
}

private static (DateTime StartUtc, DateTime EndUtc, IReadOnlyList<DateOnly> Days)? ResolvePeriod(string? period)
{
if (string.IsNullOrWhiteSpace(period))
{
return null;
}

var normalized = period.Trim().ToLowerInvariant();
var dayCount = normalized switch
{
"daily" => 1,
"weekly" => 7,
"monthly" => 30,
"quarterly" => 90,
"yearly" => 365,
_ => -1
};

if (dayCount < 0)
{
return null;
}

var today = DateTime.UtcNow.Date;
var startDate = today.AddDays(-(dayCount - 1));
var endUtc = today.AddDays(1);
var startUtc = startDate;

var days = Enumerable.Range(0, dayCount)
.Select(i => DateOnly.FromDateTime(startDate.AddDays(i)))
.ToList();

return (startUtc, endUtc, days);
}

private static RevenueReportResponse EmptyRevenueReport(string? period) =>
new(period ?? string.Empty, 0m, 0, 0m, Array.Empty<RevenueReportBreakdownItemResponse>());

private static OccupancyReportResponse EmptyOccupancyReport(string? period) =>
new(period ?? string.Empty, 0, 0, 0m, Array.Empty<OccupancyReportBreakdownItemResponse>());
}
Loading
Loading