IPriceService (record/insight/history) wired into PurchaseService. CompletePurchaseAsync, deriving a PriceObservation per resolved PurchaseItem. Latest/average/median/lowest/unit-price all computed in-memory (no MEDIAN in SQLite). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -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 | |||
| @@ -12,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions | |||
| services.AddScoped<IHouseholdService, HouseholdService>(); | |||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | |||
| services.AddScoped<IProductService, ProductService>(); | |||
| services.AddScoped<IPriceService, PriceService>(); | |||
| services.AddScoped<IPurchaseService, PurchaseService>(); | |||
| return services; | |||
| @@ -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; } | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -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<PriceInsightDto?> GetHouseholdPriceInsightAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); | |||
| Task<IReadOnlyList<PriceObservationDto>> GetHouseholdPriceHistoryAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -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<PriceInsightDto?> 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<IReadOnlyList<PriceObservationDto>> 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<decimal> 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); | |||
| } | |||
| } | |||
| @@ -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<Purchase>.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<Purchase?> GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default) | |||
| { | |||
| return await _db.Purchases | |||
| @@ -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<CartWiseDbContext>() | |||
| .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); | |||
| } | |||
| } | |||
| @@ -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() | |||
| { | |||
Powered by TurnKey Linux.