Skip to content

Commit 1f9fbc6

Browse files
committed
feat: Implement order placement functionality with validation and event handling
- Added OrderEvents.cs to define events related to order placement and payment simulation. - Created OrderHandlers.cs to handle the PlaceOrder command, including validation and event streaming. - Introduced logging for order-related actions in Log.Orders.cs. - Developed OrderSummaryProjection.cs to project order summaries from events. - Defined IOrdersClient.cs for API interactions related to orders. - Created OrderModels.cs to define data transfer objects for orders. - Implemented Orders.razor for displaying user orders in the UI. - Developed CheckoutDialog.razor for handling the checkout process. - Added unit tests for order handling and aggregate behavior in OrderHandlerTests.cs and OrderAggregateTests.cs. - Created integration tests for order placement and retrieval in OrderTests.cs.
1 parent ebe5194 commit 1f9fbc6

28 files changed

Lines changed: 1624 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using BookStore.ApiService.Events;
2+
3+
namespace BookStore.ApiService.Aggregates;
4+
5+
public class OrderAggregate
6+
{
7+
public Guid Id { get; private set; }
8+
public string TenantId { get; private set; } = string.Empty;
9+
public Guid? UserId { get; private set; }
10+
public string CustomerEmail { get; private set; } = string.Empty;
11+
public List<OrderItemData> Items { get; private set; } = [];
12+
public DeliveryAddressData DeliveryAddress { get; private set; } = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
13+
public PaymentInfoData PaymentInfo { get; private set; } = new(string.Empty, string.Empty, 0, 0);
14+
public decimal TotalAmount { get; private set; }
15+
public string Status { get; private set; } = string.Empty;
16+
public DateTimeOffset PlacedAt { get; private set; }
17+
public long Version { get; private set; }
18+
19+
void Apply(OrderPlaced @event)
20+
{
21+
Id = @event.OrderId;
22+
TenantId = @event.TenantId;
23+
UserId = @event.UserId;
24+
CustomerEmail = @event.CustomerEmail;
25+
Items = @event.Items;
26+
DeliveryAddress = @event.DeliveryAddress;
27+
PaymentInfo = @event.PaymentInfo;
28+
TotalAmount = @event.TotalAmount;
29+
PlacedAt = @event.PlacedAt;
30+
Status = "Placed";
31+
}
32+
33+
void Apply(PaymentSimulated _) => Status = "PaymentSimulated";
34+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using BookStore.ApiService.Events;
2+
3+
namespace BookStore.ApiService.Commands;
4+
5+
public record PlaceOrder(
6+
Guid OrderId,
7+
Guid? UserId,
8+
string CustomerEmail,
9+
List<OrderItemData> Items,
10+
DeliveryAddressData DeliveryAddress,
11+
PaymentInfoData PaymentInfo);
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using BookStore.ApiService.Commands;
2+
using BookStore.ApiService.Events;
3+
using BookStore.ApiService.Infrastructure;
4+
using BookStore.ApiService.Infrastructure.Extensions;
5+
using BookStore.ApiService.Infrastructure.Tenant;
6+
using BookStore.ApiService.Projections;
7+
using BookStore.Shared.Infrastructure;
8+
using BookStore.Shared.Models;
9+
using Marten;
10+
using Microsoft.AspNetCore.Mvc;
11+
using Microsoft.Extensions.Caching.Hybrid;
12+
using Wolverine;
13+
14+
namespace BookStore.ApiService.Endpoints;
15+
16+
public static class OrderEndpoints
17+
{
18+
public static void MapOrderEndpoints(this IEndpointRouteBuilder endpoints)
19+
{
20+
var group = endpoints.MapGroup("/api/orders")
21+
.WithTags("Orders")
22+
.WithMetadata(new AllowAnonymousTenantAttribute());
23+
24+
// safe: checkout must allow anonymous shoppers to place an order with explicit email provided in the request body.
25+
_ = group.MapPost("/", PlaceOrder)
26+
.WithName("PlaceOrder")
27+
.AllowAnonymous();
28+
29+
_ = group.MapGet("/", GetOrders)
30+
.WithName("GetOrders")
31+
.RequireAuthorization();
32+
}
33+
34+
static async Task<IResult> PlaceOrder(
35+
[FromBody] PlaceOrderRequest request,
36+
[FromServices] IMessageBus bus,
37+
[FromServices] ITenantContext tenantContext,
38+
HttpContext context,
39+
CancellationToken cancellationToken)
40+
{
41+
var userId = context.User.GetUserId();
42+
var isAuthenticated = userId != Guid.Empty;
43+
44+
var customerEmail = isAuthenticated
45+
? context.User.GetEmail() ?? request.CustomerEmail
46+
: request.CustomerEmail;
47+
48+
if (string.IsNullOrWhiteSpace(customerEmail))
49+
{
50+
return Result.Failure(Error.Validation(ErrorCodes.Orders.EmailRequired, "Customer email is required")).ToProblemDetails();
51+
}
52+
53+
if (!customerEmail.Contains('@', StringComparison.Ordinal))
54+
{
55+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidEmail, "Customer email is invalid")).ToProblemDetails();
56+
}
57+
58+
var cardNumberLast4 = ExtractCardLast4(request.PaymentInfo.CardNumberLast4);
59+
if (cardNumberLast4 is null)
60+
{
61+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidPayment, "Card number must contain at least four digits")).ToProblemDetails();
62+
}
63+
64+
var command = new BookStore.ApiService.Commands.PlaceOrder(
65+
Guid.CreateVersion7(),
66+
isAuthenticated ? userId : null,
67+
customerEmail,
68+
[.. request.Items.Select(item => new OrderItemData(item.BookId, item.Title, item.Quantity, item.UnitPrice))],
69+
new DeliveryAddressData(
70+
request.DeliveryAddress.FullName,
71+
request.DeliveryAddress.Street,
72+
request.DeliveryAddress.City,
73+
request.DeliveryAddress.PostalCode,
74+
request.DeliveryAddress.Country),
75+
new PaymentInfoData(
76+
request.PaymentInfo.CardHolderName,
77+
cardNumberLast4,
78+
request.PaymentInfo.ExpiryMonth,
79+
request.PaymentInfo.ExpiryYear));
80+
81+
return await bus.InvokeAsync<IResult>(
82+
command,
83+
new DeliveryOptions { TenantId = tenantContext.TenantId },
84+
cancellationToken);
85+
}
86+
87+
static async Task<IResult> GetOrders(
88+
[FromServices] IQuerySession session,
89+
[FromServices] HybridCache cache,
90+
HttpContext context,
91+
CancellationToken cancellationToken)
92+
{
93+
var userId = context.User.GetUserId();
94+
if (userId == Guid.Empty)
95+
{
96+
return Result.Failure(Error.Unauthorized(ErrorCodes.Auth.InvalidToken, "User not authenticated.")).ToProblemDetails();
97+
}
98+
99+
var tenantId = session.TenantId;
100+
var cacheKey = $"orders:{tenantId}:{userId}";
101+
102+
var response = await cache.GetOrCreateAsync(
103+
cacheKey,
104+
async ct =>
105+
{
106+
var projections = await session.Query<OrderSummaryProjection>()
107+
.Where(order => order.UserId == userId)
108+
.OrderByDescending(order => order.PlacedAt)
109+
.ToListAsync(ct);
110+
111+
return (IReadOnlyList<OrderSummaryDto>)[.. projections.Select(MapToSummaryDto)];
112+
},
113+
options: new HybridCacheEntryOptions
114+
{
115+
Expiration = TimeSpan.FromMinutes(2),
116+
LocalCacheExpiration = TimeSpan.FromMinutes(1)
117+
},
118+
tags: [CacheTags.OrderList],
119+
cancellationToken: cancellationToken);
120+
121+
return TypedResults.Ok(response);
122+
}
123+
124+
static string? ExtractCardLast4(string cardNumber)
125+
{
126+
if (string.IsNullOrWhiteSpace(cardNumber))
127+
{
128+
return null;
129+
}
130+
131+
var digits = new string([.. cardNumber.Where(char.IsDigit)]);
132+
if (digits.Length < 4)
133+
{
134+
return null;
135+
}
136+
137+
return digits[^4..];
138+
}
139+
140+
static OrderSummaryDto MapToSummaryDto(OrderSummaryProjection projection)
141+
=> new(
142+
projection.Id,
143+
projection.CustomerEmail,
144+
[.. projection.Items.Select(item => new OrderItemDto(item.BookId, item.Title, item.Quantity, item.UnitPrice))],
145+
new DeliveryAddressDto(
146+
projection.DeliveryAddress.FullName,
147+
projection.DeliveryAddress.Street,
148+
projection.DeliveryAddress.City,
149+
projection.DeliveryAddress.PostalCode,
150+
projection.DeliveryAddress.Country),
151+
new PaymentInfoDto(
152+
projection.PaymentInfo.CardHolderName,
153+
projection.PaymentInfo.CardNumberLast4,
154+
projection.PaymentInfo.ExpiryMonth,
155+
projection.PaymentInfo.ExpiryYear),
156+
projection.TotalAmount,
157+
projection.Status,
158+
projection.PlacedAt);
159+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace BookStore.ApiService.Events;
2+
3+
public record DeliveryAddressData(
4+
string FullName,
5+
string Street,
6+
string City,
7+
string PostalCode,
8+
string Country);
9+
10+
public record PaymentInfoData(
11+
string CardHolderName,
12+
string CardNumberLast4,
13+
int ExpiryMonth,
14+
int ExpiryYear);
15+
16+
public record OrderItemData(
17+
Guid BookId,
18+
string Title,
19+
int Quantity,
20+
decimal UnitPrice);
21+
22+
public record OrderPlaced(
23+
Guid OrderId,
24+
string TenantId,
25+
Guid? UserId,
26+
string CustomerEmail,
27+
List<OrderItemData> Items,
28+
DeliveryAddressData DeliveryAddress,
29+
PaymentInfoData PaymentInfo,
30+
decimal TotalAmount,
31+
DateTimeOffset PlacedAt);
32+
33+
public record PaymentSimulated(
34+
Guid OrderId,
35+
DateTimeOffset SimulatedAt);
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using BookStore.ApiService.Aggregates;
2+
using BookStore.ApiService.Commands;
3+
using BookStore.ApiService.Events;
4+
using BookStore.ApiService.Infrastructure;
5+
using BookStore.ApiService.Infrastructure.Extensions;
6+
using BookStore.ApiService.Infrastructure.Logging;
7+
using BookStore.ApiService.Projections;
8+
using BookStore.Shared.Messages.Events;
9+
using BookStore.Shared.Models;
10+
using Marten;
11+
using Microsoft.Extensions.Caching.Hybrid;
12+
13+
namespace BookStore.ApiService.Handlers.Orders;
14+
15+
public static partial class OrderHandlers
16+
{
17+
public static async Task<IResult> Handle(
18+
PlaceOrder command,
19+
IDocumentSession session,
20+
HybridCache cache,
21+
ILogger logger,
22+
CancellationToken cancellationToken)
23+
{
24+
var validationResult = Validate(command);
25+
if (validationResult.IsFailure)
26+
{
27+
Log.Orders.OrderValidationFailed(logger, command.OrderId, validationResult.Error.Code);
28+
return validationResult.ToProblemDetails();
29+
}
30+
31+
var placedAt = DateTimeOffset.UtcNow;
32+
var simulatedAt = DateTimeOffset.UtcNow;
33+
var totalAmount = command.Items.Sum(item => item.Quantity * item.UnitPrice);
34+
35+
var orderPlaced = new OrderPlaced(
36+
command.OrderId,
37+
session.TenantId,
38+
command.UserId,
39+
command.CustomerEmail,
40+
command.Items,
41+
command.DeliveryAddress,
42+
command.PaymentInfo,
43+
totalAmount,
44+
placedAt);
45+
46+
var paymentSimulated = new PaymentSimulated(command.OrderId, simulatedAt);
47+
48+
_ = session.Events.StartStream<OrderAggregate>(command.OrderId, orderPlaced, paymentSimulated);
49+
50+
if (command.UserId is Guid userId)
51+
{
52+
var userProfile = await session.Events.AggregateStreamAsync<UserProfile>(userId, token: cancellationToken);
53+
if (userProfile is not null && userProfile.ShoppingCartItems.Count > 0)
54+
{
55+
_ = session.Events.Append(userId, new ShoppingCartCleared());
56+
}
57+
}
58+
59+
await cache.RemoveByTagAsync([CacheTags.OrderList], cancellationToken);
60+
61+
Log.Orders.OrderPlaced(logger, command.OrderId, command.CustomerEmail);
62+
63+
var summary = MapToSummaryDto(command, totalAmount, placedAt, "PaymentSimulated");
64+
return TypedResults.Created($"/api/orders/{command.OrderId}", summary);
65+
}
66+
67+
static Result Validate(PlaceOrder command)
68+
{
69+
if (command.Items.Count == 0)
70+
{
71+
return Result.Failure(Error.Validation(ErrorCodes.Orders.EmptyItems, "Order must contain at least one item"));
72+
}
73+
74+
if (string.IsNullOrWhiteSpace(command.CustomerEmail))
75+
{
76+
return Result.Failure(Error.Validation(ErrorCodes.Orders.EmailRequired, "Customer email is required"));
77+
}
78+
79+
if (!command.CustomerEmail.Contains('@', StringComparison.Ordinal))
80+
{
81+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidEmail, "Customer email is invalid"));
82+
}
83+
84+
if (command.Items.Any(item => item.Quantity <= 0))
85+
{
86+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidQuantity, "Order item quantity must be greater than zero"));
87+
}
88+
89+
if (command.Items.Any(item => item.UnitPrice <= 0))
90+
{
91+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidUnitPrice, "Order item unit price must be greater than zero"));
92+
}
93+
94+
if (!IsValidAddress(command.DeliveryAddress))
95+
{
96+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidAddress, "Delivery address is invalid"));
97+
}
98+
99+
if (!IsValidPayment(command.PaymentInfo))
100+
{
101+
return Result.Failure(Error.Validation(ErrorCodes.Orders.InvalidPayment, "Payment information is invalid"));
102+
}
103+
104+
return Result.Success();
105+
}
106+
107+
static bool IsValidAddress(DeliveryAddressData address)
108+
=> !string.IsNullOrWhiteSpace(address.FullName)
109+
&& !string.IsNullOrWhiteSpace(address.Street)
110+
&& !string.IsNullOrWhiteSpace(address.City)
111+
&& !string.IsNullOrWhiteSpace(address.PostalCode)
112+
&& !string.IsNullOrWhiteSpace(address.Country);
113+
114+
static bool IsValidPayment(PaymentInfoData payment)
115+
{
116+
if (string.IsNullOrWhiteSpace(payment.CardHolderName))
117+
{
118+
return false;
119+
}
120+
121+
if (payment.CardNumberLast4.Length != 4)
122+
{
123+
return false;
124+
}
125+
126+
return payment.CardNumberLast4.All(char.IsDigit);
127+
}
128+
129+
static OrderSummaryDto MapToSummaryDto(
130+
PlaceOrder command,
131+
decimal totalAmount,
132+
DateTimeOffset placedAt,
133+
string status)
134+
=> new(
135+
command.OrderId,
136+
command.CustomerEmail,
137+
[.. command.Items.Select(item => new OrderItemDto(item.BookId, item.Title, item.Quantity, item.UnitPrice))],
138+
new DeliveryAddressDto(
139+
command.DeliveryAddress.FullName,
140+
command.DeliveryAddress.Street,
141+
command.DeliveryAddress.City,
142+
command.DeliveryAddress.PostalCode,
143+
command.DeliveryAddress.Country),
144+
new PaymentInfoDto(
145+
command.PaymentInfo.CardHolderName,
146+
command.PaymentInfo.CardNumberLast4,
147+
command.PaymentInfo.ExpiryMonth,
148+
command.PaymentInfo.ExpiryYear),
149+
totalAmount,
150+
status,
151+
placedAt);
152+
}

0 commit comments

Comments
 (0)