IPurchaseService (start/add-item/complete) + Purchase.AddItem/Complete domain methods. PurchaseController implements a single-page recording flow mirroring the proven ShoppingList UX pattern. Verified live end-to-end via dotnet run + curl. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -39,7 +39,8 @@ | |||
| "Bash(sed -n '/<h1>/,/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" | |||
| @@ -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 | |||
| @@ -12,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions | |||
| services.AddScoped<IHouseholdService, HouseholdService>(); | |||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | |||
| services.AddScoped<IProductService, ProductService>(); | |||
| services.AddScoped<IPurchaseService, PurchaseService>(); | |||
| return services; | |||
| } | |||
| @@ -22,6 +22,14 @@ public interface IApplicationDbContext | |||
| DbSet<ProductIdentifier> ProductIdentifiers { get; } | |||
| DbSet<StoreLocation> StoreLocations { get; } | |||
| DbSet<Purchase> Purchases { get; } | |||
| DbSet<PurchaseItem> PurchaseItems { get; } | |||
| DbSet<PriceObservation> PriceObservations { get; } | |||
| EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class; | |||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | |||
| @@ -0,0 +1,35 @@ | |||
| using CartWise.Application.Results; | |||
| using CartWise.Domain.Entities; | |||
| namespace CartWise.Application.Interfaces; | |||
| public interface IPurchaseService | |||
| { | |||
| Task<Result<Purchase>> StartPurchaseAsync( | |||
| Guid householdId, | |||
| string purchasedByUserId, | |||
| DateTime purchasedUtc, | |||
| Guid? storeLocationId = null, | |||
| CancellationToken cancellationToken = default); | |||
| Task<Result<PurchaseItem>> 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<Result<Purchase>> CompletePurchaseAsync( | |||
| Guid householdId, | |||
| Guid purchaseId, | |||
| decimal? tax = null, | |||
| CancellationToken cancellationToken = default); | |||
| Task<Purchase?> GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default); | |||
| Task<IReadOnlyList<Purchase>> GetHouseholdPurchasesAsync(Guid householdId, CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -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<Result<Purchase>> 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<Purchase>.Failure(ex.Message); | |||
| } | |||
| _db.Purchases.Add(purchase); | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| return Result<Purchase>.Success(purchase); | |||
| } | |||
| public async Task<Result<PurchaseItem>> 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<PurchaseItem>.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<PurchaseItem>.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<PurchaseItem>.Success(item); | |||
| } | |||
| public async Task<Result<Purchase>> CompletePurchaseAsync( | |||
| Guid householdId, | |||
| Guid purchaseId, | |||
| decimal? tax = null, | |||
| CancellationToken cancellationToken = default) | |||
| { | |||
| var purchase = await FindPurchaseForHouseholdAsync(householdId, purchaseId, cancellationToken); | |||
| if (purchase is null) | |||
| { | |||
| return Result<Purchase>.Failure("Purchase not found."); | |||
| } | |||
| try | |||
| { | |||
| purchase.Complete(tax); | |||
| } | |||
| catch (InvalidOperationException ex) | |||
| { | |||
| return Result<Purchase>.Failure(ex.Message); | |||
| } | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| return Result<Purchase>.Success(purchase); | |||
| } | |||
| public async Task<Purchase?> 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<IReadOnlyList<Purchase>> 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<Purchase?> FindPurchaseForHouseholdAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken) | |||
| { | |||
| return _db.Purchases | |||
| .Include(p => p.Items) | |||
| .FirstOrDefaultAsync(p => p.PurchaseId == purchaseId && p.HouseholdId == householdId, cancellationToken); | |||
| } | |||
| } | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -28,7 +28,7 @@ public class PurchaseItem | |||
| { | |||
| } | |||
| public PurchaseItem( | |||
| internal PurchaseItem( | |||
| Guid purchaseId, | |||
| string description, | |||
| decimal linePrice, | |||
| @@ -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<ApplicationUser> _userManager; | |||
| public PurchaseController(IPurchaseService purchaseService, IHouseholdService householdService, UserManager<ApplicationUser> userManager) | |||
| { | |||
| _purchaseService = purchaseService; | |||
| _householdService = householdService; | |||
| _userManager = userManager; | |||
| } | |||
| [HttpGet("")] | |||
| public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<Domain.Entities.Household?> 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() | |||
| }; | |||
| } | |||
| @@ -0,0 +1,70 @@ | |||
| using System.ComponentModel.DataAnnotations; | |||
| namespace CartWise.Web.ViewModels.Purchase; | |||
| public class PurchaseListViewModel | |||
| { | |||
| public List<PurchaseSummaryViewModel> 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<PurchaseItemViewModel> 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; } | |||
| } | |||
| @@ -0,0 +1,83 @@ | |||
| @model CartWise.Web.ViewModels.Purchase.PurchaseDetailsViewModel | |||
| @{ | |||
| ViewData["Title"] = "Purchase"; | |||
| } | |||
| <h1>Purchase — @Model.PurchasedUtc.ToLocalTime().ToString("MMMM d, yyyy")</h1> | |||
| <p class="text-muted">@(Model.IsCompleted ? "Completed" : "In progress")</p> | |||
| @if (Model.Items.Count == 0) | |||
| { | |||
| <p class="text-muted">No items yet.</p> | |||
| } | |||
| else | |||
| { | |||
| <table class="table"> | |||
| <thead> | |||
| <tr> | |||
| <th scope="col">Item</th> | |||
| <th scope="col">Quantity</th> | |||
| <th scope="col">Price</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| @foreach (var item in Model.Items) | |||
| { | |||
| <tr> | |||
| <td>@item.Description</td> | |||
| <td>@item.Quantity.ToString("0.###")@(string.IsNullOrEmpty(item.Unit) ? "" : $" {item.Unit}")</td> | |||
| <td>@item.LinePrice.ToString("C")</td> | |||
| </tr> | |||
| } | |||
| </tbody> | |||
| </table> | |||
| } | |||
| @if (!Model.IsCompleted) | |||
| { | |||
| <form asp-controller="Purchase" asp-action="AddItem" asp-route-id="@Model.PurchaseId" method="post" class="row g-2 mb-4"> | |||
| <div asp-validation-summary="ModelOnly" class="text-danger"></div> | |||
| <div class="col-12 col-sm-5"> | |||
| <label asp-for="NewItem.Description" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Description" class="form-control" placeholder="Item" autofocus /> | |||
| <span asp-validation-for="NewItem.Description" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-4 col-sm-2"> | |||
| <label asp-for="NewItem.Quantity" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" inputmode="decimal" /> | |||
| <span asp-validation-for="NewItem.Quantity" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-4 col-sm-2"> | |||
| <label asp-for="NewItem.Unit" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Unit" class="form-control" placeholder="Unit" /> | |||
| <span asp-validation-for="NewItem.Unit" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-4 col-sm-2"> | |||
| <label asp-for="NewItem.LinePrice" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.LinePrice" class="form-control" placeholder="Price" inputmode="decimal" /> | |||
| <span asp-validation-for="NewItem.LinePrice" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-12 col-sm-1"> | |||
| <button type="submit" class="btn btn-primary w-100">Add</button> | |||
| </div> | |||
| </form> | |||
| <form asp-controller="Purchase" asp-action="Complete" asp-route-id="@Model.PurchaseId" method="post"> | |||
| <button type="submit" class="btn btn-success" @(Model.Items.Count == 0 ? "disabled" : "")>Complete purchase</button> | |||
| </form> | |||
| } | |||
| else | |||
| { | |||
| <dl class="row"> | |||
| <dt class="col-sm-3">Subtotal</dt> | |||
| <dd class="col-sm-9">@(Model.Subtotal?.ToString("C") ?? "—")</dd> | |||
| <dt class="col-sm-3">Tax</dt> | |||
| <dd class="col-sm-9">@(Model.Tax?.ToString("C") ?? "—")</dd> | |||
| <dt class="col-sm-3">Total</dt> | |||
| <dd class="col-sm-9">@(Model.Total?.ToString("C") ?? "—")</dd> | |||
| </dl> | |||
| } | |||
| @section Scripts { | |||
| @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} | |||
| } | |||
| @@ -0,0 +1,39 @@ | |||
| @model CartWise.Web.ViewModels.Purchase.PurchaseListViewModel | |||
| @{ | |||
| ViewData["Title"] = "Purchases"; | |||
| } | |||
| <h1>Purchases</h1> | |||
| <form asp-controller="Purchase" asp-action="Start" method="post" class="mb-4"> | |||
| <button type="submit" class="btn btn-primary">Record a new purchase</button> | |||
| </form> | |||
| @if (Model.Purchases.Count == 0) | |||
| { | |||
| <p class="text-muted">No purchases recorded yet.</p> | |||
| } | |||
| else | |||
| { | |||
| <ul class="list-group"> | |||
| @foreach (var purchase in Model.Purchases) | |||
| { | |||
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |||
| <a asp-controller="Purchase" asp-action="Details" asp-route-id="@purchase.PurchaseId"> | |||
| @purchase.PurchasedUtc.ToLocalTime().ToString("MMMM d, yyyy") | |||
| <span class="text-muted">(@purchase.ItemCount item@(purchase.ItemCount == 1 ? "" : "s"))</span> | |||
| </a> | |||
| <span> | |||
| @if (!purchase.IsCompleted) | |||
| { | |||
| <span class="badge bg-warning text-dark">In progress</span> | |||
| } | |||
| else if (purchase.Total is not null) | |||
| { | |||
| <span>@purchase.Total.Value.ToString("C")</span> | |||
| } | |||
| </span> | |||
| </li> | |||
| } | |||
| </ul> | |||
| } | |||
| @@ -34,6 +34,9 @@ | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-controller="Scan" asp-action="Index">Scan</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-controller="Purchase" asp-action="Index">Purchases</a> | |||
| </li> | |||
| } | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> | |||
| @@ -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<CartWiseDbContext>() | |||
| .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); | |||
| } | |||
| } | |||
| @@ -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<ArgumentException>(() => 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<ArgumentException>(() => purchase.AddItem(description!, 3.99m, Now)); | |||
| } | |||
| [Fact] | |||
| public void AddItem_ThrowsWhenQuantityIsZeroOrNegative() | |||
| { | |||
| var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now); | |||
| Assert.Throws<ArgumentOutOfRangeException>(() => purchase.AddItem("Milk", 3.99m, Now, quantity: 0)); | |||
| Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => purchase.AddItem("Milk", -1m, Now)); | |||
| } | |||
| [Fact] | |||
| public void AddItem_ThrowsOnCompletedPurchase() | |||
| { | |||
| Assert.Throws<ArgumentException>(() => 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<InvalidOperationException>(() => purchase.AddItem("Bread", 2.5m, Now)); | |||
| } | |||
| [Fact] | |||
| public void Constructor_ThrowsWhenQuantityIsZeroOrNegative() | |||
| public void Complete_SumsLinePricesIntoSubtotalAndTotal() | |||
| { | |||
| Assert.Throws<ArgumentOutOfRangeException>(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: 0)); | |||
| Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => 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<InvalidOperationException>(() => purchase.Complete()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,94 @@ | |||
| using System.Net; | |||
| using Microsoft.AspNetCore.Mvc.Testing; | |||
| namespace CartWise.Web.Tests; | |||
| public class PurchaseAccessControlTests : IClassFixture<CartWiseWebApplicationFactory> | |||
| { | |||
| 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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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); | |||
| } | |||
| } | |||
Powered by TurnKey Linux.