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);
+ }
+}