diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs index 91ee001f..802a2f82 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs @@ -1,10 +1,18 @@ -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 list of receipts for the given user, + /// sorted by most recent event first. + /// + /// The database primary key of the user whose receipts are fetched. + /// 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 a3f57315..8d2b7fa5 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs @@ -10,97 +10,94 @@ 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(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 - { - ProductName = t.Purchase.ProductName, - SwipeDate = t.DateUsed.Value, - }) - .ToListAsync() - : []; + var all = new List(); - 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 - { - Code = p.Voucher.Code, - Quantity = p.NumberOfTickets, - ProductName = p.ProductName, - RedeemDate = p.DateCreated, - }) - .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(); - 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 - { - OrderId = Guid.Parse(p.OrderId), - ProductName = p.ProductName, - Quantity = p.NumberOfTickets, - Status = p.Status, - OrderDate = p.DateCreated, - Price = p.Price, - }) - .ToListAsync() - : []; + all.AddRange(purchases); - List combinedReceipts = - [ - .. purchaseReceipts, - .. ticketReceipts, - .. voucherReceipts, - ]; - var receiptList = combinedReceipts - .OrderByDescending(r => r.IssuingDate) - .Take(batchSize) - .ToList(); + 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(); - // 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, - }; + all.AddRange(vouchers); + + 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); + + var sorted = all.OrderByDescending(r => r.EventDate).ToList(); + + return new ReceiptsResponse { Receipts = sorted }; } } diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs deleted file mode 100644 index 6eae6800..00000000 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptBase.cs +++ /dev/null @@ -1,118 +0,0 @@ -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 -{ - /// - /// The list of receipts for the user. - /// - [Required] - public required List Receipts { get; set; } - - /// - /// 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; } -} - -/// -/// Shared properties for all receipt types. -/// -[JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")] -[JsonDerivedType(typeof(PurchaseReceipt), "Purchase")] -[JsonDerivedType(typeof(VoucherReceipt), "Voucher")] -[JsonDerivedType(typeof(UsedTicketReceipt), "UsedTicket")] -public abstract class ReceiptBase -{ - [Required] - public required string ProductName { get; set; } - - /// - /// The date/time the receipt was issued (type-specific). - /// Use this for sorting across all receipt types. - /// - [JsonIgnore] - public abstract DateTime IssuingDate { get; } -} - -public class PurchaseReceipt : ReceiptBase -{ - /// - /// The amount of tickets issued by the purchase. - /// - [Required] - public required int Quantity { get; set; } - - /// - /// The status of the purchase. - /// - [Required] - public required PurchaseStatus Status { get; set; } - - /// - /// The date and time when the order was placed. - /// - [Required] - public required DateTime OrderDate { get; set; } - - /// - /// The total price of the purchase in kr. - /// - [Required] - public required int Price { get; set; } - - /// - /// The unique identifier of the order associated with the purchase. - /// - [Required] - public required Guid OrderId { get; set; } - - [JsonIgnore] - public override DateTime IssuingDate => OrderDate; -} - -/// -/// Represents of tickets issued by a voucher. -/// -public class VoucherReceipt : ReceiptBase -{ - /// - /// The code of the voucher. - /// - [Required] - public required string Code { get; set; } - - [Required] - public required DateTime RedeemDate { get; set; } - - /// - /// The amount of tickets issued by the purchase. - /// - [Required] - public required int Quantity { 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. - /// - [Required] - public required DateTime SwipeDate { get; set; } - - [JsonIgnore] - public override DateTime IssuingDate => SwipeDate; -} diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptListItem.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptListItem.cs new file mode 100644 index 00000000..8ac34806 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptListItem.cs @@ -0,0 +1,67 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; + +/// +/// 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 +{ + /// + /// 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 string Id { get; set; } + + /// + /// The discriminator type of this receipt entry. + /// + [Required] + public required ReceiptType Type { get; set; } + + /// + /// 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 EventDate { get; set; } + + /// + /// 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 string Title { get; set; } + + /// + /// Number of tickets involved in this receipt. + /// Set for and ; + /// null for . + /// + public int? Amount { get; set; } + + /// + /// Total price paid in Danish kroner (DKK). + /// Only set for ; null for all other types. + /// + public int? PriceDKK { get; set; } + + /// + /// The name of the product or ticket type, e.g. "Filter". + /// + [Required] + public required string TicketName { get; set; } + + /// + /// 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. + /// + public string? DrinkName { get; set; } +} 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/ReceiptsRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs deleted file mode 100644 index d579fee9..00000000 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; - -namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts; - -/// -/// A paginated request for receipts based on using a continuation token -/// -public class ReceiptsRequest -{ - [Required] - public required ReceiptType Type { get; set; } - - [Required] - public int BatchSize { get; set; } = 20; - - public required string? ContinuationToken { get; set; } -} - -/// -/// The types of receipts that can be requested. Multiple types can be combined. -/// -[Flags] -public enum ReceiptType -{ - Purchase = 1, - Voucher = 2, - UsedTicket = 4, - All = Purchase | Voucher | 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; } +} diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/GetReceiptsTest.cs index e33b0be8..a8fcc106 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.Receipts_GetReceiptsAsync() ); 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.Receipts_GetReceiptsAsync(); Assert.NotNull(response); Assert.Empty(response.Receipts); } + // ── Purchase receipts ───────────────────────────────────────────────── + [Fact] public async Task GetReceipts_returns_purchase_receipt_for_completed_purchase() { @@ -65,57 +67,19 @@ public async Task GetReceipts_returns_purchase_receipt_for_completed_purchase() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 20, - null - ); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); 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("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); } - [Fact] - public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() - { - var user = await GetAuthenticatedUserAsync(); - var swipeDate = new Faker().Date.Past().ToUniversalTime(); - - var purchase = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithStatus(PurchaseStatus.Completed) - .WithType(PurchaseType.Free) - .WithTickets( - TicketBuilder - .Simple() - .WithStatus(TicketStatus.Used) - .WithDateUsed(swipeDate) - .WithOwner(user) - .Build(1) - ) - .Build(); - - await Context.Purchases.AddAsync(purchase); - await Context.SaveChangesAsync(); - - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.UsedTicket, - 20, - null - ); - - 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")); - } + // ── Voucher receipts ────────────────────────────────────────────────── [Fact] public async Task GetReceipts_returns_voucher_receipt_for_voucher_purchase() @@ -153,214 +117,221 @@ public async Task GetReceipts_returns_voucher_receipt_for_voucher_purchase() await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Voucher, - 20, - null - ); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); 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); + 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_only_returns_receipts_for_authenticated_user() + public async Task GetReceipts_returns_used_ticket_receipt_for_used_ticket() { var user = await GetAuthenticatedUserAsync(); + var swipeDate = new Faker().Date.Past().ToUniversalTime(); - var otherPurchase = PurchaseBuilder.Simple().Build(); + var menuItem = MenuItemBuilder.Simple().Build(); + await Context.MenuItems.AddAsync(menuItem); + await Context.SaveChangesAsync(); - var myPurchase = PurchaseBuilder + var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) - .WithType(PurchaseType.MobilePayV2) + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .WithTickets( + TicketBuilder + .Simple() + .WithStatus(TicketStatus.Used) + .WithDateUsed(swipeDate) + .WithOwner(user) + .WithUsedOnMenuItem(menuItem) + .Build(1) + ) .Build(); - await Context.Purchases.AddRangeAsync(otherPurchase, myPurchase); + await Context.Purchases.AddAsync(purchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - 20, - null - ); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Single(response.Receipts); - var receipt = Assert.IsType(response.Receipts.First()); - Assert.Equal(myPurchase.ProductName, receipt.ProductName); + 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_respects_batch_size() + public async Task GetReceipts_used_ticket_without_menu_item_has_null_drink_name() { var user = await GetAuthenticatedUserAsync(); + var swipeDate = new Faker().Date.Past().ToUniversalTime(); - var purchases = PurchaseBuilder + var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) .WithStatus(PurchaseStatus.Completed) - .WithType(PurchaseType.MobilePayV2) - .Build(5); - - await Context.Purchases.AddRangeAsync(purchases); + .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.Purchase, - 3, - null - ); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); - Assert.Equal(3, response.Receipts.Count); + Assert.Single(response.Receipts); + Assert.Null(response.Receipts.First().DrinkName); } + // ── User isolation ──────────────────────────────────────────────────── + [Fact] - public async Task GetReceipts_default_batch_size_is_20() + public async Task GetReceipts_only_returns_receipts_for_authenticated_user() { var user = await GetAuthenticatedUserAsync(); - var purchases = PurchaseBuilder + // 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) - .WithStatus(PurchaseStatus.Completed) .WithType(PurchaseType.MobilePayV2) - .Build(25); - - await Context.Purchases.AddRangeAsync(purchases); + .Build(); + await Context.Purchases.AddRangeAsync(otherPurchase, myPurchase); await Context.SaveChangesAsync(); - var response = await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.Purchase, - null, - null - ); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); - Assert.Equal(20, response.Receipts.Count); + Assert.Single(response.Receipts); + Assert.Equal(myPurchase.ProductName, response.Receipts.First().TicketName); } - [Fact] - public async Task GetReceipts_with_continuation_token_returns_next_batch() - { - var user = await GetAuthenticatedUserAsync(); - - var purchases = PurchaseBuilder - .Simple() - .WithPurchasedBy(user) - .WithStatus(PurchaseStatus.Completed) - .WithType(PurchaseType.MobilePayV2) - .Build(4); - - await Context.Purchases.AddRangeAsync(purchases); - - 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 - ); - 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)); - } + // ── All-types filter ────────────────────────────────────────────────── [Fact] public async Task GetReceipts_with_All_type_returns_all_receipt_types() { var user = await GetAuthenticatedUserAsync(); - var baseDate = new Faker().Date.Past(); + var baseDate = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc); - // Add a purchase receipt + // Purchase var purchase = PurchaseBuilder .Simple() .WithPurchasedBy(user) .WithStatus(PurchaseStatus.Completed) .WithType(PurchaseType.MobilePayV2) .WithDateCreated(baseDate) + .WithTickets(new List()) + .WithVoucher(f => null) .Build(); - // Add a used ticket receipt - - var ticket = TicketBuilder + // Voucher purchase + var voucherPurchase = PurchaseBuilder .Simple() - .WithOwner(user) - .WithStatus(TicketStatus.Used) - .WithDateUsed(baseDate.AddHours(-2)) + .WithPurchasedBy(user) + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Voucher) .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 + // Used ticket (no menu item) + var usedPurchase = PurchaseBuilder .Simple() - .WithUser(user) - .WithPurchase( - PurchaseBuilder + .WithPurchasedBy(user) + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .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 - ); + var response = await CoffeeCardClientV2.Receipts_GetReceiptsAsync(); Assert.Equal(3, response.Receipts.Count); - Assert.Single(response.Receipts.OfType()); - Assert.Single(response.Receipts.OfType()); - Assert.Single(response.Receipts.OfType()); + + 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_with_invalid_continuation_token_returns_400() + public async Task GetReceipts_items_are_sorted_newest_first() { - await GetAuthenticatedUserAsync(); + var user = await GetAuthenticatedUserAsync(); - var exception = await Assert.ThrowsAsync(async () => - await CoffeeCardClientV2.Receipt_GetReceiptsAsync( - ReceiptType.All, - 20, - "not-a-valid-token" - ) - ); + 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.Receipts_GetReceiptsAsync(); + + Assert.Equal(3, response.Receipts.Count); - Assert.Equal(400, exception.StatusCode); + 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]})" + ); + } } } } diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs deleted file mode 100644 index 25758bdc..00000000 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs +++ /dev/null @@ -1,77 +0,0 @@ -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.Mvc; - -namespace CoffeeCard.WebApi.Controllers.v2; - -[ApiVersion("2")] -[Route("api/v{version:apiVersion}/receipts")] -[ApiController] -[Authorize] -public class ReceiptController : ControllerBase -{ - private readonly IReceiptService _receiptService; - private readonly ClaimsUtilities _claimsUtilities; - - /// - /// Contains endpoints for retrieving receipts for the authenticated user. This includes all purchases, swiped tickets, and used vouchers - /// - /// - /// - public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsUtilities) - { - _receiptService = receiptService; - _claimsUtilities = claimsUtilities; - } - - /// - /// Retrieve all receipts for the authenticated user - /// This includes all purchases, swiped tickets, and used vouchers - /// - /// All users receipts - 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); - } -} diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptsController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptsController.cs new file mode 100644 index 00000000..f7af8730 --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptsController.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +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] +[Authorize] +public class ReceiptsController : ControllerBase +{ + private readonly IReceiptService _receiptService; + private readonly ClaimsUtilities _claimsUtilities; + + /// + /// Initializes a new instance of the class. + /// + /// Receipt service. + /// Helper for resolving the authenticated user from claims. + public ReceiptsController(IReceiptService receiptService, ClaimsUtilities claimsUtilities) + { + _receiptService = receiptService; + _claimsUtilities = claimsUtilities; + } + + /// + /// Retrieve a list of receipts for the authenticated user, + /// sorted by most recent event first. + /// + /// 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() + { + var user = await _claimsUtilities.ValidateAndReturnUserFromClaimAsync(User.Claims); + var result = await _receiptService.GetReceipts(user.Id); + return Ok(result); + } +}