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 +{ + + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + } + +
ItemQuantityPrice
@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 @@ + }