diff --git a/.claude/settings.json b/.claude/settings.json
index 10b92ce..4f2f930 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -39,7 +39,8 @@
"Bash(sed -n '/
/,/Add to my list/p' /tmp/product.html)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify4.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify4.db\" 2>&1 | tail -10 && rm -f verify4.db*)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify5.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify5.db\" 2>&1 | tail -10 && rm -f verify5.db*)",
- "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify6.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify6.db\" 2>&1 | tail -10 && rm -f verify6.db*)"
+ "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify6.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify6.db\" 2>&1 | tail -10 && rm -f verify6.db*)",
+ "Bash(curl -s -b /tmp/cw6.txt http://127.0.0.1:5187/purchases -o /tmp/index2.html -w \"status=%{http_code}\\\\n\")"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"
diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md
index b4c1504..6d3c35f 100644
--- a/docs/scrum-backlog.md
+++ b/docs/scrum-backlog.md
@@ -615,12 +615,14 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food
- Shopping list items can be linked when relevant
**Tasks**
-- [ ] Create `IPurchaseService`
-- [ ] Implement start purchase flow
-- [ ] Implement add purchase item flow
-- [ ] Implement complete purchase flow
-- [ ] Link purchase items to shopping list items where applicable
-- [ ] Add tests
+- [x] Create `IPurchaseService`
+- [x] Implement start purchase flow
+- [x] Implement add purchase item flow
+- [x] Implement complete purchase flow
+- [x] Link purchase items to shopping list items where applicable
+- [x] Add tests
+
+**Status:** Done, resolved per `DEC-008` (manual purchase entry acceptable, list linkage can be simplified). `Purchase.AddItem`/`Complete` landed here as planned (see `CW-STORY-05.2`'s note); `Purchase.IsCompleted` (`Subtotal.HasValue`) is the completion signal — there's no separate `Status` field since AGENTS.md's `Purchase` spec doesn't define one. `AddPurchaseItemAsync` accepts an optional `shoppingListItemId` to set `PurchaseItem.ShoppingListItemId` (the FK link this task asks for); it does **not** also auto-mark the source `ShoppingListItem` as purchased — that's a UX nicety beyond what "link purchase items to shopping list items" literally asks for, and the current UI doesn't yet offer a way to pick a list item when adding a purchase item anyway (no product/list-item picker built — out of scope for this story). `PurchaseController`/`Views/Purchase/*` implement a simple single-page recording flow (start → add items one at a time, mirroring the proven `ShoppingList` UX pattern → complete, after which the page becomes read-only) rather than a multi-step wizard, keeping state entirely server-side with no session/multi-request complexity. Verified live end-to-end via `dotnet run` + curl: start → add "Milk" ($7.98) → complete → subtotal/total correct → add-item form disappears once completed → index list reflects the total. 7 application tests + 3 Web tests (including cross-household isolation — a different household's purchase 404s, not 200-with-empty-data).
### `CW-STORY-05.5` Derive price intelligence
**Release:** MVP
diff --git a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
index 19c6dd3..fa88ed5 100644
--- a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
+++ b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
@@ -12,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs
index 768339b..f41acfe 100644
--- a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs
+++ b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs
@@ -22,6 +22,14 @@ public interface IApplicationDbContext
DbSet ProductIdentifiers { get; }
+ DbSet StoreLocations { get; }
+
+ DbSet Purchases { get; }
+
+ DbSet PurchaseItems { get; }
+
+ DbSet PriceObservations { get; }
+
EntityEntry Entry(TEntity entity) where TEntity : class;
Task SaveChangesAsync(CancellationToken cancellationToken = default);
diff --git a/src/CartWise.Application/Interfaces/IPurchaseService.cs b/src/CartWise.Application/Interfaces/IPurchaseService.cs
new file mode 100644
index 0000000..13df435
--- /dev/null
+++ b/src/CartWise.Application/Interfaces/IPurchaseService.cs
@@ -0,0 +1,35 @@
+using CartWise.Application.Results;
+using CartWise.Domain.Entities;
+
+namespace CartWise.Application.Interfaces;
+
+public interface IPurchaseService
+{
+ Task> StartPurchaseAsync(
+ Guid householdId,
+ string purchasedByUserId,
+ DateTime purchasedUtc,
+ Guid? storeLocationId = null,
+ CancellationToken cancellationToken = default);
+
+ Task> AddPurchaseItemAsync(
+ Guid householdId,
+ Guid purchaseId,
+ string description,
+ decimal linePrice,
+ decimal quantity = 1m,
+ string? unit = null,
+ Guid? productId = null,
+ Guid? shoppingListItemId = null,
+ CancellationToken cancellationToken = default);
+
+ Task> CompletePurchaseAsync(
+ Guid householdId,
+ Guid purchaseId,
+ decimal? tax = null,
+ CancellationToken cancellationToken = default);
+
+ Task GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default);
+
+ Task> GetHouseholdPurchasesAsync(Guid householdId, CancellationToken cancellationToken = default);
+}
diff --git a/src/CartWise.Application/Services/PurchaseService.cs b/src/CartWise.Application/Services/PurchaseService.cs
new file mode 100644
index 0000000..749ea74
--- /dev/null
+++ b/src/CartWise.Application/Services/PurchaseService.cs
@@ -0,0 +1,135 @@
+using CartWise.Application.Interfaces;
+using CartWise.Application.Results;
+using CartWise.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace CartWise.Application.Services;
+
+public class PurchaseService : IPurchaseService
+{
+ private readonly IApplicationDbContext _db;
+ private readonly TimeProvider _timeProvider;
+
+ public PurchaseService(IApplicationDbContext db, TimeProvider timeProvider)
+ {
+ _db = db;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task> StartPurchaseAsync(
+ Guid householdId,
+ string purchasedByUserId,
+ DateTime purchasedUtc,
+ Guid? storeLocationId = null,
+ CancellationToken cancellationToken = default)
+ {
+ Purchase purchase;
+ try
+ {
+ purchase = new Purchase(householdId, purchasedByUserId, purchasedUtc, _timeProvider.GetUtcNow().UtcDateTime, storeLocationId: storeLocationId);
+ }
+ catch (ArgumentException ex)
+ {
+ return Result.Failure(ex.Message);
+ }
+
+ _db.Purchases.Add(purchase);
+ await _db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(purchase);
+ }
+
+ public async Task> AddPurchaseItemAsync(
+ Guid householdId,
+ Guid purchaseId,
+ string description,
+ decimal linePrice,
+ decimal quantity = 1m,
+ string? unit = null,
+ Guid? productId = null,
+ Guid? shoppingListItemId = null,
+ CancellationToken cancellationToken = default)
+ {
+ var purchase = await FindPurchaseForHouseholdAsync(householdId, purchaseId, cancellationToken);
+ if (purchase is null)
+ {
+ return Result.Failure("Purchase not found.");
+ }
+
+ PurchaseItem item;
+ try
+ {
+ item = purchase.AddItem(
+ description,
+ linePrice,
+ _timeProvider.GetUtcNow().UtcDateTime,
+ quantity: quantity,
+ unit: unit,
+ productId: productId,
+ shoppingListItemId: shoppingListItemId);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Result.Failure(ex.Message);
+ }
+
+ // Same EF Core tracking gotcha as ShoppingListService.AddItemAsync: a new child
+ // discovered only via a mutated navigation collection on an already-tracked parent
+ // needs to be explicitly marked, since PurchaseItemId is a client-generated Guid.
+ _db.PurchaseItems.Add(item);
+ await _db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(item);
+ }
+
+ public async Task> CompletePurchaseAsync(
+ Guid householdId,
+ Guid purchaseId,
+ decimal? tax = null,
+ CancellationToken cancellationToken = default)
+ {
+ var purchase = await FindPurchaseForHouseholdAsync(householdId, purchaseId, cancellationToken);
+ if (purchase is null)
+ {
+ return Result.Failure("Purchase not found.");
+ }
+
+ try
+ {
+ purchase.Complete(tax);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Result.Failure(ex.Message);
+ }
+
+ await _db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(purchase);
+ }
+
+ public async Task GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default)
+ {
+ return await _db.Purchases
+ .Include(p => p.Items)
+ .AsNoTracking()
+ .FirstOrDefaultAsync(p => p.PurchaseId == purchaseId && p.HouseholdId == householdId, cancellationToken);
+ }
+
+ public async Task> GetHouseholdPurchasesAsync(Guid householdId, CancellationToken cancellationToken = default)
+ {
+ return await _db.Purchases
+ .Include(p => p.Items)
+ .AsNoTracking()
+ .Where(p => p.HouseholdId == householdId)
+ .OrderByDescending(p => p.PurchasedUtc)
+ .ToListAsync(cancellationToken);
+ }
+
+ private Task FindPurchaseForHouseholdAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken)
+ {
+ return _db.Purchases
+ .Include(p => p.Items)
+ .FirstOrDefaultAsync(p => p.PurchaseId == purchaseId && p.HouseholdId == householdId, cancellationToken);
+ }
+}
diff --git a/src/CartWise.Domain/Entities/Purchase.cs b/src/CartWise.Domain/Entities/Purchase.cs
index 0669c74..7d1a9d8 100644
--- a/src/CartWise.Domain/Entities/Purchase.cs
+++ b/src/CartWise.Domain/Entities/Purchase.cs
@@ -58,4 +58,39 @@ public class Purchase
SourceType = sourceType;
CreatedUtc = createdUtc;
}
+
+ public bool IsCompleted => Subtotal.HasValue;
+
+ public PurchaseItem AddItem(
+ string description,
+ decimal linePrice,
+ DateTime addedUtc,
+ decimal quantity = 1m,
+ string? unit = null,
+ Guid? productId = null,
+ Guid? groceryConceptId = null,
+ Guid? shoppingListItemId = null,
+ decimal? unitPrice = null)
+ {
+ if (IsCompleted)
+ {
+ throw new InvalidOperationException("Cannot add items to a completed purchase.");
+ }
+
+ var item = new PurchaseItem(PurchaseId, description, linePrice, addedUtc, quantity, unit, productId, groceryConceptId, shoppingListItemId, unitPrice);
+ _items.Add(item);
+ return item;
+ }
+
+ public void Complete(decimal? tax = null)
+ {
+ if (IsCompleted)
+ {
+ throw new InvalidOperationException("Purchase is already completed.");
+ }
+
+ Subtotal = _items.Sum(i => i.LinePrice);
+ Tax = tax;
+ Total = Subtotal + (tax ?? 0m);
+ }
}
diff --git a/src/CartWise.Domain/Entities/PurchaseItem.cs b/src/CartWise.Domain/Entities/PurchaseItem.cs
index a78c8b5..987dd25 100644
--- a/src/CartWise.Domain/Entities/PurchaseItem.cs
+++ b/src/CartWise.Domain/Entities/PurchaseItem.cs
@@ -28,7 +28,7 @@ public class PurchaseItem
{
}
- public PurchaseItem(
+ internal PurchaseItem(
Guid purchaseId,
string description,
decimal linePrice,
diff --git a/src/CartWise.Web/Controllers/PurchaseController.cs b/src/CartWise.Web/Controllers/PurchaseController.cs
new file mode 100644
index 0000000..0b6086d
--- /dev/null
+++ b/src/CartWise.Web/Controllers/PurchaseController.cs
@@ -0,0 +1,161 @@
+using CartWise.Application.Interfaces;
+using CartWise.Infrastructure.Identity;
+using CartWise.Web.ViewModels.Purchase;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CartWise.Web.Controllers;
+
+[Authorize]
+[Route("purchases")]
+public class PurchaseController : Controller
+{
+ private readonly IPurchaseService _purchaseService;
+ private readonly IHouseholdService _householdService;
+ private readonly UserManager _userManager;
+
+ public PurchaseController(IPurchaseService purchaseService, IHouseholdService householdService, UserManager userManager)
+ {
+ _purchaseService = purchaseService;
+ _householdService = householdService;
+ _userManager = userManager;
+ }
+
+ [HttpGet("")]
+ public async Task Index()
+ {
+ var household = await CurrentHouseholdOrNullAsync();
+ if (household is null)
+ {
+ return RedirectToAction("Create", "Household");
+ }
+
+ var purchases = await _purchaseService.GetHouseholdPurchasesAsync(household.HouseholdId);
+
+ var viewModel = new PurchaseListViewModel
+ {
+ Purchases = purchases.Select(p => new PurchaseSummaryViewModel
+ {
+ PurchaseId = p.PurchaseId,
+ PurchasedUtc = p.PurchasedUtc,
+ IsCompleted = p.IsCompleted,
+ Total = p.Total,
+ ItemCount = p.Items.Count
+ }).ToList()
+ };
+
+ return View(viewModel);
+ }
+
+ [HttpPost("start")]
+ [ValidateAntiForgeryToken]
+ public async Task Start()
+ {
+ var userId = _userManager.GetUserId(User)!;
+ var household = await _householdService.GetCurrentHouseholdAsync(userId);
+ if (household is null)
+ {
+ return RedirectToAction("Create", "Household");
+ }
+
+ var result = await _purchaseService.StartPurchaseAsync(household.HouseholdId, userId, DateTime.UtcNow);
+ if (!result.IsSuccess)
+ {
+ return RedirectToAction(nameof(Index));
+ }
+
+ return RedirectToAction(nameof(Details), new { id = result.Value!.PurchaseId });
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task Details(Guid id)
+ {
+ var household = await CurrentHouseholdOrNullAsync();
+ if (household is null)
+ {
+ return RedirectToAction("Create", "Household");
+ }
+
+ var purchase = await _purchaseService.GetPurchaseAsync(household.HouseholdId, id);
+ if (purchase is null)
+ {
+ return NotFound();
+ }
+
+ return View(ToViewModel(purchase));
+ }
+
+ [HttpPost("{id:guid}/items")]
+ [ValidateAntiForgeryToken]
+ public async Task AddItem(Guid id, [Bind(Prefix = "NewItem")] AddPurchaseItemViewModel model)
+ {
+ var household = await CurrentHouseholdOrNullAsync();
+ if (household is null)
+ {
+ return RedirectToAction("Create", "Household");
+ }
+
+ var purchase = await _purchaseService.GetPurchaseAsync(household.HouseholdId, id);
+ if (purchase is null)
+ {
+ return NotFound();
+ }
+
+ if (!ModelState.IsValid)
+ {
+ var viewModel = ToViewModel(purchase);
+ viewModel.NewItem = model;
+ return View(nameof(Details), viewModel);
+ }
+
+ var result = await _purchaseService.AddPurchaseItemAsync(household.HouseholdId, id, model.Description, model.LinePrice, model.Quantity, model.Unit);
+ if (!result.IsSuccess)
+ {
+ ModelState.AddModelError(string.Empty, result.Error!);
+ var viewModel = ToViewModel(purchase);
+ viewModel.NewItem = model;
+ return View(nameof(Details), viewModel);
+ }
+
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ [HttpPost("{id:guid}/complete")]
+ [ValidateAntiForgeryToken]
+ public async Task Complete(Guid id)
+ {
+ var household = await CurrentHouseholdOrNullAsync();
+ if (household is null)
+ {
+ return RedirectToAction("Create", "Household");
+ }
+
+ await _purchaseService.CompletePurchaseAsync(household.HouseholdId, id);
+
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ private async Task CurrentHouseholdOrNullAsync()
+ {
+ var userId = _userManager.GetUserId(User)!;
+ return await _householdService.GetCurrentHouseholdAsync(userId);
+ }
+
+ private static PurchaseDetailsViewModel ToViewModel(Domain.Entities.Purchase purchase) => new()
+ {
+ PurchaseId = purchase.PurchaseId,
+ PurchasedUtc = purchase.PurchasedUtc,
+ IsCompleted = purchase.IsCompleted,
+ Subtotal = purchase.Subtotal,
+ Tax = purchase.Tax,
+ Total = purchase.Total,
+ Items = purchase.Items.Select(i => new PurchaseItemViewModel
+ {
+ Description = i.Description,
+ Quantity = i.Quantity,
+ Unit = i.Unit,
+ LinePrice = i.LinePrice
+ }).ToList()
+ };
+}
diff --git a/src/CartWise.Web/ViewModels/Purchase/PurchaseViewModels.cs b/src/CartWise.Web/ViewModels/Purchase/PurchaseViewModels.cs
new file mode 100644
index 0000000..e392f2b
--- /dev/null
+++ b/src/CartWise.Web/ViewModels/Purchase/PurchaseViewModels.cs
@@ -0,0 +1,70 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CartWise.Web.ViewModels.Purchase;
+
+public class PurchaseListViewModel
+{
+ public List Purchases { get; set; } = [];
+}
+
+public class PurchaseSummaryViewModel
+{
+ public Guid PurchaseId { get; set; }
+
+ public DateTime PurchasedUtc { get; set; }
+
+ public bool IsCompleted { get; set; }
+
+ public decimal? Total { get; set; }
+
+ public int ItemCount { get; set; }
+}
+
+public class PurchaseDetailsViewModel
+{
+ public Guid PurchaseId { get; set; }
+
+ public DateTime PurchasedUtc { get; set; }
+
+ public bool IsCompleted { get; set; }
+
+ public decimal? Subtotal { get; set; }
+
+ public decimal? Tax { get; set; }
+
+ public decimal? Total { get; set; }
+
+ public List Items { get; set; } = [];
+
+ public AddPurchaseItemViewModel NewItem { get; set; } = new();
+}
+
+public class PurchaseItemViewModel
+{
+ public string Description { get; set; } = string.Empty;
+
+ public decimal Quantity { get; set; }
+
+ public string? Unit { get; set; }
+
+ public decimal LinePrice { get; set; }
+}
+
+public class AddPurchaseItemViewModel
+{
+ [Required]
+ [Display(Name = "Item")]
+ [StringLength(240, MinimumLength = 1)]
+ public string Description { get; set; } = string.Empty;
+
+ [Range(0.001, 100000)]
+ public decimal Quantity { get; set; } = 1m;
+
+ [StringLength(32)]
+ public string? Unit { get; set; }
+
+ [Required]
+ [Range(0, 100000)]
+ [Display(Name = "Price")]
+ public decimal LinePrice { get; set; }
+}
diff --git a/src/CartWise.Web/Views/Purchase/Details.cshtml b/src/CartWise.Web/Views/Purchase/Details.cshtml
new file mode 100644
index 0000000..84faf63
--- /dev/null
+++ b/src/CartWise.Web/Views/Purchase/Details.cshtml
@@ -0,0 +1,83 @@
+@model CartWise.Web.ViewModels.Purchase.PurchaseDetailsViewModel
+@{
+ ViewData["Title"] = "Purchase";
+}
+
+Purchase — @Model.PurchasedUtc.ToLocalTime().ToString("MMMM d, yyyy")
+@(Model.IsCompleted ? "Completed" : "In progress")
+
+@if (Model.Items.Count == 0)
+{
+ No items yet.
+}
+else
+{
+
+
+
+ | Item |
+ Quantity |
+ Price |
+
+
+
+ @foreach (var item in Model.Items)
+ {
+
+ | @item.Description |
+ @item.Quantity.ToString("0.###")@(string.IsNullOrEmpty(item.Unit) ? "" : $" {item.Unit}") |
+ @item.LinePrice.ToString("C") |
+
+ }
+
+
+}
+
+@if (!Model.IsCompleted)
+{
+
+
+
+}
+else
+{
+
+ - Subtotal
+ - @(Model.Subtotal?.ToString("C") ?? "—")
+ - Tax
+ - @(Model.Tax?.ToString("C") ?? "—")
+ - Total
+ - @(Model.Total?.ToString("C") ?? "—")
+
+}
+
+@section Scripts {
+ @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
+}
diff --git a/src/CartWise.Web/Views/Purchase/Index.cshtml b/src/CartWise.Web/Views/Purchase/Index.cshtml
new file mode 100644
index 0000000..af891c3
--- /dev/null
+++ b/src/CartWise.Web/Views/Purchase/Index.cshtml
@@ -0,0 +1,39 @@
+@model CartWise.Web.ViewModels.Purchase.PurchaseListViewModel
+@{
+ ViewData["Title"] = "Purchases";
+}
+
+Purchases
+
+
+
+@if (Model.Purchases.Count == 0)
+{
+ No purchases recorded yet.
+}
+else
+{
+
+}
diff --git a/src/CartWise.Web/Views/Shared/_Layout.cshtml b/src/CartWise.Web/Views/Shared/_Layout.cshtml
index 3ef92ce..ddbf6b8 100644
--- a/src/CartWise.Web/Views/Shared/_Layout.cshtml
+++ b/src/CartWise.Web/Views/Shared/_Layout.cshtml
@@ -34,6 +34,9 @@
Scan
+
+ Purchases
+
}
Privacy
diff --git a/tests/CartWise.Application.Tests/PurchaseServiceTests.cs b/tests/CartWise.Application.Tests/PurchaseServiceTests.cs
new file mode 100644
index 0000000..72cdf77
--- /dev/null
+++ b/tests/CartWise.Application.Tests/PurchaseServiceTests.cs
@@ -0,0 +1,117 @@
+using CartWise.Application.Services;
+using CartWise.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace CartWise.Application.Tests;
+
+public class PurchaseServiceTests : IDisposable
+{
+ private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ private readonly CartWiseDbContext _db;
+ private readonly PurchaseService _sut;
+
+ public PurchaseServiceTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+
+ _db = new CartWiseDbContext(options);
+ _sut = new PurchaseService(_db, TimeProvider.System);
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public async Task StartPurchaseAsync_CreatesPurchase()
+ {
+ var householdId = Guid.NewGuid();
+
+ var result = await _sut.StartPurchaseAsync(householdId, "user-1", Now);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(householdId, result.Value!.HouseholdId);
+ Assert.False(result.Value.IsCompleted);
+ }
+
+ [Fact]
+ public async Task AddPurchaseItemAsync_AddsItemAndPersistsIt()
+ {
+ var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
+
+ var result = await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Milk", 3.99m);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("Milk", result.Value!.Description);
+
+ var reloaded = await _sut.GetPurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId);
+ Assert.Single(reloaded!.Items);
+ }
+
+ [Fact]
+ public async Task AddPurchaseItemAsync_FailsForPurchaseInAnotherHousehold()
+ {
+ var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
+
+ var result = await _sut.AddPurchaseItemAsync(Guid.NewGuid(), started.Value!.PurchaseId, "Milk", 3.99m);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task AddPurchaseItemAsync_FailsWhenPurchaseAlreadyCompleted()
+ {
+ var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
+ await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Milk", 3.99m);
+ await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId);
+
+ var result = await _sut.AddPurchaseItemAsync(started.Value.HouseholdId, started.Value.PurchaseId, "Bread", 2.5m);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task CompletePurchaseAsync_ComputesSubtotalAndTotal()
+ {
+ var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
+ await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Milk", 3.99m);
+ await _sut.AddPurchaseItemAsync(started.Value.HouseholdId, started.Value.PurchaseId, "Bread", 2.50m);
+
+ var result = await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId, tax: 0.50m);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(6.49m, result.Value!.Subtotal);
+ Assert.Equal(0.50m, result.Value.Tax);
+ Assert.Equal(6.99m, result.Value.Total);
+ }
+
+ [Fact]
+ public async Task CompletePurchaseAsync_FailsForPurchaseInAnotherHousehold()
+ {
+ var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
+
+ var result = await _sut.CompletePurchaseAsync(Guid.NewGuid(), started.Value!.PurchaseId);
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task GetHouseholdPurchasesAsync_ReturnsOnlyThatHouseholdsPurchasesNewestFirst()
+ {
+ var householdA = Guid.NewGuid();
+ var householdB = Guid.NewGuid();
+ await _sut.StartPurchaseAsync(householdA, "user-1", Now.AddDays(-1));
+ await _sut.StartPurchaseAsync(householdA, "user-1", Now);
+ await _sut.StartPurchaseAsync(householdB, "user-2", Now);
+
+ var results = await _sut.GetHouseholdPurchasesAsync(householdA);
+
+ Assert.Equal(2, results.Count);
+ Assert.True(results[0].PurchasedUtc >= results[1].PurchasedUtc);
+ }
+}
diff --git a/tests/CartWise.Domain.Tests/PurchaseTests.cs b/tests/CartWise.Domain.Tests/PurchaseTests.cs
index 3427b60..d11ddcd 100644
--- a/tests/CartWise.Domain.Tests/PurchaseTests.cs
+++ b/tests/CartWise.Domain.Tests/PurchaseTests.cs
@@ -15,6 +15,7 @@ public class PurchaseTests
Assert.Equal(PurchaseSourceType.Manual, purchase.SourceType);
Assert.Null(purchase.StoreLocationId);
Assert.Empty(purchase.Items);
+ Assert.False(purchase.IsCompleted);
}
[Fact]
@@ -31,40 +32,92 @@ public class PurchaseTests
{
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()
+ public void AddItem_DefaultsQuantityToOne()
{
- var item = new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now);
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+
+ var item = purchase.AddItem("Milk", 3.99m, Now);
Assert.Equal(1m, item.Quantity);
Assert.Equal(3.99m, item.LinePrice);
+ Assert.Single(purchase.Items);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
- public void Constructor_ThrowsWhenDescriptionIsMissing(string? description)
+ public void AddItem_ThrowsWhenDescriptionIsMissing(string? description)
+ {
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+
+ Assert.Throws(() => purchase.AddItem(description!, 3.99m, Now));
+ }
+
+ [Fact]
+ public void AddItem_ThrowsWhenQuantityIsZeroOrNegative()
+ {
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+
+ Assert.Throws(() => purchase.AddItem("Milk", 3.99m, Now, quantity: 0));
+ Assert.Throws(() => purchase.AddItem("Milk", 3.99m, Now, quantity: -1));
+ }
+
+ [Fact]
+ public void AddItem_ThrowsWhenLinePriceIsNegative()
+ {
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+
+ Assert.Throws(() => purchase.AddItem("Milk", -1m, Now));
+ }
+
+ [Fact]
+ public void AddItem_ThrowsOnCompletedPurchase()
{
- Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), description!, 3.99m, Now));
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+ purchase.AddItem("Milk", 3.99m, Now);
+ purchase.Complete();
+
+ Assert.Throws(() => purchase.AddItem("Bread", 2.5m, Now));
}
[Fact]
- public void Constructor_ThrowsWhenQuantityIsZeroOrNegative()
+ public void Complete_SumsLinePricesIntoSubtotalAndTotal()
{
- Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: 0));
- Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: -1));
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+ purchase.AddItem("Milk", 3.99m, Now);
+ purchase.AddItem("Bread", 2.50m, Now);
+
+ purchase.Complete();
+
+ Assert.Equal(6.49m, purchase.Subtotal);
+ Assert.Equal(6.49m, purchase.Total);
+ Assert.Null(purchase.Tax);
+ Assert.True(purchase.IsCompleted);
}
[Fact]
- public void Constructor_ThrowsWhenLinePriceIsNegative()
+ public void Complete_AddsTaxToTotalWhenProvided()
{
- Assert.Throws(() => new PurchaseItem(Guid.NewGuid(), "Milk", -1m, Now));
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+ purchase.AddItem("Milk", 10m, Now);
+
+ purchase.Complete(tax: 0.80m);
+
+ Assert.Equal(10m, purchase.Subtotal);
+ Assert.Equal(0.80m, purchase.Tax);
+ Assert.Equal(10.80m, purchase.Total);
+ }
+
+ [Fact]
+ public void Complete_ThrowsWhenAlreadyCompleted()
+ {
+ var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
+ purchase.AddItem("Milk", 3.99m, Now);
+ purchase.Complete();
+
+ Assert.Throws(() => purchase.Complete());
}
}
diff --git a/tests/CartWise.Web.Tests/PurchaseAccessControlTests.cs b/tests/CartWise.Web.Tests/PurchaseAccessControlTests.cs
new file mode 100644
index 0000000..2484d8d
--- /dev/null
+++ b/tests/CartWise.Web.Tests/PurchaseAccessControlTests.cs
@@ -0,0 +1,94 @@
+using System.Net;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace CartWise.Web.Tests;
+
+public class PurchaseAccessControlTests : IClassFixture
+{
+ private readonly CartWiseWebApplicationFactory _factory;
+
+ public PurchaseAccessControlTests(CartWiseWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingPurchases()
+ {
+ var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
+
+ var response = await client.GetAsync("/purchases");
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Assert.Contains("/Account/Login", response.Headers.Location?.ToString());
+ }
+
+ [Fact]
+ public async Task RecordPurchase_FullFlow_AddsItemAndCompletes()
+ {
+ var client = _factory.CreateClient();
+ await WebTestHelpers.RegisterAsync(client, "purchaseflow1@example.com", "Purchase Flow User");
+ await WebTestHelpers.CreateHouseholdAsync(client, "Purchase Flow Household");
+
+ var indexHtml = await client.GetStringAsync("/purchases");
+ var startToken = WebTestHelpers.ExtractAntiForgeryToken(indexHtml);
+ var startResponse = await client.PostAsync("/purchases/start", new FormUrlEncodedContent(new Dictionary
+ {
+ ["__RequestVerificationToken"] = startToken
+ }));
+ startResponse.EnsureSuccessStatusCode();
+ var purchaseUrl = startResponse.RequestMessage!.RequestUri!.AbsolutePath;
+
+ var detailHtml = await client.GetStringAsync(purchaseUrl);
+ HtmlAssert.Contains("In progress", detailHtml);
+ var itemToken = WebTestHelpers.ExtractAntiForgeryToken(detailHtml);
+
+ var addItemResponse = await client.PostAsync($"{purchaseUrl}/items", new FormUrlEncodedContent(new Dictionary
+ {
+ ["__RequestVerificationToken"] = itemToken,
+ ["NewItem.Description"] = "Milk",
+ ["NewItem.Quantity"] = "2",
+ ["NewItem.Unit"] = "gal",
+ ["NewItem.LinePrice"] = "7.98"
+ }));
+ addItemResponse.EnsureSuccessStatusCode();
+
+ var afterAddHtml = await addItemResponse.Content.ReadAsStringAsync();
+ HtmlAssert.Contains("Milk", afterAddHtml);
+
+ var completeToken = WebTestHelpers.ExtractAntiForgeryToken(afterAddHtml);
+ var completeResponse = await client.PostAsync($"{purchaseUrl}/complete", new FormUrlEncodedContent(new Dictionary
+ {
+ ["__RequestVerificationToken"] = completeToken
+ }));
+ completeResponse.EnsureSuccessStatusCode();
+
+ var finalHtml = await completeResponse.Content.ReadAsStringAsync();
+ HtmlAssert.Contains("Completed", finalHtml);
+ HtmlAssert.Contains("$7.98", finalHtml);
+ }
+
+ [Fact]
+ public async Task EachHousehold_OnlySeesItsOwnPurchases()
+ {
+ var clientA = _factory.CreateClient();
+ var clientB = _factory.CreateClient();
+ await WebTestHelpers.RegisterAsync(clientA, "purchasealice@example.com", "Purchase Alice");
+ await WebTestHelpers.RegisterAsync(clientB, "purchasebob@example.com", "Purchase Bob");
+ await WebTestHelpers.CreateHouseholdAsync(clientA, "Purchase Household A");
+ await WebTestHelpers.CreateHouseholdAsync(clientB, "Purchase Household B");
+
+ var indexA = await clientA.GetStringAsync("/purchases");
+ var tokenA = WebTestHelpers.ExtractAntiForgeryToken(indexA);
+ var startResponseA = await clientA.PostAsync("/purchases/start", new FormUrlEncodedContent(new Dictionary
+ {
+ ["__RequestVerificationToken"] = tokenA
+ }));
+ var purchaseUrlA = startResponseA.RequestMessage!.RequestUri!.AbsolutePath;
+ var purchaseIdA = purchaseUrlA.Split('/').Last();
+
+ var response = await clientB.GetAsync($"/purchases/{purchaseIdA}");
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}