diff --git a/.claude/settings.json b/.claude/settings.json
index 7ee3e25..cdf216a 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -37,7 +37,8 @@
"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(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 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*)"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"
diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md
index e87169e..05f13d6 100644
--- a/docs/scrum-backlog.md
+++ b/docs/scrum-backlog.md
@@ -573,12 +573,14 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food
- EF configurations and migration are created
**Tasks**
-- [ ] Create `Purchase`
-- [ ] Create `PurchaseItem`
-- [ ] Add source type enum
-- [ ] Add EF configurations
-- [ ] Create migration
-- [ ] Add tests
+- [x] Create `Purchase`
+- [x] Create `PurchaseItem`
+- [x] Add source type enum
+- [x] Add EF configurations
+- [x] Create migration
+- [x] Add tests
+
+**Status:** Done — `Purchase` is the aggregate root for `PurchaseItem`, mirroring `ShoppingList`/`ShoppingListItem`'s split across stories: `Items` navigation exists now, but no `AddItem` composition method yet and `PurchaseItem`'s constructor stays `public` — both arrive in `CW-STORY-05.4` when the service layer actually needs them, exactly matching the `CW-STORY-03.1`→`03.3` precedent. `PurchaseSourceType` (`Manual`/`ShoppingMode`/`ReceiptImport`/`RetailerImport`) added. `PurchaseItem` enforces `Quantity > 0` and `LinePrice >= 0` as constructor invariants. `AddPurchases` migration verified applying cleanly. 11 domain tests.
### `CW-STORY-05.3` Model append-only price observations
**Release:** MVP
diff --git a/src/CartWise.Domain/Entities/Purchase.cs b/src/CartWise.Domain/Entities/Purchase.cs
new file mode 100644
index 0000000..0669c74
--- /dev/null
+++ b/src/CartWise.Domain/Entities/Purchase.cs
@@ -0,0 +1,61 @@
+using CartWise.Domain.Enums;
+
+namespace CartWise.Domain.Entities;
+
+public class Purchase
+{
+ private readonly List _items = [];
+
+ public Guid PurchaseId { get; private set; }
+
+ public Guid HouseholdId { get; private set; }
+
+ public Guid? StoreLocationId { get; private set; }
+
+ public string PurchasedByUserId { get; private set; } = null!;
+
+ public DateTime PurchasedUtc { get; private set; }
+
+ public decimal? Subtotal { get; private set; }
+
+ public decimal? Tax { get; private set; }
+
+ public decimal? Total { get; private set; }
+
+ public PurchaseSourceType SourceType { get; private set; }
+
+ public DateTime CreatedUtc { get; private set; }
+
+ public IReadOnlyCollection Items => _items.AsReadOnly();
+
+ private Purchase()
+ {
+ }
+
+ public Purchase(
+ Guid householdId,
+ string purchasedByUserId,
+ DateTime purchasedUtc,
+ DateTime createdUtc,
+ PurchaseSourceType sourceType = PurchaseSourceType.Manual,
+ Guid? storeLocationId = null)
+ {
+ if (householdId == Guid.Empty)
+ {
+ throw new ArgumentException("HouseholdId is required.", nameof(householdId));
+ }
+
+ if (string.IsNullOrWhiteSpace(purchasedByUserId))
+ {
+ throw new ArgumentException("PurchasedByUserId is required.", nameof(purchasedByUserId));
+ }
+
+ PurchaseId = Guid.NewGuid();
+ HouseholdId = householdId;
+ StoreLocationId = storeLocationId;
+ PurchasedByUserId = purchasedByUserId;
+ PurchasedUtc = purchasedUtc;
+ SourceType = sourceType;
+ CreatedUtc = createdUtc;
+ }
+}
diff --git a/src/CartWise.Domain/Entities/PurchaseItem.cs b/src/CartWise.Domain/Entities/PurchaseItem.cs
new file mode 100644
index 0000000..a78c8b5
--- /dev/null
+++ b/src/CartWise.Domain/Entities/PurchaseItem.cs
@@ -0,0 +1,75 @@
+namespace CartWise.Domain.Entities;
+
+public class PurchaseItem
+{
+ public Guid PurchaseItemId { get; private set; }
+
+ public Guid PurchaseId { get; private set; }
+
+ public Guid? ProductId { get; private set; }
+
+ public Guid? GroceryConceptId { get; private set; }
+
+ public Guid? ShoppingListItemId { get; private set; }
+
+ public string Description { get; private set; } = null!;
+
+ public decimal Quantity { get; private set; }
+
+ public string? Unit { get; private set; }
+
+ public decimal LinePrice { get; private set; }
+
+ public decimal? UnitPrice { get; private set; }
+
+ public DateTime CreatedUtc { get; private set; }
+
+ private PurchaseItem()
+ {
+ }
+
+ public PurchaseItem(
+ Guid purchaseId,
+ string description,
+ decimal linePrice,
+ DateTime createdUtc,
+ decimal quantity = 1m,
+ string? unit = null,
+ Guid? productId = null,
+ Guid? groceryConceptId = null,
+ Guid? shoppingListItemId = null,
+ decimal? unitPrice = null)
+ {
+ if (purchaseId == Guid.Empty)
+ {
+ throw new ArgumentException("PurchaseId is required.", nameof(purchaseId));
+ }
+
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ throw new ArgumentException("Description is required.", nameof(description));
+ }
+
+ if (quantity <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be greater than zero.");
+ }
+
+ if (linePrice < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(linePrice), "LinePrice cannot be negative.");
+ }
+
+ PurchaseItemId = Guid.NewGuid();
+ PurchaseId = purchaseId;
+ Description = description.Trim();
+ Quantity = quantity;
+ Unit = unit;
+ LinePrice = linePrice;
+ UnitPrice = unitPrice;
+ ProductId = productId;
+ GroceryConceptId = groceryConceptId;
+ ShoppingListItemId = shoppingListItemId;
+ CreatedUtc = createdUtc;
+ }
+}
diff --git a/src/CartWise.Domain/Enums/PurchaseSourceType.cs b/src/CartWise.Domain/Enums/PurchaseSourceType.cs
new file mode 100644
index 0000000..b0f7059
--- /dev/null
+++ b/src/CartWise.Domain/Enums/PurchaseSourceType.cs
@@ -0,0 +1,9 @@
+namespace CartWise.Domain.Enums;
+
+public enum PurchaseSourceType
+{
+ Manual = 0,
+ ShoppingMode = 1,
+ ReceiptImport = 2,
+ RetailerImport = 3
+}
diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
index 5359bcc..0bf6ece 100644
--- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
+++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
@@ -37,6 +37,10 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio
public DbSet StoreLocations => Set();
+ public DbSet Purchases => Set();
+
+ public DbSet PurchaseItems => Set();
+
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
diff --git a/src/CartWise.Infrastructure/Data/Configurations/PurchaseConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/PurchaseConfiguration.cs
new file mode 100644
index 0000000..914c926
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Configurations/PurchaseConfiguration.cs
@@ -0,0 +1,34 @@
+using CartWise.Domain.Entities;
+using CartWise.Infrastructure.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CartWise.Infrastructure.Data.Configurations;
+
+public class PurchaseConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(p => p.PurchaseId);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(p => p.HouseholdId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(p => p.StoreLocationId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(p => p.PurchasedByUserId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(p => new { p.HouseholdId, p.PurchasedUtc });
+
+ builder.Metadata.FindNavigation(nameof(Purchase.Items))!
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Configurations/PurchaseItemConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/PurchaseItemConfiguration.cs
new file mode 100644
index 0000000..f013d7a
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Configurations/PurchaseItemConfiguration.cs
@@ -0,0 +1,41 @@
+using CartWise.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CartWise.Infrastructure.Data.Configurations;
+
+public class PurchaseItemConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(i => i.PurchaseItemId);
+
+ builder.Property(i => i.Description)
+ .HasMaxLength(240)
+ .IsRequired();
+
+ builder.Property(i => i.Unit).HasMaxLength(32);
+
+ builder.HasOne()
+ .WithMany(p => p.Items)
+ .HasForeignKey(i => i.PurchaseId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(i => i.ProductId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(i => i.GroceryConceptId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(i => i.ShoppingListItemId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasIndex(i => i.ProductId);
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.Designer.cs
new file mode 100644
index 0000000..45dea81
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.Designer.cs
@@ -0,0 +1,996 @@
+//
+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("20260810191555_AddPurchases")]
+ partial class AddPurchases
+ {
+ ///
+ 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.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.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/20260810191555_AddPurchases.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.cs
new file mode 100644
index 0000000..89f1383
--- /dev/null
+++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.cs
@@ -0,0 +1,143 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CartWise.Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddPurchases : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Purchases",
+ columns: table => new
+ {
+ PurchaseId = table.Column(type: "TEXT", nullable: false),
+ HouseholdId = table.Column(type: "TEXT", nullable: false),
+ StoreLocationId = table.Column(type: "TEXT", nullable: true),
+ PurchasedByUserId = table.Column(type: "TEXT", nullable: false),
+ PurchasedUtc = table.Column(type: "TEXT", nullable: false),
+ Subtotal = table.Column(type: "TEXT", nullable: true),
+ Tax = table.Column(type: "TEXT", nullable: true),
+ Total = table.Column(type: "TEXT", nullable: true),
+ SourceType = table.Column(type: "INTEGER", nullable: false),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Purchases", x => x.PurchaseId);
+ table.ForeignKey(
+ name: "FK_Purchases_AspNetUsers_PurchasedByUserId",
+ column: x => x.PurchasedByUserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ table.ForeignKey(
+ name: "FK_Purchases_Households_HouseholdId",
+ column: x => x.HouseholdId,
+ principalTable: "Households",
+ principalColumn: "HouseholdId",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Purchases_StoreLocations_StoreLocationId",
+ column: x => x.StoreLocationId,
+ principalTable: "StoreLocations",
+ principalColumn: "StoreLocationId",
+ onDelete: ReferentialAction.SetNull);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "PurchaseItems",
+ columns: table => new
+ {
+ PurchaseItemId = table.Column(type: "TEXT", nullable: false),
+ PurchaseId = table.Column(type: "TEXT", nullable: false),
+ ProductId = table.Column(type: "TEXT", nullable: true),
+ GroceryConceptId = table.Column(type: "TEXT", nullable: true),
+ ShoppingListItemId = table.Column(type: "TEXT", nullable: true),
+ Description = table.Column(type: "TEXT", maxLength: 240, nullable: false),
+ Quantity = table.Column(type: "TEXT", nullable: false),
+ Unit = table.Column(type: "TEXT", maxLength: 32, nullable: true),
+ LinePrice = table.Column(type: "TEXT", nullable: false),
+ UnitPrice = table.Column(type: "TEXT", nullable: true),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_PurchaseItems", x => x.PurchaseItemId);
+ table.ForeignKey(
+ name: "FK_PurchaseItems_GroceryConcepts_GroceryConceptId",
+ column: x => x.GroceryConceptId,
+ principalTable: "GroceryConcepts",
+ principalColumn: "GroceryConceptId",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_PurchaseItems_Products_ProductId",
+ column: x => x.ProductId,
+ principalTable: "Products",
+ principalColumn: "ProductId",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_PurchaseItems_Purchases_PurchaseId",
+ column: x => x.PurchaseId,
+ principalTable: "Purchases",
+ principalColumn: "PurchaseId",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_PurchaseItems_ShoppingListItems_ShoppingListItemId",
+ column: x => x.ShoppingListItemId,
+ principalTable: "ShoppingListItems",
+ principalColumn: "ShoppingListItemId",
+ onDelete: ReferentialAction.SetNull);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PurchaseItems_GroceryConceptId",
+ table: "PurchaseItems",
+ column: "GroceryConceptId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PurchaseItems_ProductId",
+ table: "PurchaseItems",
+ column: "ProductId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PurchaseItems_PurchaseId",
+ table: "PurchaseItems",
+ column: "PurchaseId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PurchaseItems_ShoppingListItemId",
+ table: "PurchaseItems",
+ column: "ShoppingListItemId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Purchases_HouseholdId_PurchasedUtc",
+ table: "Purchases",
+ columns: new[] { "HouseholdId", "PurchasedUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Purchases_PurchasedByUserId",
+ table: "Purchases",
+ column: "PurchasedByUserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Purchases_StoreLocationId",
+ table: "Purchases",
+ column: "StoreLocationId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "PurchaseItems");
+
+ migrationBuilder.DropTable(
+ name: "Purchases");
+ }
+ }
+}
diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
index feb5c60..a5bfdc1 100644
--- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
+++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
@@ -268,6 +268,103 @@ namespace CartWise.Infrastructure.Data.Migrations
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")
@@ -732,6 +829,50 @@ namespace CartWise.Infrastructure.Data.Migrations
.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)
@@ -837,6 +978,11 @@ namespace CartWise.Infrastructure.Data.Migrations
b.Navigation("Identifiers");
});
+ modelBuilder.Entity("CartWise.Domain.Entities.Purchase", b =>
+ {
+ b.Navigation("Items");
+ });
+
modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
{
b.Navigation("Items");
diff --git a/tests/CartWise.Domain.Tests/PurchaseTests.cs b/tests/CartWise.Domain.Tests/PurchaseTests.cs
new file mode 100644
index 0000000..3427b60
--- /dev/null
+++ b/tests/CartWise.Domain.Tests/PurchaseTests.cs
@@ -0,0 +1,70 @@
+using CartWise.Domain.Entities;
+using CartWise.Domain.Enums;
+
+namespace CartWise.Domain.Tests;
+
+public class PurchaseTests
+{
+ private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void Constructor_DefaultsToManualSourceAndNoStore()
+ {
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+
+ Assert.Equal(PurchaseSourceType.Manual, purchase.SourceType);
+ Assert.Null(purchase.StoreLocationId);
+ Assert.Empty(purchase.Items);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsWhenHouseholdIdIsEmpty()
+ {
+ Assert.Throws(() => new Purchase(Guid.Empty, "user-1", Now, Now));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Constructor_ThrowsWhenPurchasedByUserIdIsMissing(string? userId)
+ {
+ Assert.Throws(() => new Purchase(Guid.NewGuid(), userId!, Now, Now));
+ }
+}
+
+public class PurchaseItemTests
+{
+ private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void Constructor_DefaultsQuantityToOne()
+ {
+ var item = new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now);
+
+ Assert.Equal(1m, item.Quantity);
+ Assert.Equal(3.99m, item.LinePrice);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Constructor_ThrowsWhenDescriptionIsMissing(string? description)
+ {
+ Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), description!, 3.99m, Now));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsWhenQuantityIsZeroOrNegative()
+ {
+ Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: 0));
+ Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: -1));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsWhenLinePriceIsNegative()
+ {
+ Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), "Milk", -1m, Now));
+ }
+}