Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions coffeecard/CoffeeCard.Library/Services/v2/IReceiptService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
using System;
using System.Threading.Tasks;
using CoffeeCard.Models.DataTransferObjects.v2.Receipts;

namespace CoffeeCard.Library.Services.v2;

/// <summary>
/// Service for retrieving receipts for a specific user.
/// </summary>
public interface IReceiptService
{
Task<ReceiptResponse> GetReceipts(DateTime from, ReceiptType type, int userId, int batchSize);
/// <summary>
/// Returns a list of receipts for the given user,
/// sorted by most recent event first.
/// </summary>
/// <param name="userId">The database primary key of the user whose receipts are fetched.</param>
/// <returns>A <see cref="ReceiptsResponse"/> containing the receipt items.</returns>
Task<ReceiptsResponse> GetReceipts(int userId);
}
149 changes: 73 additions & 76 deletions coffeecard/CoffeeCard.Library/Services/v2/ReceiptService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,97 +10,94 @@

namespace CoffeeCard.Library.Services.v2;

/// <summary>
/// Implementation of <see cref="IReceiptService"/> that queries the database for receipts
/// and returns a flat, merged, sorted list.
/// </summary>
public class ReceiptService : IReceiptService
{
private readonly ILogger<ReceiptService> _logger;
private readonly CoffeeCardContext _context;

/// <summary>
/// Initializes a new instance of <see cref="ReceiptService"/>.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="context">EF Core database context.</param>
public ReceiptService(ILogger<ReceiptService> logger, CoffeeCardContext context)
{
_logger = logger;
_context = context;
}

public async Task<ReceiptResponse> GetReceipts(
DateTime from,
ReceiptType type,
int userId,
int batchSize
)
/// <inheritdoc />
public async Task<ReceiptsResponse> 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<ReceiptListItem>();

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<ReceiptBase> 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 };
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System;
using System.ComponentModel.DataAnnotations;

namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts;

/// <summary>
/// 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 <see cref="Type"/>.
/// </summary>
public class ReceiptListItem
{
/// <summary>
/// Composite string identifier in the format <c>"TypeName:EntityId"</c>,
/// e.g. <c>"Purchase:123"</c>, <c>"Voucher:456"</c>, or <c>"UsedTicket:789"</c>.
/// The numeric part is the entity's database primary key.
/// </summary>
[Required]
public required string Id { get; set; }

/// <summary>
/// The discriminator type of this receipt entry.
/// </summary>
[Required]
public required ReceiptType Type { get; set; }

/// <summary>
/// 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.
/// </summary>
[Required]
public required DateTime EventDate { get; set; }

/// <summary>
/// Server-assembled, human-readable summary of this receipt entry, e.g.
/// <c>"Purchased 10x Filter"</c>, <c>"Redeemed 5x Filter tickets"</c>, or <c>"Swiped a Filter Coffee"</c>.
/// </summary>
[Required]
public required string Title { get; set; }

/// <summary>
/// Number of tickets involved in this receipt.
/// Set for <see cref="ReceiptType.Purchase"/> and <see cref="ReceiptType.Voucher"/>;
/// <c>null</c> for <see cref="ReceiptType.UsedTicket"/>.
/// </summary>
public int? Amount { get; set; }

/// <summary>
/// Total price paid in Danish kroner (DKK).
/// Only set for <see cref="ReceiptType.Purchase"/>; <c>null</c> for all other types.
/// </summary>
public int? PriceDKK { get; set; }

/// <summary>
/// The name of the product or ticket type, e.g. <c>"Filter"</c>.
/// </summary>
[Required]
public required string TicketName { get; set; }

/// <summary>
/// The name of the drink or menu item the ticket was used on.
/// Only set for <see cref="ReceiptType.UsedTicket"/> items where a menu item was recorded;
/// <c>null</c> for all other types and for used tickets with no associated menu item.
/// </summary>
public string? DrinkName { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace CoffeeCard.Models.DataTransferObjects.v2.Receipts;

/// <summary>
/// Discriminator values that identify the concrete type of a <see cref="ReceiptListItem"/>.
/// Each value is mutually exclusive.
/// </summary>
public enum ReceiptType
{
/// <summary>A ticket-bundle purchase made via a payment method (e.g. MobilePay).</summary>
Purchase,

/// <summary>Tickets issued by redeeming a voucher.</summary>
Voucher,

/// <summary>A single ticket consumed (swiped) by the user.</summary>
UsedTicket,
}
Loading
Loading