diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 6d3c35f..242ed92 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -636,13 +636,15 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food - Freshness and source can be displayed **Tasks** -- [ ] Create `IPriceService` -- [ ] Derive observations from purchase items -- [ ] Implement latest price calculation -- [ ] Implement average and median calculations -- [ ] Implement lowest recent price calculation -- [ ] Implement unit price calculation -- [ ] Add tests for price statistics +- [x] Create `IPriceService` +- [x] Derive observations from purchase items +- [x] Implement latest price calculation +- [x] Implement average and median calculations +- [x] Implement lowest recent price calculation +- [x] Implement unit price calculation +- [x] Add tests for price statistics + +**Status:** Done — `PurchaseService.CompletePurchaseAsync` now calls into the new `IPriceService.RecordObservationAsync` for every item that has a resolved `ProductId` (items without one — the common free-text case — correctly create no observation, since `PriceObservation.ProductId` isn't nullable and there'd be nothing meaningful to attach a price history entry to). `Price` on the observation is `LinePrice / Quantity` (price per purchased unit/package, e.g. "$3.99 for the jar"); `UnitPrice` is that divided by the `Product.SizeValue` when known (e.g. "$0.25/oz" for a 16 oz jar), `null` otherwise — matches AGENTS.md §16's `unitPrice = effectivePrice / packageSize`. `Purchase.SourceType` maps to the matching `PriceObservationSourceType` (`Manual`→`UserEntered`, etc.); `ConfidenceScore` is `1.0` since these come directly from what the user typed. `GetHouseholdPriceInsightAsync` computes latest/average/median/lowest/unit-price in-memory (SQLite/EF Core has no MEDIAN aggregate) — fine at MVP's per-household-per-product data scale. Explicitly **not** built: AGENTS.md §16's "Good Price" label — it's presented as an illustrative example ("do not hardcode an arbitrary universal threshold without tests"), isn't in this story's task list, and doesn't have the household-history depth yet to threshold against meaningfully. 12 new application tests (8 `PriceService`, 4 derivation-focused additions to `PurchaseServiceTests`, including the odd/even median cases and the has-size vs. no-size unit-price branches). ### `CW-STORY-05.6` Build price views **Release:** MVP diff --git a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs index fa88ed5..a103256 100644 --- a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs +++ b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/CartWise.Application/DTOs/PriceInsightDto.cs b/src/CartWise.Application/DTOs/PriceInsightDto.cs new file mode 100644 index 0000000..85172fc --- /dev/null +++ b/src/CartWise.Application/DTOs/PriceInsightDto.cs @@ -0,0 +1,18 @@ +namespace CartWise.Application.DTOs; + +public class PriceInsightDto +{ + public int ObservationCount { get; init; } + + public decimal? LatestPrice { get; init; } + + public DateTime? LatestObservedUtc { get; init; } + + public decimal? AveragePrice { get; init; } + + public decimal? MedianPrice { get; init; } + + public decimal? LowestPrice { get; init; } + + public decimal? UnitPrice { get; init; } +} diff --git a/src/CartWise.Application/DTOs/PriceObservationDto.cs b/src/CartWise.Application/DTOs/PriceObservationDto.cs new file mode 100644 index 0000000..dfe9fc6 --- /dev/null +++ b/src/CartWise.Application/DTOs/PriceObservationDto.cs @@ -0,0 +1,12 @@ +namespace CartWise.Application.DTOs; + +public class PriceObservationDto +{ + public decimal Price { get; init; } + + public decimal? UnitPrice { get; init; } + + public DateTime ObservedUtc { get; init; } + + public string SourceType { get; init; } = string.Empty; +} diff --git a/src/CartWise.Application/Interfaces/IPriceService.cs b/src/CartWise.Application/Interfaces/IPriceService.cs new file mode 100644 index 0000000..8d419a8 --- /dev/null +++ b/src/CartWise.Application/Interfaces/IPriceService.cs @@ -0,0 +1,23 @@ +using CartWise.Application.DTOs; +using CartWise.Domain.Enums; + +namespace CartWise.Application.Interfaces; + +public interface IPriceService +{ + Task RecordObservationAsync( + Guid productId, + decimal price, + DateTime observedUtc, + PriceObservationSourceType sourceType, + decimal confidenceScore, + Guid? householdId = null, + Guid? storeLocationId = null, + decimal? unitPrice = null, + string? sourceReference = null, + CancellationToken cancellationToken = default); + + Task GetHouseholdPriceInsightAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); + + Task> GetHouseholdPriceHistoryAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); +} diff --git a/src/CartWise.Application/Services/PriceService.cs b/src/CartWise.Application/Services/PriceService.cs new file mode 100644 index 0000000..c8ebf26 --- /dev/null +++ b/src/CartWise.Application/Services/PriceService.cs @@ -0,0 +1,102 @@ +using CartWise.Application.DTOs; +using CartWise.Application.Interfaces; +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Services; + +public class PriceService : IPriceService +{ + private readonly IApplicationDbContext _db; + private readonly TimeProvider _timeProvider; + + public PriceService(IApplicationDbContext db, TimeProvider timeProvider) + { + _db = db; + _timeProvider = timeProvider; + } + + public async Task RecordObservationAsync( + Guid productId, + decimal price, + DateTime observedUtc, + PriceObservationSourceType sourceType, + decimal confidenceScore, + Guid? householdId = null, + Guid? storeLocationId = null, + decimal? unitPrice = null, + string? sourceReference = null, + CancellationToken cancellationToken = default) + { + var observation = new PriceObservation( + productId, + price, + observedUtc, + sourceType, + confidenceScore, + _timeProvider.GetUtcNow().UtcDateTime, + householdId: householdId, + storeLocationId: storeLocationId, + unitPrice: unitPrice, + sourceReference: sourceReference); + + _db.PriceObservations.Add(observation); + await _db.SaveChangesAsync(cancellationToken); + } + + public async Task GetHouseholdPriceInsightAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default) + { + var observations = await _db.PriceObservations + .AsNoTracking() + .Where(o => o.ProductId == productId && o.HouseholdId == householdId) + .OrderByDescending(o => o.ObservedUtc) + .ToListAsync(cancellationToken); + + if (observations.Count == 0) + { + return null; + } + + var sortedPrices = observations.Select(o => o.Price).OrderBy(p => p).ToList(); + var latest = observations[0]; + + return new PriceInsightDto + { + ObservationCount = observations.Count, + LatestPrice = latest.Price, + LatestObservedUtc = latest.ObservedUtc, + AveragePrice = Math.Round(sortedPrices.Average(), 2), + MedianPrice = CalculateMedian(sortedPrices), + LowestPrice = sortedPrices[0], + UnitPrice = observations.FirstOrDefault(o => o.UnitPrice.HasValue)?.UnitPrice + }; + } + + public async Task> GetHouseholdPriceHistoryAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default) + { + return await _db.PriceObservations + .AsNoTracking() + .Where(o => o.ProductId == productId && o.HouseholdId == householdId) + .OrderByDescending(o => o.ObservedUtc) + .Select(o => new PriceObservationDto + { + Price = o.Price, + UnitPrice = o.UnitPrice, + ObservedUtc = o.ObservedUtc, + SourceType = o.SourceType.ToString() + }) + .ToListAsync(cancellationToken); + } + + private static decimal CalculateMedian(List sortedPrices) + { + var count = sortedPrices.Count; + if (count % 2 == 1) + { + return sortedPrices[count / 2]; + } + + return Math.Round((sortedPrices[(count / 2) - 1] + sortedPrices[count / 2]) / 2m, 2); + } +} diff --git a/src/CartWise.Application/Services/PurchaseService.cs b/src/CartWise.Application/Services/PurchaseService.cs index 749ea74..edad306 100644 --- a/src/CartWise.Application/Services/PurchaseService.cs +++ b/src/CartWise.Application/Services/PurchaseService.cs @@ -1,6 +1,7 @@ using CartWise.Application.Interfaces; using CartWise.Application.Results; using CartWise.Domain.Entities; +using CartWise.Domain.Enums; using Microsoft.EntityFrameworkCore; namespace CartWise.Application.Services; @@ -8,11 +9,13 @@ namespace CartWise.Application.Services; public class PurchaseService : IPurchaseService { private readonly IApplicationDbContext _db; + private readonly IPriceService _priceService; private readonly TimeProvider _timeProvider; - public PurchaseService(IApplicationDbContext db, TimeProvider timeProvider) + public PurchaseService(IApplicationDbContext db, IPriceService priceService, TimeProvider timeProvider) { _db = db; + _priceService = priceService; _timeProvider = timeProvider; } @@ -105,9 +108,48 @@ public class PurchaseService : IPurchaseService await _db.SaveChangesAsync(cancellationToken); + await DeriveObservationsAsync(purchase, cancellationToken); + return Result.Success(purchase); } + private async Task DeriveObservationsAsync(Purchase purchase, CancellationToken cancellationToken) + { + var sourceType = MapSourceType(purchase.SourceType); + + foreach (var item in purchase.Items.Where(i => i.ProductId.HasValue)) + { + var effectivePrice = item.LinePrice / item.Quantity; + + var sizeValue = await _db.Products + .Where(p => p.ProductId == item.ProductId) + .Select(p => p.SizeValue) + .FirstOrDefaultAsync(cancellationToken); + + var unitPrice = sizeValue is > 0 ? Math.Round(effectivePrice / sizeValue.Value, 4) : (decimal?)null; + + await _priceService.RecordObservationAsync( + item.ProductId!.Value, + effectivePrice, + purchase.PurchasedUtc, + sourceType, + confidenceScore: 1m, + householdId: purchase.HouseholdId, + storeLocationId: purchase.StoreLocationId, + unitPrice: unitPrice, + sourceReference: purchase.PurchaseId.ToString(), + cancellationToken: cancellationToken); + } + } + + private static PriceObservationSourceType MapSourceType(PurchaseSourceType sourceType) => sourceType switch + { + PurchaseSourceType.ShoppingMode => PriceObservationSourceType.ShoppingMode, + PurchaseSourceType.ReceiptImport => PriceObservationSourceType.Receipt, + PurchaseSourceType.RetailerImport => PriceObservationSourceType.RetailerApi, + _ => PriceObservationSourceType.UserEntered + }; + public async Task GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default) { return await _db.Purchases diff --git a/tests/CartWise.Application.Tests/PriceServiceTests.cs b/tests/CartWise.Application.Tests/PriceServiceTests.cs new file mode 100644 index 0000000..173953c --- /dev/null +++ b/tests/CartWise.Application.Tests/PriceServiceTests.cs @@ -0,0 +1,99 @@ +using CartWise.Application.Services; +using CartWise.Domain.Enums; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Tests; + +public class PriceServiceTests : IDisposable +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + private readonly CartWiseDbContext _db; + private readonly PriceService _sut; + + public PriceServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _db = new CartWiseDbContext(options); + _sut = new PriceService(_db, TimeProvider.System); + } + + public void Dispose() + { + _db.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetHouseholdPriceInsightAsync_ReturnsNullWhenNoObservationsExist() + { + var insight = await _sut.GetHouseholdPriceInsightAsync(Guid.NewGuid(), Guid.NewGuid()); + + Assert.Null(insight); + } + + [Fact] + public async Task RecordObservationAsync_ThenGetInsight_ReturnsLatestAverageMedianAndLowest() + { + var householdId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + await _sut.RecordObservationAsync(productId, 3.00m, Now.AddDays(-2), PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + await _sut.RecordObservationAsync(productId, 5.00m, Now.AddDays(-1), PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + await _sut.RecordObservationAsync(productId, 4.00m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + + var insight = await _sut.GetHouseholdPriceInsightAsync(householdId, productId); + + Assert.NotNull(insight); + Assert.Equal(3, insight!.ObservationCount); + Assert.Equal(4.00m, insight.LatestPrice); + Assert.Equal(Now, insight.LatestObservedUtc); + Assert.Equal(4.00m, insight.AveragePrice); + Assert.Equal(4.00m, insight.MedianPrice); + Assert.Equal(3.00m, insight.LowestPrice); + } + + [Fact] + public async Task GetHouseholdPriceInsightAsync_ComputesMedianForEvenCount() + { + var householdId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + await _sut.RecordObservationAsync(productId, 2.00m, Now.AddDays(-1), PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + await _sut.RecordObservationAsync(productId, 4.00m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + + var insight = await _sut.GetHouseholdPriceInsightAsync(householdId, productId); + + Assert.Equal(3.00m, insight!.MedianPrice); + } + + [Fact] + public async Task GetHouseholdPriceInsightAsync_DoesNotMixDifferentHouseholds() + { + var productId = Guid.NewGuid(); + await _sut.RecordObservationAsync(productId, 3.00m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: Guid.NewGuid()); + + var insight = await _sut.GetHouseholdPriceInsightAsync(Guid.NewGuid(), productId); + + Assert.Null(insight); + } + + [Fact] + public async Task GetHouseholdPriceHistoryAsync_ReturnsNewestFirst() + { + var householdId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + await _sut.RecordObservationAsync(productId, 3.00m, Now.AddDays(-1), PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + await _sut.RecordObservationAsync(productId, 4.00m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: householdId); + + var history = await _sut.GetHouseholdPriceHistoryAsync(householdId, productId); + + Assert.Equal(2, history.Count); + Assert.Equal(4.00m, history[0].Price); + Assert.Equal(3.00m, history[1].Price); + } +} diff --git a/tests/CartWise.Application.Tests/PurchaseServiceTests.cs b/tests/CartWise.Application.Tests/PurchaseServiceTests.cs index 72cdf77..3394672 100644 --- a/tests/CartWise.Application.Tests/PurchaseServiceTests.cs +++ b/tests/CartWise.Application.Tests/PurchaseServiceTests.cs @@ -1,4 +1,6 @@ using CartWise.Application.Services; +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; using CartWise.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -18,7 +20,7 @@ public class PurchaseServiceTests : IDisposable .Options; _db = new CartWiseDbContext(options); - _sut = new PurchaseService(_db, TimeProvider.System); + _sut = new PurchaseService(_db, new PriceService(_db, TimeProvider.System), TimeProvider.System); } public void Dispose() @@ -100,6 +102,60 @@ public class PurchaseServiceTests : IDisposable Assert.False(result.IsSuccess); } + [Fact] + public async Task CompletePurchaseAsync_DoesNotCreateObservationForItemWithoutProduct() + { + var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now); + await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Unresolved item", 3.99m); + + await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId); + + Assert.Empty(await _db.PriceObservations.ToListAsync()); + } + + [Fact] + public async Task CompletePurchaseAsync_CreatesObservationWithUnitPriceForResolvedProduct() + { + var product = new Product("Peanut Butter 16 oz", ProductSourceType.Manual, Now, sizeValue: 16m, sizeUnit: "oz"); + _db.Products.Add(product); + await _db.SaveChangesAsync(); + + var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now); + await _sut.AddPurchaseItemAsync( + started.Value!.HouseholdId, + started.Value.PurchaseId, + "Peanut Butter", + 4.00m, + quantity: 1m, + productId: product.ProductId); + + await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId); + + var observation = Assert.Single(await _db.PriceObservations.ToListAsync()); + Assert.Equal(product.ProductId, observation.ProductId); + Assert.Equal(started.Value.HouseholdId, observation.HouseholdId); + Assert.Equal(4.00m, observation.Price); + Assert.Equal(0.25m, observation.UnitPrice); + Assert.Equal(PriceObservationSourceType.UserEntered, observation.SourceType); + Assert.Equal(1m, observation.ConfidenceScore); + } + + [Fact] + public async Task CompletePurchaseAsync_CreatesObservationWithoutUnitPriceWhenProductHasNoSize() + { + var product = new Product("Mystery Item", ProductSourceType.Manual, Now); + _db.Products.Add(product); + await _db.SaveChangesAsync(); + + var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now); + await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Mystery Item", 2.00m, productId: product.ProductId); + + await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId); + + var observation = Assert.Single(await _db.PriceObservations.ToListAsync()); + Assert.Null(observation.UnitPrice); + } + [Fact] public async Task GetHouseholdPurchasesAsync_ReturnsOnlyThatHouseholdsPurchasesNewestFirst() {