From ecc883d4bf09fe3ca58233e23a661373083ac114 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 10 Aug 2026 15:17:06 -0400 Subject: [PATCH] CW-STORY-05.2: model purchases and purchase items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purchase (aggregate root) and PurchaseItem per AGENTS.md §5.6. AddItem composition + tightened PurchaseItem ctor deferred to CW-STORY-05.4, mirroring the CW-STORY-03.1/03.3 split. Co-Authored-By: Claude Sonnet 5 --- .claude/settings.json | 3 +- docs/scrum-backlog.md | 14 +- src/CartWise.Domain/Entities/Purchase.cs | 61 ++ src/CartWise.Domain/Entities/PurchaseItem.cs | 75 ++ .../Enums/PurchaseSourceType.cs | 9 + .../Data/CartWiseDbContext.cs | 4 + .../Configurations/PurchaseConfiguration.cs | 34 + .../PurchaseItemConfiguration.cs | 41 + .../20260810191555_AddPurchases.Designer.cs | 996 ++++++++++++++++++ .../Migrations/20260810191555_AddPurchases.cs | 143 +++ .../CartWiseDbContextModelSnapshot.cs | 146 +++ tests/CartWise.Domain.Tests/PurchaseTests.cs | 70 ++ 12 files changed, 1589 insertions(+), 7 deletions(-) create mode 100644 src/CartWise.Domain/Entities/Purchase.cs create mode 100644 src/CartWise.Domain/Entities/PurchaseItem.cs create mode 100644 src/CartWise.Domain/Enums/PurchaseSourceType.cs create mode 100644 src/CartWise.Infrastructure/Data/Configurations/PurchaseConfiguration.cs create mode 100644 src/CartWise.Infrastructure/Data/Configurations/PurchaseItemConfiguration.cs create mode 100644 src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.Designer.cs create mode 100644 src/CartWise.Infrastructure/Data/Migrations/20260810191555_AddPurchases.cs create mode 100644 tests/CartWise.Domain.Tests/PurchaseTests.cs 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)); + } +}