diff --git a/.claude/settings.json b/.claude/settings.json
index c1dd4cb..7ee3e25 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -36,7 +36,8 @@
"Bash(curl -s -m 8 \"https://world.openfoodfacts.org/api/v2/product/3017620422003.json\")",
"Bash(curl -s -b /tmp/cw5.txt -m 15 http://127.0.0.1:5187/api/products/barcode/3017620422003)",
"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(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*)"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"
diff --git a/docs/decision-log.md b/docs/decision-log.md
index 0566bb6..bdb3797 100644
--- a/docs/decision-log.md
+++ b/docs/decision-log.md
@@ -105,3 +105,11 @@ This file records product, scope, architecture, and delivery decisions for CartW
- Reason: Explicit founder direction, given after being asked directly whether to keep the MVP-first order or override it.
- Impact: `CW-EPIC-04` (Product Catalog and Barcode Resolution) and `CW-EPIC-06` (Shopping Mode) are no longer sequenced after MVP — they will be built next, ahead of `CW-EPIC-05`/`07`. `DEC-003` (barcode out of MVP), `DEC-004` (external providers out of MVP), and `DEC-007` (shopping mode may be deferred) remain valid as *scope* decisions — none of that work becomes MVP-required by this change — but their *sequencing* guidance is superseded. Individual stories within `04`/`06` that carry their own explicit Decision Gate (e.g. "confirm whether a product catalog is still needed," "Open Food Facts integration," "camera scan flow") will still be raised with the founder individually as implementation reaches them; this decision only resolves ordering, not each gate's content. Building substantial Post-MVP scope now will likely push completion past the September 10, 2026 MVP target.
- Revisit Trigger: If schedule pressure toward September 10, 2026 becomes acute, reconsider reverting to MVP-first sequencing for whatever remains.
+
+### DEC-011 - Store tracking is optional on Purchase for MVP
+- Date: 2026-08-10
+- Status: Accepted
+- Decision: `Purchase.StoreLocationId` is a nullable FK. Recording a purchase never requires picking a store; a household can optionally attach one once `StoreLocation`s exist.
+- Reason: `CW-STORY-05.1`'s decision gate asked whether store tracking should be minimal/optional or required. Founder chose optional — no "create a store" step should block recording the first purchase.
+- Impact: `PriceObservation.StoreLocationId` is likewise nullable (already specified that way in AGENTS.md §5.6). Store-scoped price comparisons ("cheapest at Store X") remain possible later for whichever purchases did specify a store, without requiring it retroactively.
+- Revisit Trigger: If store-level price comparison becomes a core MVP feature rather than a nice-to-have.
diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md
index 0d42035..e87169e 100644
--- a/docs/scrum-backlog.md
+++ b/docs/scrum-backlog.md
@@ -553,11 +553,13 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food
- EF configurations and migration are created
**Tasks**
-- [ ] Create `Retailer`
-- [ ] Create `StoreLocation`
-- [ ] Add EF configurations
-- [ ] Create migration
-- [ ] Add tests for basic persistence rules
+- [x] Create `Retailer`
+- [x] Create `StoreLocation`
+- [x] Add EF configurations
+- [x] Create migration
+- [x] Add tests for basic persistence rules
+
+**Status:** Done — per `DEC-011`, store tracking is optional everywhere it appears (`Purchase.StoreLocationId`/`PriceObservation.StoreLocationId` are nullable, as AGENTS.md §5.6 already specified for the latter). `Retailer`/`StoreLocation` are plain independent entities (no aggregate relationship needed — a store isn't composed/protected by its retailer the way `ShoppingListItem` is by `ShoppingList`). `AddRetailersAndStores` migration verified applying cleanly. 9 domain tests.
### `CW-STORY-05.2` Model purchases and purchase items
**Release:** MVP
diff --git a/src/CartWise.Domain/Entities/Retailer.cs b/src/CartWise.Domain/Entities/Retailer.cs
new file mode 100644
index 0000000..224a9c8
--- /dev/null
+++ b/src/CartWise.Domain/Entities/Retailer.cs
@@ -0,0 +1,29 @@
+namespace CartWise.Domain.Entities;
+
+public class Retailer
+{
+ public Guid RetailerId { get; private set; }
+
+ public string Name { get; private set; } = null!;
+
+ public string NormalizedName { get; private set; } = null!;
+
+ public string? WebsiteUrl { get; private set; }
+
+ private Retailer()
+ {
+ }
+
+ public Retailer(string name, string? websiteUrl = null)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Retailer name is required.", nameof(name));
+ }
+
+ RetailerId = Guid.NewGuid();
+ Name = name.Trim();
+ NormalizedName = Name.ToUpperInvariant();
+ WebsiteUrl = websiteUrl;
+ }
+}
diff --git a/src/CartWise.Domain/Entities/StoreLocation.cs b/src/CartWise.Domain/Entities/StoreLocation.cs
new file mode 100644
index 0000000..4e6f82a
--- /dev/null
+++ b/src/CartWise.Domain/Entities/StoreLocation.cs
@@ -0,0 +1,76 @@
+namespace CartWise.Domain.Entities;
+
+public class StoreLocation
+{
+ public Guid StoreLocationId { get; private set; }
+
+ public Guid RetailerId { get; private set; }
+
+ public string? ExternalStoreId { get; private set; }
+
+ public string Name { get; private set; } = null!;
+
+ public string? AddressLine1 { get; private set; }
+
+ public string? AddressLine2 { get; private set; }
+
+ public string? City { get; private set; }
+
+ public string? Region { get; private set; }
+
+ public string? PostalCode { get; private set; }
+
+ public string? CountryCode { get; private set; }
+
+ public decimal? Latitude { get; private set; }
+
+ public decimal? Longitude { get; private set; }
+
+ public DateTime CreatedUtc { get; private set; }
+
+ public DateTime UpdatedUtc { get; private set; }
+
+ private StoreLocation()
+ {
+ }
+
+ public StoreLocation(
+ Guid retailerId,
+ string name,
+ DateTime createdUtc,
+ string? externalStoreId = null,
+ string? addressLine1 = null,
+ string? addressLine2 = null,
+ string? city = null,
+ string? region = null,
+ string? postalCode = null,
+ string? countryCode = null,
+ decimal? latitude = null,
+ decimal? longitude = null)
+ {
+ if (retailerId == Guid.Empty)
+ {
+ throw new ArgumentException("RetailerId is required.", nameof(retailerId));
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Store name is required.", nameof(name));
+ }
+
+ StoreLocationId = Guid.NewGuid();
+ RetailerId = retailerId;
+ Name = name.Trim();
+ ExternalStoreId = externalStoreId;
+ AddressLine1 = addressLine1;
+ AddressLine2 = addressLine2;
+ City = city;
+ Region = region;
+ PostalCode = postalCode;
+ CountryCode = countryCode;
+ Latitude = latitude;
+ Longitude = longitude;
+ CreatedUtc = createdUtc;
+ UpdatedUtc = createdUtc;
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
index d74bfed..5359bcc 100644
--- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
+++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
@@ -33,6 +33,10 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio
public DbSet HouseholdProductPreferences => Set();
+ public DbSet Retailers => Set();
+
+ public DbSet StoreLocations => Set();
+
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
diff --git a/src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs
new file mode 100644
index 0000000..40605d6
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs
@@ -0,0 +1,24 @@
+using CartWise.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CartWise.Infrastructure.Data.Configurations;
+
+public class RetailerConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(r => r.RetailerId);
+
+ builder.Property(r => r.Name)
+ .HasMaxLength(160)
+ .IsRequired();
+
+ builder.Property(r => r.NormalizedName)
+ .HasMaxLength(160)
+ .IsRequired();
+
+ builder.HasIndex(r => r.NormalizedName)
+ .IsUnique();
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs
new file mode 100644
index 0000000..ec995cb
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs
@@ -0,0 +1,32 @@
+using CartWise.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CartWise.Infrastructure.Data.Configurations;
+
+public class StoreLocationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(s => s.StoreLocationId);
+
+ builder.Property(s => s.Name)
+ .HasMaxLength(200)
+ .IsRequired();
+
+ builder.Property(s => s.ExternalStoreId).HasMaxLength(120);
+ builder.Property(s => s.AddressLine1).HasMaxLength(200);
+ builder.Property(s => s.AddressLine2).HasMaxLength(200);
+ builder.Property(s => s.City).HasMaxLength(120);
+ builder.Property(s => s.Region).HasMaxLength(80);
+ builder.Property(s => s.PostalCode).HasMaxLength(24);
+ builder.Property(s => s.CountryCode).HasMaxLength(2);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(s => s.RetailerId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasIndex(s => s.RetailerId);
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs
new file mode 100644
index 0000000..381d2c3
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs
@@ -0,0 +1,850 @@
+//
+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("20260810191230_AddRetailersAndStores")]
+ partial class AddRetailersAndStores
+ {
+ ///
+ 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.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.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.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.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.ShoppingList", b =>
+ {
+ b.Navigation("Items");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs
new file mode 100644
index 0000000..6e62e4b
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs
@@ -0,0 +1,80 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CartWise.Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddRetailersAndStores : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Retailers",
+ columns: table => new
+ {
+ RetailerId = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 160, nullable: false),
+ NormalizedName = table.Column(type: "TEXT", maxLength: 160, nullable: false),
+ WebsiteUrl = table.Column(type: "TEXT", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Retailers", x => x.RetailerId);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "StoreLocations",
+ columns: table => new
+ {
+ StoreLocationId = table.Column(type: "TEXT", nullable: false),
+ RetailerId = table.Column(type: "TEXT", nullable: false),
+ ExternalStoreId = table.Column(type: "TEXT", maxLength: 120, nullable: true),
+ Name = table.Column(type: "TEXT", maxLength: 200, nullable: false),
+ AddressLine1 = table.Column(type: "TEXT", maxLength: 200, nullable: true),
+ AddressLine2 = table.Column(type: "TEXT", maxLength: 200, nullable: true),
+ City = table.Column(type: "TEXT", maxLength: 120, nullable: true),
+ Region = table.Column(type: "TEXT", maxLength: 80, nullable: true),
+ PostalCode = table.Column(type: "TEXT", maxLength: 24, nullable: true),
+ CountryCode = table.Column(type: "TEXT", maxLength: 2, nullable: true),
+ Latitude = table.Column(type: "TEXT", nullable: true),
+ Longitude = table.Column(type: "TEXT", nullable: true),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false),
+ UpdatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_StoreLocations", x => x.StoreLocationId);
+ table.ForeignKey(
+ name: "FK_StoreLocations_Retailers_RetailerId",
+ column: x => x.RetailerId,
+ principalTable: "Retailers",
+ principalColumn: "RetailerId",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Retailers_NormalizedName",
+ table: "Retailers",
+ column: "NormalizedName",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_StoreLocations_RetailerId",
+ table: "StoreLocations",
+ column: "RetailerId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "StoreLocations");
+
+ migrationBuilder.DropTable(
+ name: "Retailers");
+ }
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
index dd78696..feb5c60 100644
--- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
+++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
@@ -268,6 +268,33 @@ namespace CartWise.Infrastructure.Data.Migrations
b.ToTable("ProductIdentifiers");
});
+ 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")
@@ -363,6 +390,67 @@ namespace CartWise.Infrastructure.Data.Migrations
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")
@@ -679,6 +767,15 @@ namespace CartWise.Infrastructure.Data.Migrations
.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)
diff --git a/tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs b/tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs
new file mode 100644
index 0000000..099ff96
--- /dev/null
+++ b/tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs
@@ -0,0 +1,53 @@
+using CartWise.Domain.Entities;
+
+namespace CartWise.Domain.Tests;
+
+public class RetailerAndStoreLocationTests
+{
+ private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void Retailer_Constructor_SetsNormalizedName()
+ {
+ var retailer = new Retailer("Trader Joe's");
+
+ Assert.Equal("TRADER JOE'S", retailer.NormalizedName);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Retailer_Constructor_ThrowsWhenNameIsMissing(string? name)
+ {
+ Assert.Throws(() => new Retailer(name!));
+ }
+
+ [Fact]
+ public void StoreLocation_Constructor_SetsRequiredFields()
+ {
+ var retailer = new Retailer("Trader Joe's");
+
+ var store = new StoreLocation(retailer.RetailerId, "Downtown", Now, city: "Springfield");
+
+ Assert.Equal(retailer.RetailerId, store.RetailerId);
+ Assert.Equal("Downtown", store.Name);
+ Assert.Equal("Springfield", store.City);
+ Assert.Equal(Now, store.CreatedUtc);
+ }
+
+ [Fact]
+ public void StoreLocation_Constructor_ThrowsWhenRetailerIdIsEmpty()
+ {
+ Assert.Throws(() => new StoreLocation(Guid.Empty, "Downtown", Now));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void StoreLocation_Constructor_ThrowsWhenNameIsMissing(string? name)
+ {
+ Assert.Throws(() => new StoreLocation(Guid.NewGuid(), name!, Now));
+ }
+}