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(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 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 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": [ | "additionalDirectories": [ | ||||
| "G:\\development\\C Sharp AI\\CartWise" | "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 | - Shopping list items can be linked when relevant | ||||
| **Tasks** | **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 | ### `CW-STORY-05.5` Derive price intelligence | ||||
| **Release:** MVP | **Release:** MVP | ||||
| @@ -12,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions | |||||
| services.AddScoped<IHouseholdService, HouseholdService>(); | services.AddScoped<IHouseholdService, HouseholdService>(); | ||||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | services.AddScoped<IShoppingListService, ShoppingListService>(); | ||||
| services.AddScoped<IProductService, ProductService>(); | services.AddScoped<IProductService, ProductService>(); | ||||
| services.AddScoped<IPurchaseService, PurchaseService>(); | |||||
| return services; | return services; | ||||
| } | } | ||||
| @@ -22,6 +22,14 @@ public interface IApplicationDbContext | |||||
| DbSet<ProductIdentifier> ProductIdentifiers { get; } | 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; | EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class; | ||||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | 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; | SourceType = sourceType; | ||||
| CreatedUtc = createdUtc; | 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, | Guid purchaseId, | ||||
| string description, | string description, | ||||
| decimal linePrice, | 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"> | <li class="nav-item"> | ||||
| <a class="nav-link text-dark" asp-controller="Scan" asp-action="Index">Scan</a> | <a class="nav-link text-dark" asp-controller="Scan" asp-action="Index">Scan</a> | ||||
| </li> | </li> | ||||
| <li class="nav-item"> | |||||
| <a class="nav-link text-dark" asp-controller="Purchase" asp-action="Index">Purchases</a> | |||||
| </li> | |||||
| } | } | ||||
| <li class="nav-item"> | <li class="nav-item"> | ||||
| <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> | <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.Equal(PurchaseSourceType.Manual, purchase.SourceType); | ||||
| Assert.Null(purchase.StoreLocationId); | Assert.Null(purchase.StoreLocationId); | ||||
| Assert.Empty(purchase.Items); | Assert.Empty(purchase.Items); | ||||
| Assert.False(purchase.IsCompleted); | |||||
| } | } | ||||
| [Fact] | [Fact] | ||||
| @@ -31,40 +32,92 @@ public class PurchaseTests | |||||
| { | { | ||||
| Assert.Throws<ArgumentException>(() => new Purchase(Guid.NewGuid(), userId!, Now, Now)); | 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] | [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(1m, item.Quantity); | ||||
| Assert.Equal(3.99m, item.LinePrice); | Assert.Equal(3.99m, item.LinePrice); | ||||
| Assert.Single(purchase.Items); | |||||
| } | } | ||||
| [Theory] | [Theory] | ||||
| [InlineData("")] | [InlineData("")] | ||||
| [InlineData(" ")] | [InlineData(" ")] | ||||
| [InlineData(null)] | [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] | [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] | [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.