From 11ac8b5f8bd76055dd5d0bba1fd1887151a41869 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 15:36:15 +0200 Subject: [PATCH 1/7] Rewrite receipts endpoint to remove pagination and polymorphism --- .../Services/v2/IReceiptService.cs | 15 +- .../Services/v2/ReceiptService.cs | 154 +++--- .../v2/Receipts/ReceiptBase.cs | 122 ++--- .../v2/Receipts/ReceiptsRequest.cs | 37 +- .../Controllers/v2/GetReceiptsTest.cs | 449 ++++++++++++------ .../Controllers/v2/ReceiptController.cs | 63 +-- 6 files changed, 487 insertions(+), 353 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs index 91ee001f..88fbc54b 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs @@ -1,10 +1,21 @@ -using System; using System.Threading.Tasks; using CoffeeCard.Models.DataTransferObjects.v2.Receipts; namespace CoffeeCard.Library.Services.v2; +/// +/// Service for retrieving receipts for a specific user. +/// public interface IReceiptService { - Task GetReceipts(DateTime from, ReceiptType type, int userId, int batchSize); + /// + /// Returns a flat, unsegmented list of receipts for the given user, optionally filtered by + /// . The returned list is sorted by event date descending. + /// + /// + /// The receipt type to include. Use to return every type. + /// + /// The database primary key of the user whose receipts are fetched. + /// A containing the matching receipt items. + Task GetReceipts(ReceiptTypeFilter type, int userId); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs index a3f57315..bf5308d4 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs @@ -10,97 +10,113 @@ namespace CoffeeCard.Library.Services.v2; +/// +/// Implementation of that queries the database for receipts +/// and returns a flat, merged, sorted list. +/// public class ReceiptService : IReceiptService { private readonly ILogger _logger; private readonly CoffeeCardContext _context; + /// + /// Initializes a new instance of . + /// + /// Logger instance. + /// EF Core database context. public ReceiptService(ILogger logger, CoffeeCardContext context) { _logger = logger; _context = context; } - public async Task GetReceipts( - DateTime from, - ReceiptType type, - int userId, - int batchSize - ) + /// + public async Task GetReceipts(ReceiptTypeFilter type, int userId) { - var ticketReceipts = type.HasFlag(ReceiptType.UsedTicket) - ? await _context - .Tickets.Where(t => t.OwnerId == userId) - .Where(ticket => ticket.DateUsed < from) - .Where(ticket => ticket.DateUsed != null) - .OrderByDescending(ticket => ticket.DateCreated) - .Take(batchSize) - .Select(t => new UsedTicketReceipt + var all = new List(); + + if (type == ReceiptTypeFilter.All || type == ReceiptTypeFilter.Purchase) + { + var purchases = await _context + .Purchases.AsNoTracking() + .Where(p => p.PurchasedById == userId && p.Type != PurchaseType.Voucher) + .Select(p => new ReceiptListItem { - ProductName = t.Purchase.ProductName, - SwipeDate = t.DateUsed.Value, + Id = "Purchase:" + p.Id, + Type = ReceiptType.Purchase, + EventDate = p.DateCreated, + Title = "Purchased " + p.NumberOfTickets + "x " + p.ProductName, + Amount = p.NumberOfTickets, + PriceDKK = p.Price, + TicketName = p.ProductName, + DrinkName = null, }) - .ToListAsync() - : []; + .ToListAsync(); + + all.AddRange(purchases); + } - var voucherReceipts = type.HasFlag(ReceiptType.Voucher) - ? await _context - .Purchases.Where(p => p.PurchasedById == userId) - .Where(purchase => purchase.DateCreated < from) - .Where(p => p.Type == PurchaseType.Voucher) - .OrderByDescending(purchase => purchase.DateCreated) - .Take(batchSize) - .Select(p => new VoucherReceipt + if (type == ReceiptTypeFilter.All || type == ReceiptTypeFilter.Voucher) + { + var vouchers = await _context + .Purchases.AsNoTracking() + .Where(p => p.PurchasedById == userId && p.Type == PurchaseType.Voucher) + .Select(p => new ReceiptListItem { - Code = p.Voucher.Code, - Quantity = p.NumberOfTickets, - ProductName = p.ProductName, - RedeemDate = p.DateCreated, + Id = "Voucher:" + p.Id, + Type = ReceiptType.Voucher, + EventDate = p.DateCreated, + Title = "Redeemed " + p.NumberOfTickets + "x " + p.ProductName + " tickets", + Amount = p.NumberOfTickets, + PriceDKK = null, + TicketName = p.ProductName, + DrinkName = null, }) - .ToListAsync() - : []; + .ToListAsync(); + + all.AddRange(vouchers); + } - var purchaseReceipts = type.HasFlag(ReceiptType.Purchase) - ? await _context - .Purchases.Where(p => p.PurchasedById == userId) - .Where(purchase => purchase.DateCreated < from) - .Where(p => p.Type != PurchaseType.Voucher) - .OrderByDescending(purchase => purchase.DateCreated) - .Take(batchSize) - .Select(p => new PurchaseReceipt + if (type == ReceiptTypeFilter.All || type == ReceiptTypeFilter.UsedTicket) + { + var usedTickets = await _context + .Tickets.AsNoTracking() + .Where(t => t.OwnerId == userId && t.DateUsed != null) + .Select(t => new ReceiptListItem { - OrderId = Guid.Parse(p.OrderId), - ProductName = p.ProductName, - Quantity = p.NumberOfTickets, - Status = p.Status, - OrderDate = p.DateCreated, - Price = p.Price, + Id = "UsedTicket:" + t.Id, + Type = ReceiptType.UsedTicket, + EventDate = t.DateUsed!.Value, + Title = + t.UsedOnMenuItem != null + ? "Swiped a " + t.UsedOnMenuItem.Name + : "Swiped a " + t.Purchase.ProductName + " ticket", + Amount = null, + PriceDKK = null, + TicketName = t.Purchase.ProductName, + DrinkName = t.UsedOnMenuItem != null ? t.UsedOnMenuItem.Name : null, }) - .ToListAsync() - : []; + .ToListAsync(); - List combinedReceipts = - [ - .. purchaseReceipts, - .. ticketReceipts, - .. voucherReceipts, - ]; - var receiptList = combinedReceipts - .OrderByDescending(r => r.IssuingDate) - .Take(batchSize) + all.AddRange(usedTickets); + } + + var sorted = all.OrderByDescending(r => r.EventDate) + .ThenByDescending(r => ParseIdNumber(r.Id)) .ToList(); - // As they are ordered by date, the last index will represent the continuation token - var continuationToken = - receiptList.Count == 0 ? DateTime.UtcNow : receiptList[^1].IssuingDate; - var encodedToken = Convert.ToBase64String( - System.Text.Encoding.UTF8.GetBytes(continuationToken.ToString("O")) - ); - return new ReceiptResponse - { - Receipts = receiptList, - // As they are ordered by date, the last index will represent the continuation token - ContinuationToken = encodedToken, - }; + return new ReceiptsResponse { Receipts = sorted }; + } + + /// + /// Parses the numeric entity-ID component from a composite receipt identifier such as + /// "Purchase:123" and returns it as an . + /// + /// The composite ID string. + /// The numeric database primary key embedded in . + private static int ParseIdNumber(string id) + { + var parts = id.Split(':'); + return int.Parse(parts[1]); } } diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs index 6eae6800..a1abda27 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs @@ -1,118 +1,98 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; -using CoffeeCard.Models.DataTransferObjects.v2.Purchase; namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; -public class ReceiptResponse +/// +/// Discriminator values that identify the concrete type of a . +/// Each value is mutually exclusive. +/// +public enum ReceiptType { - /// - /// The list of receipts for the user. - /// - [Required] - public required List Receipts { get; set; } + /// A ticket-bundle purchase made via a payment method (e.g. MobilePay). + Purchase, - /// - /// Used as the "from" parameter in the next request to get the next batch of receipts. - /// Is set to the date and time of the oldest receipt in the current batch. - /// - [Required] - public required string ContinuationToken { get; set; } + /// Tickets issued by redeeming a voucher. + Voucher, + + /// A single ticket consumed (swiped) by the user. + UsedTicket, } /// -/// Shared properties for all receipt types. +/// Flat response wrapper returned by GET /api/v2/receipts. +/// Contains all receipts matching the requested filter, sorted newest-first. /// -[JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")] -[JsonDerivedType(typeof(PurchaseReceipt), "Purchase")] -[JsonDerivedType(typeof(VoucherReceipt), "Voucher")] -[JsonDerivedType(typeof(UsedTicketReceipt), "UsedTicket")] -public abstract class ReceiptBase +public class ReceiptsResponse { - [Required] - public required string ProductName { get; set; } - /// - /// The date/time the receipt was issued (type-specific). - /// Use this for sorting across all receipt types. + /// The flat list of receipts for the authenticated user, sorted by + /// descending. /// - [JsonIgnore] - public abstract DateTime IssuingDate { get; } + [Required] + public required List Receipts { get; set; } } -public class PurchaseReceipt : ReceiptBase +/// +/// A single receipt entry in the flat receipt list. +/// All fields are present on every item; nullable fields are null when not applicable for the +/// receipt . +/// +public class ReceiptListItem { /// - /// The amount of tickets issued by the purchase. + /// Composite string identifier in the format "TypeName:EntityId", + /// e.g. "Purchase:123", "Voucher:456", or "UsedTicket:789". + /// The numeric part is the entity's database primary key. /// [Required] - public required int Quantity { get; set; } + public required string Id { get; set; } /// - /// The status of the purchase. + /// The discriminator type of this receipt entry. /// [Required] - public required PurchaseStatus Status { get; set; } + public required ReceiptType Type { get; set; } /// - /// The date and time when the order was placed. + /// The canonical event date used for sorting across all receipt types. + /// For purchases and vouchers this is the order/redeem date; for used tickets it is the swipe date. + /// Expressed in UTC. /// [Required] - public required DateTime OrderDate { get; set; } + public required DateTime EventDate { get; set; } /// - /// The total price of the purchase in kr. + /// Server-assembled, human-readable summary of this receipt entry, e.g. + /// "Purchased 10x Filter", "Redeemed 5x Filter tickets", or "Swiped a Filter Coffee". /// [Required] - public required int Price { get; set; } + public required string Title { get; set; } /// - /// The unique identifier of the order associated with the purchase. + /// Number of tickets involved in this receipt. + /// Set for and ; + /// null for . /// - [Required] - public required Guid OrderId { get; set; } + public int? Amount { get; set; } - [JsonIgnore] - public override DateTime IssuingDate => OrderDate; -} - -/// -/// Represents of tickets issued by a voucher. -/// -public class VoucherReceipt : ReceiptBase -{ /// - /// The code of the voucher. + /// Total price paid in Danish kroner (DKK). + /// Only set for ; null for all other types. /// - [Required] - public required string Code { get; set; } - - [Required] - public required DateTime RedeemDate { get; set; } + public int? PriceDKK { get; set; } /// - /// The amount of tickets issued by the purchase. + /// The name of the product or ticket type, e.g. "Filter". /// [Required] - public required int Quantity { get; set; } + public required string TicketName { get; set; } - [JsonIgnore] - public override DateTime IssuingDate => RedeemDate; -} - -/// -/// Represents a receipt for a ticket being used by a swipe -/// -public class UsedTicketReceipt : ReceiptBase -{ /// - /// The date and time when the swipe was made. + /// The name of the drink or menu item the ticket was used on. + /// Only set for items where a menu item was recorded; + /// null for all other types and for used tickets with no associated menu item. /// - [Required] - public required DateTime SwipeDate { get; set; } - - [JsonIgnore] - public override DateTime IssuingDate => SwipeDate; + public string? DrinkName { get; set; } } diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs index d579fee9..b7dd86f1 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs @@ -1,30 +1,33 @@ -using System; -using System.ComponentModel.DataAnnotations; - namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; /// -/// A paginated request for receipts based on using a continuation token +/// Filter values for the type query parameter on GET /api/v2/receipts. +/// (the default) returns every receipt type in a single merged list. /// -public class ReceiptsRequest +public enum ReceiptTypeFilter { - [Required] - public required ReceiptType Type { get; set; } + /// Return all receipt types (purchases, vouchers, and used tickets). + All = 0, - [Required] - public int BatchSize { get; set; } = 20; + /// Return only purchase receipts. + Purchase, - public required string? ContinuationToken { get; set; } + /// Return only voucher receipts. + Voucher, + + /// Return only used-ticket receipts. + UsedTicket, } /// -/// The types of receipts that can be requested. Multiple types can be combined. +/// Query-string parameters for GET /api/v2/receipts. /// -[Flags] -public enum ReceiptType +public class ReceiptsRequest { - Purchase = 1, - Voucher = 2, - UsedTicket = 4, - All = Purchase | Voucher | UsedTicket, + /// + /// The receipt type to include in the response. + /// Defaults to , which returns every type merged into a + /// single list sorted by event date descending. + /// + public ReceiptTypeFilter Type { get; set; } = ReceiptTypeFilter.All; } diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs index e33b0be8..d0e71b60 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs @@ -17,33 +17,35 @@ namespace CoffeeCard.Tests.Integration.Controllers.v2 public class GetReceiptsTest(CustomWebApplicationFactory factory) : BaseIntegrationTest(factory) { + // ── Authentication ──────────────────────────────────────────────────── + [Fact] public async Task GetReceipts_returns_401_when_not_authenticated() { RemoveRequestHeaders(); var exception = await Assert.ThrowsAsync(async () => - await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptType.All, 20, null) + await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptTypeFilter.All) ); Assert.Equal(401, exception.StatusCode); } + // ── Empty state ─────────────────────────────────────────────────────── + [Fact] public async Task GetReceipts_returns_empty_list_when_user_has_no_activity() { await GetAuthenticatedUserAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.All, - 20, - null - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptTypeFilter.All); Assert.NotNull(response); Assert.Empty(response.Receipts); } + // ── Purchase receipts ───────────────────────────────────────────────── + [Fact] public async Task GetReceipts_returns_purchase_receipt_for_completed_purchase() { @@ -66,25 +68,83 @@ public async Task GetReceipts_returns_purchase_receipt_for_completed_purchase() await Context.SaveChangesAsync(); var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 20, - null + ReceiptTypeFilter.Purchase + ); + + Assert.Single(response.Receipts); + var receipt = response.Receipts.First(); + Assert.StartsWith("Purchase:", receipt.Id); + Assert.Equal(ReceiptType.Purchase, receipt.Type); + Assert.Equal(purchase.ProductName, receipt.TicketName); + Assert.Equal(purchase.NumberOfTickets, receipt.Amount); + Assert.Equal(purchase.Price, receipt.PriceDKK); + Assert.Null(receipt.DrinkName); + } + + // ── Voucher receipts ────────────────────────────────────────────────── + + [Fact] + public async Task GetReceipts_returns_voucher_receipt_for_voucher_purchase() + { + var user = await GetAuthenticatedUserAsync(); + var redeemDate = new DateTime(2026, 1, 3, 9, 0, 0, DateTimeKind.Utc); + + var product = ProductBuilder.Simple().Build(); + await Context.Products.AddAsync(product); + + var voucher = VoucherBuilder + .Simple() + .WithCode("TESTVOUCHER") + .WithProduct(product) + .WithUser(user) + .WithDateCreated(redeemDate) + .WithDateUsed(redeemDate) + .WithDescription(f => null) + .WithRequester(f => null) + .Build(); + + var purchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithPrice(0) + .WithNumberOfTickets(5) + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Voucher) + .WithOrderId(Guid.NewGuid().ToString()) + .WithDateCreated(redeemDate) + .WithTickets(new List()) + .WithVoucher(voucher) + .Build(); + + await Context.Purchases.AddAsync(purchase); + await Context.SaveChangesAsync(); + + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( + ReceiptTypeFilter.Voucher ); Assert.Single(response.Receipts); - var receipt = Assert.IsType(response.Receipts.First()); - Assert.Equal(purchase.ProductName, receipt.ProductName); - Assert.Equal(purchase.Price, receipt.Price); - Assert.Equal(purchase.NumberOfTickets, receipt.Quantity); - Assert.Equal(Guid.Parse(purchase.OrderId), receipt.OrderId); + var receipt = response.Receipts.First(); + Assert.StartsWith("Voucher:", receipt.Id); + Assert.Equal(ReceiptType.Voucher, receipt.Type); + Assert.Equal(purchase.ProductName, receipt.TicketName); + Assert.Equal(purchase.NumberOfTickets, receipt.Amount); + Assert.Null(receipt.PriceDKK); + Assert.Null(receipt.DrinkName); } + // ── UsedTicket receipts ─────────────────────────────────────────────── + [Fact] public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() { var user = await GetAuthenticatedUserAsync(); var swipeDate = new Faker().Date.Past().ToUniversalTime(); + var menuItem = MenuItemBuilder.Simple().Build(); + await Context.MenuItems.AddAsync(menuItem); + await Context.SaveChangesAsync(); + var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) @@ -96,6 +156,7 @@ public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() .WithStatus(TicketStatus.Used) .WithDateUsed(swipeDate) .WithOwner(user) + .WithUsedOnMenuItem(menuItem) .Build(1) ) .Build(); @@ -104,76 +165,62 @@ public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() await Context.SaveChangesAsync(); var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.UsedTicket, - 20, - null + ReceiptTypeFilter.UsedTicket ); Assert.Single(response.Receipts); - var receipt = Assert.IsType(response.Receipts.First()); - Assert.Equal(purchase.ProductName, receipt.ProductName); - - // We compare the string representations of the dates to avoid issues with precision loss (nanoseconds) when converting between DateTime and DateTimeOffset - Assert.Equal(swipeDate.ToString("F"), receipt.SwipeDate.UtcDateTime.ToString("F")); + var receipt = response.Receipts.First(); + Assert.StartsWith("UsedTicket:", receipt.Id); + Assert.Equal(ReceiptType.UsedTicket, receipt.Type); + Assert.Equal(menuItem.Name, receipt.DrinkName); + Assert.Null(receipt.Amount); + Assert.Null(receipt.PriceDKK); } [Fact] - public async Task GetReceipts_returns_voucher_receipt_for_voucher_purchase() + public async Task GetReceipts_used_ticket_without_menu_item_has_null_drink_name() { var user = await GetAuthenticatedUserAsync(); - var redeemDate = new DateTime(2026, 1, 3, 9, 0, 0, DateTimeKind.Utc); - - var product = ProductBuilder.Simple().Build(); - await Context.Products.AddAsync(product); - - var voucher = VoucherBuilder - .Simple() - .WithCode("TESTVOUCHER") - .WithProduct(product) - .WithUser(user) - .WithDateCreated(redeemDate) - .WithDateUsed(redeemDate) - .WithDescription(f => null) - .WithRequester(f => null) - .Build(); + var swipeDate = new Faker().Date.Past().ToUniversalTime(); var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) - .WithPrice(0) - .WithNumberOfTickets(5) .WithStatus(PurchaseStatus.Completed) - .WithType(PurchaseType.Voucher) - .WithOrderId(Guid.NewGuid().ToString()) - .WithDateCreated(redeemDate) - .WithTickets(new List()) - .WithVoucher(voucher) + .WithType(PurchaseType.Free) + .WithTickets( + TicketBuilder + .Simple() + .WithStatus(TicketStatus.Used) + .WithDateUsed(swipeDate) + .WithOwner(user) + .WithUsedOnMenuItem(_ => null) + .Build(1) + ) .Build(); await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Voucher, - 20, - null + ReceiptTypeFilter.UsedTicket ); Assert.Single(response.Receipts); - var receipt = Assert.IsType(response.Receipts.First()); - Assert.Equal(purchase.ProductName, receipt.ProductName); - Assert.Equal("TESTVOUCHER", receipt.Code); - Assert.Equal(purchase.NumberOfTickets, receipt.Quantity); - Assert.Equal(redeemDate, receipt.RedeemDate.UtcDateTime); + Assert.Null(response.Receipts.First().DrinkName); } + // ── User isolation ──────────────────────────────────────────────────── + [Fact] public async Task GetReceipts_only_returns_receipts_for_authenticated_user() { var user = await GetAuthenticatedUserAsync(); - var otherPurchase = PurchaseBuilder.Simple().Build(); + // Another user's purchase — should NOT appear + var otherPurchase = PurchaseBuilder.Simple().WithType(PurchaseType.MobilePayV2).Build(); + // This user's purchase var myPurchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) @@ -184,183 +231,283 @@ public async Task GetReceipts_only_returns_receipts_for_authenticated_user() await Context.SaveChangesAsync(); var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 20, - null + ReceiptTypeFilter.Purchase ); Assert.Single(response.Receipts); - var receipt = Assert.IsType(response.Receipts.First()); - Assert.Equal(myPurchase.ProductName, receipt.ProductName); + Assert.Equal(myPurchase.ProductName, response.Receipts.First().TicketName); } + // ── All-types filter ────────────────────────────────────────────────── + [Fact] - public async Task GetReceipts_respects_batch_size() + public async Task GetReceipts_with_All_type_returns_all_receipt_types() { var user = await GetAuthenticatedUserAsync(); + var baseDate = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc); - var purchases = PurchaseBuilder + // Purchase + var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) .WithStatus(PurchaseStatus.Completed) .WithType(PurchaseType.MobilePayV2) - .Build(5); + .WithDateCreated(baseDate) + .WithTickets(new List()) + .WithVoucher(f => null) + .Build(); - await Context.Purchases.AddRangeAsync(purchases); + // Voucher purchase + var voucherPurchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Voucher) + .WithDateCreated(baseDate.AddHours(-1)) + .WithTickets(new List()) + .Build(); + // Used ticket (no menu item) + var usedPurchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .WithDateCreated(baseDate.AddHours(-2)) + .WithTickets( + TicketBuilder + .Simple() + .WithOwner(user) + .WithStatus(TicketStatus.Used) + .WithDateUsed(baseDate.AddHours(-2)) + .WithUsedOnMenuItem(_ => null) + .Build(1) + ) + .WithVoucher(f => null) + .Build(); + + await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 3, - null - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptTypeFilter.All); Assert.Equal(3, response.Receipts.Count); + + var types = response.Receipts.Select(r => r.Type).ToHashSet(); + Assert.Contains(ReceiptType.Purchase, types); + Assert.Contains(ReceiptType.Voucher, types); + Assert.Contains(ReceiptType.UsedTicket, types); } + // ── Sort order ──────────────────────────────────────────────────────── + [Fact] - public async Task GetReceipts_default_batch_size_is_20() + public async Task GetReceipts_items_are_sorted_newest_first() { var user = await GetAuthenticatedUserAsync(); - var purchases = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithStatus(PurchaseStatus.Completed) - .WithType(PurchaseType.MobilePayV2) - .Build(25); - - await Context.Purchases.AddRangeAsync(purchases); + var dates = new[] + { + new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), + }; + + foreach (var date in dates) + { + var p = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithType(PurchaseType.MobilePayV2) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(date) + .WithTickets(new List()) + .WithVoucher(f => null) + .Build(); + + await Context.Purchases.AddAsync(p); + } await Context.SaveChangesAsync(); var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - null, - null + ReceiptTypeFilter.Purchase ); - Assert.Equal(20, response.Receipts.Count); + Assert.Equal(3, response.Receipts.Count); + + var eventDates = response.Receipts.Select(r => r.EventDate).ToList(); + for (var i = 0; i < eventDates.Count - 1; i++) + { + Assert.True( + eventDates[i] >= eventDates[i + 1], + $"Expected item {i} ({eventDates[i]}) to be >= item {i + 1} ({eventDates[i + 1]})" + ); + } } + // ── Type-specific filters ───────────────────────────────────────────── + [Fact] - public async Task GetReceipts_with_continuation_token_returns_next_batch() + public async Task GetReceipts_purchase_filter_excludes_vouchers_and_tickets() { var user = await GetAuthenticatedUserAsync(); + var baseDate = new DateTime(2026, 2, 1, 12, 0, 0, DateTimeKind.Utc); - var purchases = PurchaseBuilder + var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) - .WithStatus(PurchaseStatus.Completed) .WithType(PurchaseType.MobilePayV2) - .Build(4); + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate) + .WithTickets(new List()) + .WithVoucher(f => null) + .Build(); + + var voucherPurchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithType(PurchaseType.Voucher) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate.AddHours(-1)) + .WithTickets(new List()) + .Build(); - await Context.Purchases.AddRangeAsync(purchases); + var usedPurchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithType(PurchaseType.Free) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate.AddHours(-2)) + .WithTickets( + TicketBuilder + .Simple() + .WithOwner(user) + .WithStatus(TicketStatus.Used) + .WithDateUsed(baseDate.AddHours(-2)) + .WithUsedOnMenuItem(_ => null) + .Build(1) + ) + .WithVoucher(f => null) + .Build(); + await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); await Context.SaveChangesAsync(); - // Get the first batch of 2 - var firstPage = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 2, - null - ); - Assert.Equal(2, firstPage.Receipts.Count); - Assert.NotNull(firstPage.ContinuationToken); - - // Use the continuation token to get the next batch - var secondPage = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 2, - firstPage.ContinuationToken + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( + ReceiptTypeFilter.Purchase ); - Assert.Equal(2, secondPage.Receipts.Count); - - // The two pages should not overlap - var firstPageDates = firstPage - .Receipts.Cast() - .Select(r => r.OrderDate) - .ToHashSet(); - var secondPageDates = secondPage - .Receipts.Cast() - .Select(r => r.OrderDate) - .ToHashSet(); - Assert.Empty(firstPageDates.Intersect(secondPageDates)); + + Assert.All(response.Receipts, r => Assert.Equal(ReceiptType.Purchase, r.Type)); + Assert.Single(response.Receipts); } [Fact] - public async Task GetReceipts_with_All_type_returns_all_receipt_types() + public async Task GetReceipts_voucher_filter_excludes_purchases_and_tickets() { var user = await GetAuthenticatedUserAsync(); - var baseDate = new Faker().Date.Past(); + var baseDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc); - // Add a purchase receipt var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) - .WithStatus(PurchaseStatus.Completed) .WithType(PurchaseType.MobilePayV2) + .WithStatus(PurchaseStatus.Completed) .WithDateCreated(baseDate) + .WithTickets(new List()) + .WithVoucher(f => null) .Build(); - // Add a used ticket receipt - - var ticket = TicketBuilder + var voucherPurchase = PurchaseBuilder .Simple() - .WithOwner(user) - .WithStatus(TicketStatus.Used) - .WithDateUsed(baseDate.AddHours(-2)) + .WithPurchasedBy(user) + .WithType(PurchaseType.Voucher) + .WithStatus(PurchaseStatus.Completed) .WithDateCreated(baseDate.AddHours(-1)) + .WithTickets(new List()) .Build(); - // Add a voucher receipt - var product = ProductBuilder.Simple().Build(); - await Context.Products.AddAsync(product); - - var voucher = VoucherBuilder + var usedPurchase = PurchaseBuilder .Simple() - .WithUser(user) - .WithPurchase( - PurchaseBuilder + .WithPurchasedBy(user) + .WithType(PurchaseType.Free) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate.AddHours(-2)) + .WithTickets( + TicketBuilder .Simple() - .WithType(PurchaseType.Voucher) - .WithPurchasedBy(user) - .Build() + .WithOwner(user) + .WithStatus(TicketStatus.Used) + .WithDateUsed(baseDate.AddHours(-2)) + .WithUsedOnMenuItem(_ => null) + .Build(1) ) + .WithVoucher(f => null) .Build(); - await Context.Purchases.AddAsync(purchase); - await Context.Tickets.AddAsync(ticket); - await Context.Vouchers.AddAsync(voucher); + await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); await Context.SaveChangesAsync(); var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.All, - 20, - null + ReceiptTypeFilter.Voucher ); - Assert.Equal(3, response.Receipts.Count); - Assert.Single(response.Receipts.OfType()); - Assert.Single(response.Receipts.OfType()); - Assert.Single(response.Receipts.OfType()); + Assert.All(response.Receipts, r => Assert.Equal(ReceiptType.Voucher, r.Type)); + Assert.Single(response.Receipts); } [Fact] - public async Task GetReceipts_with_invalid_continuation_token_returns_400() + public async Task GetReceipts_usedticket_filter_excludes_purchases_and_vouchers() { - await GetAuthenticatedUserAsync(); + var user = await GetAuthenticatedUserAsync(); + var baseDate = new DateTime(2026, 2, 3, 12, 0, 0, DateTimeKind.Utc); - var exception = await Assert.ThrowsAsync(async () => - await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.All, - 20, - "not-a-valid-token" + var purchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithType(PurchaseType.MobilePayV2) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate) + .WithTickets(new List()) + .WithVoucher(f => null) + .Build(); + + var voucherPurchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithType(PurchaseType.Voucher) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate.AddHours(-1)) + .WithTickets(new List()) + .Build(); + + var usedPurchase = PurchaseBuilder + .Simple() + .WithPurchasedBy(user) + .WithType(PurchaseType.Free) + .WithStatus(PurchaseStatus.Completed) + .WithDateCreated(baseDate.AddHours(-2)) + .WithTickets( + TicketBuilder + .Simple() + .WithOwner(user) + .WithStatus(TicketStatus.Used) + .WithDateUsed(baseDate.AddHours(-2)) + .WithUsedOnMenuItem(_ => null) + .Build(1) ) + .WithVoucher(f => null) + .Build(); + + await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); + await Context.SaveChangesAsync(); + + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( + ReceiptTypeFilter.UsedTicket ); - Assert.Equal(400, exception.StatusCode); + Assert.All(response.Receipts, r => Assert.Equal(ReceiptType.UsedTicket, r.Type)); + Assert.Single(response.Receipts); } } } diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs index 25758bdc..5302721a 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs @@ -1,16 +1,17 @@ -using System; -using System.Buffers.Text; -using System.Globalization; using System.Threading.Tasks; -using CoffeeCard.Common.Errors; using CoffeeCard.Library.Services.v2; using CoffeeCard.Library.Utils; using CoffeeCard.Models.DataTransferObjects.v2.Receipts; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace CoffeeCard.WebApi.Controllers.v2; +/// +/// Endpoints for retrieving the authenticated user's receipts. +/// Receipts include completed purchases, redeemed vouchers, and swiped tickets. +/// [ApiVersion("2")] [Route("api/v{version:apiVersion}/receipts")] [ApiController] @@ -21,10 +22,10 @@ public class ReceiptController : ControllerBase private readonly ClaimsUtilities _claimsUtilities; /// - /// Contains endpoints for retrieving receipts for the authenticated user. This includes all purchases, swiped tickets, and used vouchers + /// Initializes a new instance of the class. /// - /// - /// + /// Receipt service. + /// Helper for resolving the authenticated user from claims. public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsUtilities) { _receiptService = receiptService; @@ -32,46 +33,22 @@ public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsU } /// - /// Retrieve all receipts for the authenticated user - /// This includes all purchases, swiped tickets, and used vouchers + /// Retrieve a flat list of receipts for the authenticated user. + /// Pass type to filter by receipt kind; omit it (or pass All) to get every type + /// merged into a single list sorted by event date descending. /// - /// All users receipts - public async Task> GetReceipts( + /// Query parameters specifying the optional type filter. + /// The matching receipts, sorted newest-first. + /// Invalid or missing authentication credentials. + [HttpGet] + [ProducesResponseType(typeof(ReceiptsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)] + public async Task> GetReceipts( [FromQuery] ReceiptsRequest request ) { var user = await _claimsUtilities.ValidateAndReturnUserFromClaimAsync(User.Claims); - - DateTime from; - if (request.ContinuationToken == null) - { - from = DateTime.UtcNow; - } - else - { - try - { - var bytes = Convert.FromBase64String(request.ContinuationToken); - var utf8String = System.Text.Encoding.UTF8.GetString(bytes); - from = DateTime.ParseExact( - utf8String, - "O", - CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind - ); - } - catch (FormatException) - { - throw new BadRequestException("Invalid continuation token"); - } - } - var receipts = await _receiptService.GetReceipts( - from, - request.Type, - user.Id, - request.BatchSize - ); - - return Ok(receipts); + var result = await _receiptService.GetReceipts(request.Type, user.Id); + return Ok(result); } } From 59eb0e8073b373abbe2e852ab6c0f09888efa8a4 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 16:41:46 +0200 Subject: [PATCH 2/7] Split ReceiptBase into multiple files --- .../{ReceiptBase.cs => ReceiptListItem.cs} | 31 ------------------- .../v2/Receipts/ReceiptType.cs | 17 ++++++++++ .../v2/Receipts/ReceiptsResponse.cs | 18 +++++++++++ 3 files changed, 35 insertions(+), 31 deletions(-) rename coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/{ReceiptBase.cs => ReceiptListItem.cs} (71%) create mode 100644 coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptType.cs create mode 100644 coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsResponse.cs diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptListItem.cs similarity index 71% rename from coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs rename to coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptListItem.cs index a1abda27..8ac34806 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptListItem.cs @@ -1,39 +1,8 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; -/// -/// Discriminator values that identify the concrete type of a . -/// Each value is mutually exclusive. -/// -public enum ReceiptType -{ - /// A ticket-bundle purchase made via a payment method (e.g. MobilePay). - Purchase, - - /// Tickets issued by redeeming a voucher. - Voucher, - - /// A single ticket consumed (swiped) by the user. - UsedTicket, -} - -/// -/// Flat response wrapper returned by GET /api/v2/receipts. -/// Contains all receipts matching the requested filter, sorted newest-first. -/// -public class ReceiptsResponse -{ - /// - /// The flat list of receipts for the authenticated user, sorted by - /// descending. - /// - [Required] - public required List Receipts { get; set; } -} - /// /// A single receipt entry in the flat receipt list. /// All fields are present on every item; nullable fields are null when not applicable for the diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptType.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptType.cs new file mode 100644 index 00000000..4f23a66b --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptType.cs @@ -0,0 +1,17 @@ +namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; + +/// +/// Discriminator values that identify the concrete type of a . +/// Each value is mutually exclusive. +/// +public enum ReceiptType +{ + /// A ticket-bundle purchase made via a payment method (e.g. MobilePay). + Purchase, + + /// Tickets issued by redeeming a voucher. + Voucher, + + /// A single ticket consumed (swiped) by the user. + UsedTicket, +} diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsResponse.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsResponse.cs new file mode 100644 index 00000000..28134042 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsResponse.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; + +/// +/// Flat response wrapper returned by GET /api/v2/receipts. +/// Contains all receipts matching the requested filter, sorted newest-first. +/// +public class ReceiptsResponse +{ + /// + /// The flat list of receipts for the authenticated user, sorted by + /// descending. + /// + [Required] + public required List Receipts { get; set; } +} From 4ba0a9041d8d6375645edf931cff1dfaa89bb8bc Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 19:33:02 +0200 Subject: [PATCH 3/7] Exclude Free purchases from receipt retrieval --- coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs index bf5308d4..f9c52b19 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs @@ -39,7 +39,11 @@ public async Task GetReceipts(ReceiptTypeFilter type, int user { var purchases = await _context .Purchases.AsNoTracking() - .Where(p => p.PurchasedById == userId && p.Type != PurchaseType.Voucher) + .Where(p => + p.PurchasedById == userId + && p.Type != PurchaseType.Voucher + && p.Type != PurchaseType.Free + ) .Select(p => new ReceiptListItem { Id = "Purchase:" + p.Id, From 1ad0aaa305f3590aa00a2fd63c86622c3001db83 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 20:12:53 +0200 Subject: [PATCH 4/7] Remove type filtering from endpoint --- .../Services/v2/IReceiptService.cs | 11 +- .../Services/v2/ReceiptService.cs | 121 +++++------ .../v2/Receipts/ReceiptsRequest.cs | 33 --- .../Controllers/v2/GetReceiptsTest.cs | 194 +----------------- .../Controllers/v2/ReceiptController.cs | 12 +- 5 files changed, 73 insertions(+), 298 deletions(-) delete mode 100644 coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs index 88fbc54b..802a2f82 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs @@ -9,13 +9,10 @@ namespace CoffeeCard.Library.Services.v2; public interface IReceiptService { /// - /// Returns a flat, unsegmented list of receipts for the given user, optionally filtered by - /// . The returned list is sorted by event date descending. + /// Returns a list of receipts for the given user, + /// sorted by most recent event first. /// - /// - /// The receipt type to include. Use to return every type. - /// /// The database primary key of the user whose receipts are fetched. - /// A containing the matching receipt items. - Task GetReceipts(ReceiptTypeFilter type, int userId); + /// A containing the receipt items. + Task GetReceipts(int userId); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs index f9c52b19..1a3c94fb 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs @@ -31,79 +31,70 @@ public ReceiptService(ILogger logger, CoffeeCardContext context) } /// - public async Task GetReceipts(ReceiptTypeFilter type, int userId) + public async Task GetReceipts(int userId) { var all = new List(); - if (type == ReceiptTypeFilter.All || type == ReceiptTypeFilter.Purchase) - { - var purchases = await _context - .Purchases.AsNoTracking() - .Where(p => - p.PurchasedById == userId - && p.Type != PurchaseType.Voucher - && p.Type != PurchaseType.Free - ) - .Select(p => new ReceiptListItem - { - Id = "Purchase:" + p.Id, - Type = ReceiptType.Purchase, - EventDate = p.DateCreated, - Title = "Purchased " + p.NumberOfTickets + "x " + p.ProductName, - Amount = p.NumberOfTickets, - PriceDKK = p.Price, - TicketName = p.ProductName, - DrinkName = null, - }) - .ToListAsync(); + var purchases = await _context + .Purchases.AsNoTracking() + .Where(p => + p.PurchasedById == userId + && p.Type != PurchaseType.Voucher + && p.Type != PurchaseType.Free + ) + .Select(p => new ReceiptListItem + { + Id = "Purchase:" + p.Id, + Type = ReceiptType.Purchase, + EventDate = p.DateCreated, + Title = "Purchased " + p.NumberOfTickets + "x " + p.ProductName, + Amount = p.NumberOfTickets, + PriceDKK = p.Price, + TicketName = p.ProductName, + DrinkName = null, + }) + .ToListAsync(); - all.AddRange(purchases); - } + all.AddRange(purchases); - if (type == ReceiptTypeFilter.All || type == ReceiptTypeFilter.Voucher) - { - var vouchers = await _context - .Purchases.AsNoTracking() - .Where(p => p.PurchasedById == userId && p.Type == PurchaseType.Voucher) - .Select(p => new ReceiptListItem - { - Id = "Voucher:" + p.Id, - Type = ReceiptType.Voucher, - EventDate = p.DateCreated, - Title = "Redeemed " + p.NumberOfTickets + "x " + p.ProductName + " tickets", - Amount = p.NumberOfTickets, - PriceDKK = null, - TicketName = p.ProductName, - DrinkName = null, - }) - .ToListAsync(); + var vouchers = await _context + .Purchases.AsNoTracking() + .Where(p => p.PurchasedById == userId && p.Type == PurchaseType.Voucher) + .Select(p => new ReceiptListItem + { + Id = "Voucher:" + p.Id, + Type = ReceiptType.Voucher, + EventDate = p.DateCreated, + Title = "Redeemed " + p.NumberOfTickets + "x " + p.ProductName + " tickets", + Amount = p.NumberOfTickets, + PriceDKK = null, + TicketName = p.ProductName, + DrinkName = null, + }) + .ToListAsync(); - all.AddRange(vouchers); - } + all.AddRange(vouchers); - if (type == ReceiptTypeFilter.All || type == ReceiptTypeFilter.UsedTicket) - { - var usedTickets = await _context - .Tickets.AsNoTracking() - .Where(t => t.OwnerId == userId && t.DateUsed != null) - .Select(t => new ReceiptListItem - { - Id = "UsedTicket:" + t.Id, - Type = ReceiptType.UsedTicket, - EventDate = t.DateUsed!.Value, - Title = - t.UsedOnMenuItem != null - ? "Swiped a " + t.UsedOnMenuItem.Name - : "Swiped a " + t.Purchase.ProductName + " ticket", - Amount = null, - PriceDKK = null, - TicketName = t.Purchase.ProductName, - DrinkName = t.UsedOnMenuItem != null ? t.UsedOnMenuItem.Name : null, - }) - .ToListAsync(); + var usedTickets = await _context + .Tickets.AsNoTracking() + .Where(t => t.OwnerId == userId && t.DateUsed != null) + .Select(t => new ReceiptListItem + { + Id = "UsedTicket:" + t.Id, + Type = ReceiptType.UsedTicket, + EventDate = t.DateUsed!.Value, + Title = + t.UsedOnMenuItem != null + ? "Swiped a " + t.UsedOnMenuItem.Name + : "Swiped a " + t.Purchase.ProductName + " ticket", + Amount = null, + PriceDKK = null, + TicketName = t.Purchase.ProductName, + DrinkName = t.UsedOnMenuItem != null ? t.UsedOnMenuItem.Name : null, + }) + .ToListAsync(); - all.AddRange(usedTickets); - } + all.AddRange(usedTickets); var sorted = all.OrderByDescending(r => r.EventDate) .ThenByDescending(r => ParseIdNumber(r.Id)) diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs deleted file mode 100644 index b7dd86f1..00000000 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; - -/// -/// Filter values for the type query parameter on GET /api/v2/receipts. -/// (the default) returns every receipt type in a single merged list. -/// -public enum ReceiptTypeFilter -{ - /// Return all receipt types (purchases, vouchers, and used tickets). - All = 0, - - /// Return only purchase receipts. - Purchase, - - /// Return only voucher receipts. - Voucher, - - /// Return only used-ticket receipts. - UsedTicket, -} - -/// -/// Query-string parameters for GET /api/v2/receipts. -/// -public class ReceiptsRequest -{ - /// - /// The receipt type to include in the response. - /// Defaults to , which returns every type merged into a - /// single list sorted by event date descending. - /// - public ReceiptTypeFilter Type { get; set; } = ReceiptTypeFilter.All; -} diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs index d0e71b60..fb896353 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs @@ -25,7 +25,7 @@ public async Task GetReceipts_returns_401_when_not_authenticated() RemoveRequestHeaders(); var exception = await Assert.ThrowsAsync(async () => - await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptTypeFilter.All) + await CoffeeCardClientV2.Receipt_GetReceiptsAsync() ); Assert.Equal(401, exception.StatusCode); @@ -38,7 +38,7 @@ public async Task GetReceipts_returns_empty_list_when_user_has_no_activity() { await GetAuthenticatedUserAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptTypeFilter.All); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.NotNull(response); Assert.Empty(response.Receipts); @@ -67,9 +67,7 @@ public async Task GetReceipts_returns_purchase_receipt_for_completed_purchase() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.Purchase - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Single(response.Receipts); var receipt = response.Receipts.First(); @@ -119,9 +117,7 @@ public async Task GetReceipts_returns_voucher_receipt_for_voucher_purchase() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.Voucher - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Single(response.Receipts); var receipt = response.Receipts.First(); @@ -164,9 +160,7 @@ public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.UsedTicket - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Single(response.Receipts); var receipt = response.Receipts.First(); @@ -202,9 +196,7 @@ public async Task GetReceipts_used_ticket_without_menu_item_has_null_drink_name( await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.UsedTicket - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Single(response.Receipts); Assert.Null(response.Receipts.First().DrinkName); @@ -230,9 +222,7 @@ public async Task GetReceipts_only_returns_receipts_for_authenticated_user() await Context.Purchases.AddRangeAsync(otherPurchase, myPurchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.Purchase - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Single(response.Receipts); Assert.Equal(myPurchase.ProductName, response.Receipts.First().TicketName); @@ -289,7 +279,7 @@ public async Task GetReceipts_with_All_type_returns_all_receipt_types() await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(ReceiptTypeFilter.All); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Equal(3, response.Receipts.Count); @@ -330,9 +320,7 @@ public async Task GetReceipts_items_are_sorted_newest_first() await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.Purchase - ); + var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); Assert.Equal(3, response.Receipts.Count); @@ -345,169 +333,5 @@ public async Task GetReceipts_items_are_sorted_newest_first() ); } } - - // ── Type-specific filters ───────────────────────────────────────────── - - [Fact] - public async Task GetReceipts_purchase_filter_excludes_vouchers_and_tickets() - { - var user = await GetAuthenticatedUserAsync(); - var baseDate = new DateTime(2026, 2, 1, 12, 0, 0, DateTimeKind.Utc); - - var purchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.MobilePayV2) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate) - .WithTickets(new List()) - .WithVoucher(f => null) - .Build(); - - var voucherPurchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.Voucher) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate.AddHours(-1)) - .WithTickets(new List()) - .Build(); - - var usedPurchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.Free) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate.AddHours(-2)) - .WithTickets( - TicketBuilder - .Simple() - .WithOwner(user) - .WithStatus(TicketStatus.Used) - .WithDateUsed(baseDate.AddHours(-2)) - .WithUsedOnMenuItem(_ => null) - .Build(1) - ) - .WithVoucher(f => null) - .Build(); - - await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); - await Context.SaveChangesAsync(); - - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.Purchase - ); - - Assert.All(response.Receipts, r => Assert.Equal(ReceiptType.Purchase, r.Type)); - Assert.Single(response.Receipts); - } - - [Fact] - public async Task GetReceipts_voucher_filter_excludes_purchases_and_tickets() - { - var user = await GetAuthenticatedUserAsync(); - var baseDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc); - - var purchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.MobilePayV2) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate) - .WithTickets(new List()) - .WithVoucher(f => null) - .Build(); - - var voucherPurchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.Voucher) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate.AddHours(-1)) - .WithTickets(new List()) - .Build(); - - var usedPurchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.Free) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate.AddHours(-2)) - .WithTickets( - TicketBuilder - .Simple() - .WithOwner(user) - .WithStatus(TicketStatus.Used) - .WithDateUsed(baseDate.AddHours(-2)) - .WithUsedOnMenuItem(_ => null) - .Build(1) - ) - .WithVoucher(f => null) - .Build(); - - await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); - await Context.SaveChangesAsync(); - - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.Voucher - ); - - Assert.All(response.Receipts, r => Assert.Equal(ReceiptType.Voucher, r.Type)); - Assert.Single(response.Receipts); - } - - [Fact] - public async Task GetReceipts_usedticket_filter_excludes_purchases_and_vouchers() - { - var user = await GetAuthenticatedUserAsync(); - var baseDate = new DateTime(2026, 2, 3, 12, 0, 0, DateTimeKind.Utc); - - var purchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.MobilePayV2) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate) - .WithTickets(new List()) - .WithVoucher(f => null) - .Build(); - - var voucherPurchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.Voucher) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate.AddHours(-1)) - .WithTickets(new List()) - .Build(); - - var usedPurchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithType(PurchaseType.Free) - .WithStatus(PurchaseStatus.Completed) - .WithDateCreated(baseDate.AddHours(-2)) - .WithTickets( - TicketBuilder - .Simple() - .WithOwner(user) - .WithStatus(TicketStatus.Used) - .WithDateUsed(baseDate.AddHours(-2)) - .WithUsedOnMenuItem(_ => null) - .Build(1) - ) - .WithVoucher(f => null) - .Build(); - - await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); - await Context.SaveChangesAsync(); - - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptTypeFilter.UsedTicket - ); - - Assert.All(response.Receipts, r => Assert.Equal(ReceiptType.UsedTicket, r.Type)); - Assert.Single(response.Receipts); - } } } diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs index 5302721a..286f48a4 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs @@ -33,22 +33,18 @@ public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsU } /// - /// Retrieve a flat list of receipts for the authenticated user. - /// Pass type to filter by receipt kind; omit it (or pass All) to get every type - /// merged into a single list sorted by event date descending. + /// Retrieve a list of receipts for the authenticated user, + /// sorted by most recent event first. /// - /// Query parameters specifying the optional type filter. /// The matching receipts, sorted newest-first. /// Invalid or missing authentication credentials. [HttpGet] [ProducesResponseType(typeof(ReceiptsResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)] - public async Task> GetReceipts( - [FromQuery] ReceiptsRequest request - ) + public async Task> GetReceipts() { var user = await _claimsUtilities.ValidateAndReturnUserFromClaimAsync(User.Claims); - var result = await _receiptService.GetReceipts(request.Type, user.Id); + var result = await _receiptService.GetReceipts(user.Id); return Ok(result); } } From b63769bdcdf256f45a4c3369b3cbaba3f482caff Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 20:36:24 +0200 Subject: [PATCH 5/7] Rename ReceiptController -> ReceiptsController --- .../v2/{ReceiptController.cs => ReceiptsController.cs} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename coffeecard/CoffeeCard.WebApi/Controllers/v2/{ReceiptController.cs => ReceiptsController.cs} (88%) diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptsController.cs similarity index 88% rename from coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs rename to coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptsController.cs index 286f48a4..f7af8730 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptsController.cs @@ -16,17 +16,17 @@ namespace CoffeeCard.WebApi.Controllers.v2; [Route("api/v{version:apiVersion}/receipts")] [ApiController] [Authorize] -public class ReceiptController : ControllerBase +public class ReceiptsController : ControllerBase { private readonly IReceiptService _receiptService; private readonly ClaimsUtilities _claimsUtilities; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Receipt service. /// Helper for resolving the authenticated user from claims. - public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsUtilities) + public ReceiptsController(IReceiptService receiptService, ClaimsUtilities claimsUtilities) { _receiptService = receiptService; _claimsUtilities = claimsUtilities; From 003b6661752763e7f8575777dc51ec04a3e2885b Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 20:41:12 +0200 Subject: [PATCH 6/7] Don't sort by id as tiebreaker --- .../Services/v2/ReceiptService.cs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs index 1a3c94fb..8d2b7fa5 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs @@ -96,22 +96,8 @@ public async Task GetReceipts(int userId) all.AddRange(usedTickets); - var sorted = all.OrderByDescending(r => r.EventDate) - .ThenByDescending(r => ParseIdNumber(r.Id)) - .ToList(); + var sorted = all.OrderByDescending(r => r.EventDate).ToList(); return new ReceiptsResponse { Receipts = sorted }; } - - /// - /// Parses the numeric entity-ID component from a composite receipt identifier such as - /// "Purchase:123" and returns it as an . - /// - /// The composite ID string. - /// The numeric database primary key embedded in . - private static int ParseIdNumber(string id) - { - var parts = id.Split(':'); - return int.Parse(parts[1]); - } } From 03dbebf0512a4b8e6330d69272ebdce5f5bcaf1b Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Sun, 3 May 2026 20:45:20 +0200 Subject: [PATCH 7/7] Also rename Receipt -> Receipts in tests --- .../Controllers/v2/GetReceiptsTest.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs index fb896353..a8fcc106 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs @@ -25,7 +25,7 @@ public async Task GetReceipts_returns_401_when_not_authenticated() RemoveRequestHeaders(); var exception = await Assert.ThrowsAsync(async () => - await CoffeeCardClientV2.Receipt_GetReceiptsAsync() + await CoffeeCardClientV2.Receipts_GetReceiptsAsync() ); Assert.Equal(401, exception.StatusCode); @@ -38,7 +38,7 @@ public async Task GetReceipts_returns_empty_list_when_user_has_no_activity() { await GetAuthenticatedUserAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.NotNull(response); Assert.Empty(response.Receipts); @@ -67,7 +67,7 @@ public async Task GetReceipts_returns_purchase_receipt_for_completed_purchase() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Single(response.Receipts); var receipt = response.Receipts.First(); @@ -117,7 +117,7 @@ public async Task GetReceipts_returns_voucher_receipt_for_voucher_purchase() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Single(response.Receipts); var receipt = response.Receipts.First(); @@ -160,7 +160,7 @@ public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Single(response.Receipts); var receipt = response.Receipts.First(); @@ -196,7 +196,7 @@ public async Task GetReceipts_used_ticket_without_menu_item_has_null_drink_name( await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Single(response.Receipts); Assert.Null(response.Receipts.First().DrinkName); @@ -222,7 +222,7 @@ public async Task GetReceipts_only_returns_receipts_for_authenticated_user() await Context.Purchases.AddRangeAsync(otherPurchase, myPurchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Single(response.Receipts); Assert.Equal(myPurchase.ProductName, response.Receipts.First().TicketName); @@ -279,7 +279,7 @@ public async Task GetReceipts_with_All_type_returns_all_receipt_types() await Context.Purchases.AddRangeAsync(purchase, voucherPurchase, usedPurchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Equal(3, response.Receipts.Count); @@ -320,7 +320,7 @@ public async Task GetReceipts_items_are_sorted_newest_first() await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync(); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Equal(3, response.Receipts.Count);