diff --git a/.claude/settings.json b/.claude/settings.json
index cdf216a..10b92ce 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -38,7 +38,8 @@
"Bash(curl -s -b /tmp/cw5.txt http://127.0.0.1:5187/products/0ce5df7a-6c8d-489d-ba9f-8d41699f0705 -o /tmp/product.html -w \"status=%{http_code}\\\\n\")",
"Bash(sed -n '/
/,/Add to my list/p' /tmp/product.html)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify4.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify4.db\" 2>&1 | tail -10 && rm -f verify4.db*)",
- "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify5.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify5.db\" 2>&1 | tail -10 && rm -f verify5.db*)"
+ "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify5.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify5.db\" 2>&1 | tail -10 && rm -f verify5.db*)",
+ "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify6.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify6.db\" 2>&1 | tail -10 && rm -f verify6.db*)"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"
diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md
index 05f13d6..b4c1504 100644
--- a/docs/scrum-backlog.md
+++ b/docs/scrum-backlog.md
@@ -594,11 +594,13 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food
- Older observations are not overwritten by newer ones
**Tasks**
-- [ ] Create `PriceObservation`
-- [ ] Add indexes for product, household, and store history
-- [ ] Add EF configuration
-- [ ] Create migration
-- [ ] Add tests for append-only behavior
+- [x] Create `PriceObservation`
+- [x] Add indexes for product, household, and store history
+- [x] Add EF configuration
+- [x] Create migration
+- [x] Add tests for append-only behavior
+
+**Status:** Done — `PriceObservation` has zero mutation methods (only a constructor); a dedicated reflection-based test (`Type_HasNoPublicMutationMethods`) asserts no public instance methods exist beyond property getters, so the append-only invariant can't silently regress. Real bug caught and fixed while writing the EF configuration: `Product → PriceObservation` was initially wired `DeleteBehavior.Cascade` (copy-paste from other configs), which would have let deleting a `Product` silently wipe out its entire price history — directly contradicting CLAUDE.md's "protect historical data from accidental cascade deletion" rule. Changed to `Restrict`. All three indexes from AGENTS.md §5.6 added (`ProductId`+`ObservedUtc`, `+StoreLocationId`, `+HouseholdId`). `ConfidenceScore` is constructor-validated to `[0, 1]`; `CurrencyCode` must be exactly 3 letters (defaults `"USD"`). `AddPriceObservations` migration verified applying cleanly. 7 domain tests.
### `CW-STORY-05.4` Record household purchases
**Release:** MVP
diff --git a/src/CartWise.Domain/Entities/PriceObservation.cs b/src/CartWise.Domain/Entities/PriceObservation.cs
new file mode 100644
index 0000000..3cc51f6
--- /dev/null
+++ b/src/CartWise.Domain/Entities/PriceObservation.cs
@@ -0,0 +1,89 @@
+using CartWise.Domain.Enums;
+
+namespace CartWise.Domain.Entities;
+
+///
+/// Append-only price history. There are intentionally no mutation methods on this type —
+/// a new observation is always a new row, never an update to an existing one.
+///
+public class PriceObservation
+{
+ public Guid PriceObservationId { get; private set; }
+
+ public Guid? HouseholdId { get; private set; }
+
+ public Guid ProductId { get; private set; }
+
+ public Guid? StoreLocationId { get; private set; }
+
+ public decimal Price { get; private set; }
+
+ public decimal? SalePrice { get; private set; }
+
+ public decimal? UnitPrice { get; private set; }
+
+ public string CurrencyCode { get; private set; } = null!;
+
+ public DateTime ObservedUtc { get; private set; }
+
+ public PriceObservationSourceType SourceType { get; private set; }
+
+ public string? SourceReference { get; private set; }
+
+ public decimal ConfidenceScore { get; private set; }
+
+ public DateTime CreatedUtc { get; private set; }
+
+ private PriceObservation()
+ {
+ }
+
+ public PriceObservation(
+ Guid productId,
+ decimal price,
+ DateTime observedUtc,
+ PriceObservationSourceType sourceType,
+ decimal confidenceScore,
+ DateTime createdUtc,
+ Guid? householdId = null,
+ Guid? storeLocationId = null,
+ decimal? salePrice = null,
+ decimal? unitPrice = null,
+ string currencyCode = "USD",
+ string? sourceReference = null)
+ {
+ if (productId == Guid.Empty)
+ {
+ throw new ArgumentException("ProductId is required.", nameof(productId));
+ }
+
+ if (price < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");
+ }
+
+ if (confidenceScore is < 0 or > 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(confidenceScore), "ConfidenceScore must be between 0 and 1.");
+ }
+
+ if (string.IsNullOrWhiteSpace(currencyCode) || currencyCode.Length != 3)
+ {
+ throw new ArgumentException("CurrencyCode must be a 3-letter code.", nameof(currencyCode));
+ }
+
+ PriceObservationId = Guid.NewGuid();
+ ProductId = productId;
+ Price = price;
+ ObservedUtc = observedUtc;
+ SourceType = sourceType;
+ ConfidenceScore = confidenceScore;
+ HouseholdId = householdId;
+ StoreLocationId = storeLocationId;
+ SalePrice = salePrice;
+ UnitPrice = unitPrice;
+ CurrencyCode = currencyCode.Trim().ToUpperInvariant();
+ SourceReference = sourceReference;
+ CreatedUtc = createdUtc;
+ }
+}
diff --git a/src/CartWise.Domain/Enums/PriceObservationSourceType.cs b/src/CartWise.Domain/Enums/PriceObservationSourceType.cs
new file mode 100644
index 0000000..f465dac
--- /dev/null
+++ b/src/CartWise.Domain/Enums/PriceObservationSourceType.cs
@@ -0,0 +1,12 @@
+namespace CartWise.Domain.Enums;
+
+public enum PriceObservationSourceType
+{
+ UserEntered = 0,
+ ShoppingMode = 1,
+ Receipt = 2,
+ OpenFoodFacts = 3,
+ RetailerApi = 4,
+ Instacart = 5,
+ ImportedPurchase = 6
+}
diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
index 0bf6ece..e1eb9f6 100644
--- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
+++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
@@ -41,6 +41,8 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio
public DbSet PurchaseItems => Set();
+ public DbSet PriceObservations => Set();
+
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
diff --git a/src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs
new file mode 100644
index 0000000..1a9f9c5
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Configurations/PriceObservationConfiguration.cs
@@ -0,0 +1,41 @@
+using CartWise.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CartWise.Infrastructure.Data.Configurations;
+
+public class PriceObservationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(o => o.PriceObservationId);
+
+ builder.Property(o => o.CurrencyCode)
+ .HasMaxLength(3)
+ .IsRequired();
+
+ builder.Property(o => o.SourceReference)
+ .HasMaxLength(200);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(o => o.HouseholdId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ // Restrict, not Cascade: PriceObservation is append-only history and must never be
+ // silently destroyed by deleting the Product it references.
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(o => o.ProductId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(o => o.StoreLocationId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasIndex(o => new { o.ProductId, o.ObservedUtc });
+ builder.HasIndex(o => new { o.ProductId, o.StoreLocationId, o.ObservedUtc });
+ builder.HasIndex(o => new { o.HouseholdId, o.ProductId, o.ObservedUtc });
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs
new file mode 100644
index 0000000..ac9d930
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.Designer.cs
@@ -0,0 +1,1073 @@
+//
+using System;
+using CartWise.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace CartWise.Infrastructure.Data.Migrations
+{
+ [DbContext(typeof(CartWiseDbContext))]
+ [Migration("20260810191825_AddPriceObservations")]
+ partial class AddPriceObservations
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.HasKey("BrandId");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Brands");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b =>
+ {
+ b.Property("GroceryConceptId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CategoryId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.HasKey("GroceryConceptId");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("GroceryConcepts");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Household", b =>
+ {
+ b.Property("HouseholdId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedByUserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.HasKey("HouseholdId");
+
+ b.HasIndex("CreatedByUserId");
+
+ b.ToTable("Households");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b =>
+ {
+ b.Property("HouseholdId")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("JoinedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("HouseholdId", "UserId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("HouseholdMembers");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b =>
+ {
+ b.Property("HouseholdProductPreferenceId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GroceryConceptId")
+ .HasColumnType("TEXT");
+
+ b.Property("HouseholdId")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredQuantity")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredUnit")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("SubstitutionLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("HouseholdProductPreferenceId");
+
+ b.HasIndex("GroceryConceptId");
+
+ b.HasIndex("PreferredProductId");
+
+ b.HasIndex("HouseholdId", "GroceryConceptId")
+ .IsUnique();
+
+ b.ToTable("HouseholdProductPreferences");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b =>
+ {
+ b.Property("PriceObservationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfidenceScore")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrencyCode")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("TEXT");
+
+ b.Property("HouseholdId")
+ .HasColumnType("TEXT");
+
+ b.Property("ObservedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Price")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("SalePrice")
+ .HasColumnType("TEXT");
+
+ b.Property("SourceReference")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("SourceType")
+ .HasColumnType("INTEGER");
+
+ b.Property("StoreLocationId")
+ .HasColumnType("TEXT");
+
+ b.Property("UnitPrice")
+ .HasColumnType("TEXT");
+
+ b.HasKey("PriceObservationId");
+
+ b.HasIndex("StoreLocationId");
+
+ b.HasIndex("ProductId", "ObservedUtc");
+
+ b.HasIndex("HouseholdId", "ProductId", "ObservedUtc");
+
+ b.HasIndex("ProductId", "StoreLocationId", "ObservedUtc");
+
+ b.ToTable("PriceObservations");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
+ {
+ b.Property("ProductId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("BrandId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("GroceryConceptId")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("TEXT");
+
+ b.Property("SizeUnit")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("SizeValue")
+ .HasColumnType("TEXT");
+
+ b.Property("SourceExternalId")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("SourceType")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ProductId");
+
+ b.HasIndex("BrandId");
+
+ b.HasIndex("GroceryConceptId");
+
+ b.HasIndex("NormalizedName");
+
+ b.ToTable("Products");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b =>
+ {
+ b.Property("ProductCategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.Property("ParentCategoryId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ProductCategoryId");
+
+ b.HasIndex("ParentCategoryId");
+
+ b.ToTable("ProductCategories");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b =>
+ {
+ b.Property("ProductIdentifierId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("IdentifierType")
+ .HasColumnType("INTEGER");
+
+ b.Property("NormalizedValue")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ProductIdentifierId");
+
+ b.HasIndex("ProductId");
+
+ b.HasIndex("IdentifierType", "NormalizedValue")
+ .IsUnique();
+
+ b.ToTable("ProductIdentifiers");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b =>
+ {
+ b.Property("PurchaseId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("HouseholdId")
+ .HasColumnType("TEXT");
+
+ b.Property("PurchasedByUserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("PurchasedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("SourceType")
+ .HasColumnType("INTEGER");
+
+ b.Property("StoreLocationId")
+ .HasColumnType("TEXT");
+
+ b.Property("Subtotal")
+ .HasColumnType("TEXT");
+
+ b.Property("Tax")
+ .HasColumnType("TEXT");
+
+ b.Property("Total")
+ .HasColumnType("TEXT");
+
+ b.HasKey("PurchaseId");
+
+ b.HasIndex("PurchasedByUserId");
+
+ b.HasIndex("StoreLocationId");
+
+ b.HasIndex("HouseholdId", "PurchasedUtc");
+
+ b.ToTable("Purchases");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.PurchaseItem", b =>
+ {
+ b.Property("PurchaseItemId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("TEXT");
+
+ b.Property("GroceryConceptId")
+ .HasColumnType("TEXT");
+
+ b.Property("LinePrice")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("PurchaseId")
+ .HasColumnType("TEXT");
+
+ b.Property("Quantity")
+ .HasColumnType("TEXT");
+
+ b.Property("ShoppingListItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Unit")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UnitPrice")
+ .HasColumnType("TEXT");
+
+ b.HasKey("PurchaseItemId");
+
+ b.HasIndex("GroceryConceptId");
+
+ b.HasIndex("ProductId");
+
+ b.HasIndex("PurchaseId");
+
+ b.HasIndex("ShoppingListItemId");
+
+ b.ToTable("PurchaseItems");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Retailer", b =>
+ {
+ b.Property("RetailerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.Property("WebsiteUrl")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RetailerId");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Retailers");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
+ {
+ b.Property("ShoppingListId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CompletedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedByUserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("HouseholdId")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ShoppingListId");
+
+ b.HasIndex("CreatedByUserId");
+
+ b.HasIndex("HouseholdId", "Status");
+
+ b.ToTable("ShoppingLists");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b =>
+ {
+ b.Property("ShoppingListItemId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("TEXT");
+
+ b.Property("GroceryConceptId")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("PurchasedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Quantity")
+ .HasColumnType("TEXT");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ShoppingListId")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("Unit")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ShoppingListItemId");
+
+ b.HasIndex("AddedByUserId");
+
+ b.HasIndex("GroceryConceptId");
+
+ b.HasIndex("ShoppingListId", "Status");
+
+ b.ToTable("ShoppingListItems");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b =>
+ {
+ b.Property("StoreLocationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddressLine1")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("City")
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.Property("CountryCode")
+ .HasMaxLength(2)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalStoreId")
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.Property("Latitude")
+ .HasColumnType("TEXT");
+
+ b.Property("Longitude")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("PostalCode")
+ .HasMaxLength(24)
+ .HasColumnType("TEXT");
+
+ b.Property("Region")
+ .HasMaxLength(80)
+ .HasColumnType("TEXT");
+
+ b.Property("RetailerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("StoreLocationId");
+
+ b.HasIndex("RetailerId");
+
+ b.ToTable("StoreLocations");
+ });
+
+ modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.ProductCategory", null)
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Household", b =>
+ {
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("CreatedByUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Household", null)
+ .WithMany("Members")
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.GroceryConcept", null)
+ .WithMany()
+ .HasForeignKey("GroceryConceptId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("PreferredProductId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("CartWise.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.StoreLocation", null)
+ .WithMany()
+ .HasForeignKey("StoreLocationId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Brand", null)
+ .WithMany()
+ .HasForeignKey("BrandId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("CartWise.Domain.Entities.GroceryConcept", null)
+ .WithMany()
+ .HasForeignKey("GroceryConceptId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.ProductCategory", null)
+ .WithMany()
+ .HasForeignKey("ParentCategoryId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Product", null)
+ .WithMany("Identifiers")
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("PurchasedByUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.StoreLocation", null)
+ .WithMany()
+ .HasForeignKey("StoreLocationId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.PurchaseItem", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.GroceryConcept", null)
+ .WithMany()
+ .HasForeignKey("GroceryConceptId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("CartWise.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("CartWise.Domain.Entities.Purchase", null)
+ .WithMany("Items")
+ .HasForeignKey("PurchaseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.ShoppingListItem", null)
+ .WithMany()
+ .HasForeignKey("ShoppingListItemId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
+ {
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("CreatedByUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b =>
+ {
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("AddedByUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.GroceryConcept", null)
+ .WithMany()
+ .HasForeignKey("GroceryConceptId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("CartWise.Domain.Entities.ShoppingList", null)
+ .WithMany("Items")
+ .HasForeignKey("ShoppingListId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Retailer", null)
+ .WithMany()
+ .HasForeignKey("RetailerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Household", b =>
+ {
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
+ {
+ b.Navigation("Identifiers");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b =>
+ {
+ b.Navigation("Items");
+ });
+
+ modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
+ {
+ b.Navigation("Items");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs
new file mode 100644
index 0000000..0f299f3
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191825_AddPriceObservations.cs
@@ -0,0 +1,83 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CartWise.Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddPriceObservations : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "PriceObservations",
+ columns: table => new
+ {
+ PriceObservationId = table.Column(type: "TEXT", nullable: false),
+ HouseholdId = table.Column(type: "TEXT", nullable: true),
+ ProductId = table.Column(type: "TEXT", nullable: false),
+ StoreLocationId = table.Column(type: "TEXT", nullable: true),
+ Price = table.Column(type: "TEXT", nullable: false),
+ SalePrice = table.Column(type: "TEXT", nullable: true),
+ UnitPrice = table.Column(type: "TEXT", nullable: true),
+ CurrencyCode = table.Column(type: "TEXT", maxLength: 3, nullable: false),
+ ObservedUtc = table.Column(type: "TEXT", nullable: false),
+ SourceType = table.Column(type: "INTEGER", nullable: false),
+ SourceReference = table.Column(type: "TEXT", maxLength: 200, nullable: true),
+ ConfidenceScore = table.Column(type: "TEXT", nullable: false),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_PriceObservations", x => x.PriceObservationId);
+ table.ForeignKey(
+ name: "FK_PriceObservations_Households_HouseholdId",
+ column: x => x.HouseholdId,
+ principalTable: "Households",
+ principalColumn: "HouseholdId",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_PriceObservations_Products_ProductId",
+ column: x => x.ProductId,
+ principalTable: "Products",
+ principalColumn: "ProductId",
+ onDelete: ReferentialAction.Restrict);
+ table.ForeignKey(
+ name: "FK_PriceObservations_StoreLocations_StoreLocationId",
+ column: x => x.StoreLocationId,
+ principalTable: "StoreLocations",
+ principalColumn: "StoreLocationId",
+ onDelete: ReferentialAction.SetNull);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PriceObservations_HouseholdId_ProductId_ObservedUtc",
+ table: "PriceObservations",
+ columns: new[] { "HouseholdId", "ProductId", "ObservedUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PriceObservations_ProductId_ObservedUtc",
+ table: "PriceObservations",
+ columns: new[] { "ProductId", "ObservedUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PriceObservations_ProductId_StoreLocationId_ObservedUtc",
+ table: "PriceObservations",
+ columns: new[] { "ProductId", "StoreLocationId", "ObservedUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PriceObservations_StoreLocationId",
+ table: "PriceObservations",
+ column: "StoreLocationId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "PriceObservations");
+ }
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
index a5bfdc1..bedea99 100644
--- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
+++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
@@ -159,6 +159,64 @@ namespace CartWise.Infrastructure.Data.Migrations
b.ToTable("HouseholdProductPreferences");
});
+ modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b =>
+ {
+ b.Property("PriceObservationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfidenceScore")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrencyCode")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("TEXT");
+
+ b.Property("HouseholdId")
+ .HasColumnType("TEXT");
+
+ b.Property("ObservedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Price")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("SalePrice")
+ .HasColumnType("TEXT");
+
+ b.Property("SourceReference")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("SourceType")
+ .HasColumnType("INTEGER");
+
+ b.Property("StoreLocationId")
+ .HasColumnType("TEXT");
+
+ b.Property("UnitPrice")
+ .HasColumnType("TEXT");
+
+ b.HasKey("PriceObservationId");
+
+ b.HasIndex("StoreLocationId");
+
+ b.HasIndex("ProductId", "ObservedUtc");
+
+ b.HasIndex("HouseholdId", "ProductId", "ObservedUtc");
+
+ b.HasIndex("ProductId", "StoreLocationId", "ObservedUtc");
+
+ b.ToTable("PriceObservations");
+ });
+
modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
{
b.Property("ProductId")
@@ -799,6 +857,25 @@ namespace CartWise.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.SetNull);
});
+ modelBuilder.Entity("CartWise.Domain.Entities.PriceObservation", b =>
+ {
+ b.HasOne("CartWise.Domain.Entities.Household", null)
+ .WithMany()
+ .HasForeignKey("HouseholdId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("CartWise.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CartWise.Domain.Entities.StoreLocation", null)
+ .WithMany()
+ .HasForeignKey("StoreLocationId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
{
b.HasOne("CartWise.Domain.Entities.Brand", null)
diff --git a/tests/CartWise.Domain.Tests/PriceObservationTests.cs b/tests/CartWise.Domain.Tests/PriceObservationTests.cs
new file mode 100644
index 0000000..674b360
--- /dev/null
+++ b/tests/CartWise.Domain.Tests/PriceObservationTests.cs
@@ -0,0 +1,58 @@
+using CartWise.Domain.Entities;
+using CartWise.Domain.Enums;
+using System.Reflection;
+
+namespace CartWise.Domain.Tests;
+
+public class PriceObservationTests
+{
+ private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void Constructor_SetsDefaultCurrencyCode()
+ {
+ var observation = new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now);
+
+ Assert.Equal("USD", observation.CurrencyCode);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsWhenProductIdIsEmpty()
+ {
+ Assert.Throws(() => new PriceObservation(Guid.Empty, 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsWhenPriceIsNegative()
+ {
+ Assert.Throws(() => new PriceObservation(Guid.NewGuid(), -1m, Now, PriceObservationSourceType.UserEntered, 1m, Now));
+ }
+
+ [Theory]
+ [InlineData(-0.01)]
+ [InlineData(1.01)]
+ public void Constructor_ThrowsWhenConfidenceScoreIsOutOfRange(decimal confidence)
+ {
+ Assert.Throws(() => new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, confidence, Now));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsWhenCurrencyCodeIsNotThreeLetters()
+ {
+ Assert.Throws(() => new PriceObservation(Guid.NewGuid(), 3.99m, Now, PriceObservationSourceType.UserEntered, 1m, Now, currencyCode: "US"));
+ }
+
+ [Fact]
+ public void Type_HasNoPublicMutationMethods()
+ {
+ // Append-only enforcement: nothing beyond the constructor may change an existing
+ // observation's state. If this test ever needs updating, the append-only invariant
+ // has likely been broken.
+ var publicMethods = typeof(PriceObservation)
+ .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+ .Where(m => !m.IsSpecialName) // exclude property getters
+ .ToList();
+
+ Assert.Empty(publicMethods);
+ }
+}