From 3bb694c3f662ff73968f57bba76dffc46f07f660 Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Sun, 23 Jun 2024 23:33:30 -0500 Subject: [PATCH] Retail Cart Initial Use Cases (2024-06-23) (#48) * expanded functionality of carts module * rename, cleanup, you know * items in cart reminder event * renamed and expanded http for carts * initial cart projection, cleaned up confirm process, cart services --- .../HttpApi/Products/ProductQueryApi.cs | 2 +- src/Catalog/Catalog.Api/Registrations.cs | 2 + .../HttpApi/InventoryQueryApi.cs | 2 +- .../CartCommandApi.cs} | 24 ++-- .../HttpApi/Carts/CartQueryApi.cs | 22 ++++ .../ShoppingCart.Api/Infrastructure/Mongo.cs | 35 ++++++ .../Queries/Carts/UserCartDocument.cs | 13 +++ .../Queries/Carts/UserCartProjection.cs | 38 ++++++ src/Retail/ShoppingCart.Api/Registrations.cs | 27 ++++- .../ShoppingCart.Api.Carts.http | 108 ++++++++++++++++++ .../ShoppingCart.Api/ShoppingCart.Api.csproj | 3 + .../ShoppingCart.Api/ShoppingCart.Api.http | 6 - .../appsettings.Development.json | 11 ++ src/Retail/ShoppingCart/CartState.cs | 55 --------- src/Retail/ShoppingCart/CartStatus.cs | 8 -- src/Retail/ShoppingCart/Carts/Cart.cs | 8 ++ .../ShoppingCart/{ => Carts}/CartCommands.cs | 6 +- .../ShoppingCart/{ => Carts}/CartEvents.cs | 14 ++- .../{ => Carts}/CartFuncService.cs | 30 +++-- src/Retail/ShoppingCart/{ => Carts}/CartId.cs | 2 +- src/Retail/ShoppingCart/Carts/CartState.cs | 65 +++++++++++ src/Retail/ShoppingCart/Carts/CartStatus.cs | 9 ++ .../ShoppingCart/{ => Carts}/CustomerId.cs | 2 +- .../Inventories/IInventoryChecker.cs | 8 ++ .../Inventories/InventoryChecker.cs | 20 ++++ .../ShoppingCart/Prices/IPriceQuoter.cs | 8 ++ src/Retail/ShoppingCart/Prices/PriceQuoter.cs | 21 ++++ .../Products/IProductValidator.cs | 6 + .../Products/PricedProductItem.cs | 61 ++++++++++ .../ShoppingCart/{ => Products}/ProductId.cs | 2 +- .../{ => Products}/ProductItem.cs | 40 +++++-- .../ShoppingCart/Products/ProductValidator.cs | 16 +++ src/Retail/ShoppingCart/Sku.cs | 22 ++++ 33 files changed, 584 insertions(+), 112 deletions(-) rename src/Retail/ShoppingCart.Api/HttpApi/{CommandApi.cs => Carts/CartCommandApi.cs} (60%) create mode 100644 src/Retail/ShoppingCart.Api/HttpApi/Carts/CartQueryApi.cs create mode 100644 src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs create mode 100644 src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs create mode 100644 src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs create mode 100644 src/Retail/ShoppingCart.Api/ShoppingCart.Api.Carts.http delete mode 100644 src/Retail/ShoppingCart.Api/ShoppingCart.Api.http delete mode 100644 src/Retail/ShoppingCart/CartState.cs delete mode 100644 src/Retail/ShoppingCart/CartStatus.cs create mode 100644 src/Retail/ShoppingCart/Carts/Cart.cs rename src/Retail/ShoppingCart/{ => Carts}/CartCommands.cs (88%) rename src/Retail/ShoppingCart/{ => Carts}/CartEvents.cs (70%) rename src/Retail/ShoppingCart/{ => Carts}/CartFuncService.cs (74%) rename src/Retail/ShoppingCart/{ => Carts}/CartId.cs (69%) create mode 100644 src/Retail/ShoppingCart/Carts/CartState.cs create mode 100644 src/Retail/ShoppingCart/Carts/CartStatus.cs rename src/Retail/ShoppingCart/{ => Carts}/CustomerId.cs (70%) create mode 100644 src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs create mode 100644 src/Retail/ShoppingCart/Inventories/InventoryChecker.cs create mode 100644 src/Retail/ShoppingCart/Prices/IPriceQuoter.cs create mode 100644 src/Retail/ShoppingCart/Prices/PriceQuoter.cs create mode 100644 src/Retail/ShoppingCart/Products/IProductValidator.cs create mode 100644 src/Retail/ShoppingCart/Products/PricedProductItem.cs rename src/Retail/ShoppingCart/{ => Products}/ProductId.cs (67%) rename src/Retail/ShoppingCart/{ => Products}/ProductItem.cs (61%) create mode 100644 src/Retail/ShoppingCart/Products/ProductValidator.cs create mode 100644 src/Retail/ShoppingCart/Sku.cs diff --git a/src/Catalog/Catalog.Api/HttpApi/Products/ProductQueryApi.cs b/src/Catalog/Catalog.Api/HttpApi/Products/ProductQueryApi.cs index 4f71c61..9c615c1 100644 --- a/src/Catalog/Catalog.Api/HttpApi/Products/ProductQueryApi.cs +++ b/src/Catalog/Catalog.Api/HttpApi/Products/ProductQueryApi.cs @@ -13,7 +13,7 @@ public class ProductQueryApi : ControllerBase [HttpGet] [Route("{id}")] - public async Task GetProduct(string id, CancellationToken ct) + public async Task Get(string id, CancellationToken ct) { var product = await _store.Load(StreamName.For(id), ct); return product.State; diff --git a/src/Catalog/Catalog.Api/Registrations.cs b/src/Catalog/Catalog.Api/Registrations.cs index a98e7cf..4a7f940 100644 --- a/src/Catalog/Catalog.Api/Registrations.cs +++ b/src/Catalog/Catalog.Api/Registrations.cs @@ -89,6 +89,8 @@ public static void AddEventuous(this IServiceCollection services, IConfiguration .AddEventHandler() .WithPartitioningByStream(2)); + // TODO: add additional mongo, postgresql, and other custom projections + // services.AddSubscription( // "ProductDraftsProjections", // builder => builder diff --git a/src/Inventory/Inventory.Api/HttpApi/InventoryQueryApi.cs b/src/Inventory/Inventory.Api/HttpApi/InventoryQueryApi.cs index 506c917..153cee5 100644 --- a/src/Inventory/Inventory.Api/HttpApi/InventoryQueryApi.cs +++ b/src/Inventory/Inventory.Api/HttpApi/InventoryQueryApi.cs @@ -13,7 +13,7 @@ public class InventoryQueryApi : ControllerBase [HttpGet] [Route("{id}")] - public async Task GetInventory(string id, CancellationToken ct) + public async Task Get(string id, CancellationToken ct) { // TODO: Is there a way to query the AggregateStory without a proper Aggregate, and just State? var product = await _store.Load(StreamName.For(id), ct); diff --git a/src/Retail/ShoppingCart.Api/HttpApi/CommandApi.cs b/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs similarity index 60% rename from src/Retail/ShoppingCart.Api/HttpApi/CommandApi.cs rename to src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs index 162b4d4..1343c74 100644 --- a/src/Retail/ShoppingCart.Api/HttpApi/CommandApi.cs +++ b/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs @@ -1,23 +1,23 @@ using Eventuous; using Eventuous.AspNetCore.Web; using Microsoft.AspNetCore.Mvc; -using static ShoppingCart.CartCommands.V1; +using ShoppingCart.Carts; -namespace ShoppingCart.Api.HttpApi; +namespace ShoppingCart.Api.HttpApi.Carts; [Route("/cart")] -public class CommandApi : CommandHttpApiBaseFunc +public class CartCommandApi : CommandHttpApiBaseFunc { private readonly IFuncCommandService _service; - public CommandApi(IFuncCommandService service) : base(service) + public CartCommandApi(IFuncCommandService service) : base(service) { _service = service; } [HttpPost] [Route("open")] - public async Task> OpenCart([FromBody] OpenCart cmd, CancellationToken ct) + public async Task> OpenCart([FromBody] CartCommands.V1.OpenCart cmd, CancellationToken ct) { var result = await _service.Handle(cmd, ct); return Ok(result); @@ -25,7 +25,7 @@ public async Task> OpenCart([FromBody] OpenCart cmd, Cancel [HttpPost] [Route("add-product")] - public async Task> OpenCart([FromBody] AddProductToCart cmd, CancellationToken ct) + public async Task> OpenCart([FromBody] CartCommands.V1.AddProductToCart cmd, CancellationToken ct) { var result = await _service.Handle(cmd, ct); return Ok(result); @@ -33,15 +33,7 @@ public async Task> OpenCart([FromBody] AddProductToCart cmd [HttpPost] [Route("remove-product")] - public async Task> OpenCart([FromBody] RemoveProductFromCart cmd, CancellationToken ct) - { - var result = await _service.Handle(cmd, ct); - return Ok(result); - } - - [HttpPost] - [Route("prepare-checkout")] - public async Task> PrepareForCheckout([FromBody] PrepareCartForCheckout cmd, CancellationToken ct) + public async Task> OpenCart([FromBody] CartCommands.V1.RemoveProductFromCart cmd, CancellationToken ct) { var result = await _service.Handle(cmd, ct); return Ok(result); @@ -49,7 +41,7 @@ public async Task> PrepareForCheckout([FromBody] PrepareCar [HttpPost] [Route("confirm")] - public async Task> OpenCart([FromBody] ConfirmCart cmd, CancellationToken ct) + public async Task> OpenCart([FromBody] CartCommands.V1.ConfirmCart cmd, CancellationToken ct) { var result = await _service.Handle(cmd, ct); return Ok(result); diff --git a/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartQueryApi.cs b/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartQueryApi.cs new file mode 100644 index 0000000..9450f9f --- /dev/null +++ b/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartQueryApi.cs @@ -0,0 +1,22 @@ +using Eventuous; +using Microsoft.AspNetCore.Mvc; +using ShoppingCart.Carts; + +namespace ShoppingCart.Api.HttpApi.Carts; + +[Route("/carts")] +public class CartQueryApi : ControllerBase +{ + private readonly IAggregateStore _store; + + public CartQueryApi(IAggregateStore store) => _store = store; + + [HttpGet] + [Route("{id}")] + public async Task Get(string id, CancellationToken ct) + { + // TODO: Is there a way to query the AggregateStory without a proper Aggregate, and just State? + var product = await _store.Load(StreamName.For(id), ct); + return product.State; + } +} diff --git a/src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs b/src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs new file mode 100644 index 0000000..d57d722 --- /dev/null +++ b/src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs @@ -0,0 +1,35 @@ +using MongoDb.Bson.NodaTime; +using MongoDB.Driver; +using MongoDB.Driver.Core.Extensions.DiagnosticSources; + +namespace ShoppingCart.Api.Infrastructure; + +public static class Mongo +{ + public static IMongoDatabase ConfigureMongo(IConfiguration configuration) + { + NodaTimeSerializers.Register(); + var config = configuration.GetSection("Mongo").Get(); + + var settings = MongoClientSettings.FromConnectionString(config!.ConnectionString); + + if (config.User != null && config.Password != null) { + settings.Credential = new MongoCredential( + null, + new MongoInternalIdentity("admin", config.User), + new PasswordEvidence(config.Password) + ); + } + + settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber()); + return new MongoClient(settings).GetDatabase(config.Database); + } + + public record MongoSettings + { + public string ConnectionString { get; init; } = null!; + public string Database { get; init; } = null!; + public string? User { get; init; } + public string? Password { get; init; } + } +} diff --git a/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs new file mode 100644 index 0000000..62b90ad --- /dev/null +++ b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs @@ -0,0 +1,13 @@ +using Eventuous.Projections.MongoDB.Tools; + +namespace ShoppingCart.Api.Queries.Carts; + +public record UserCartDocument : ProjectedDocument +{ + public UserCartDocument(string Id) : base(Id) + { + } + + public string CustomerId { get; set; } = null!; + public string Status { get; set; } = null!; +} diff --git a/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs new file mode 100644 index 0000000..73a7f8b --- /dev/null +++ b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs @@ -0,0 +1,38 @@ +using Eventuous.Projections.MongoDB; +using Eventuous.Subscriptions.Context; +using MongoDB.Driver; +using ShoppingCart.Carts; + +namespace ShoppingCart.Api.Queries.Carts; + +[Obsolete("Obsolete per Eventuous; use new API instead (TODO)")] +public class UserCartProjection : MongoProjection +{ + public UserCartProjection(IMongoDatabase database) : base(database) + { + On(stream => stream.GetId(), Handle); + + On(builder => builder + .UpdateOne + .DefaultId() + .Update((evt, update) => + update.Set(x => x.Status, nameof(CartStatus.Confirmed)))); + + On(builder => builder + .UpdateOne + .DefaultId() + .Update((evt, update) => + update.Set(x => x.Status, nameof(CartStatus.Cancelled)))); + } + + private static UpdateDefinition Handle( + IMessageConsumeContext ctx, + UpdateDefinitionBuilder update) + { + var evt = ctx.Message; + + return update.SetOnInsert(x => x.Id, ctx.Stream.GetId()) + .Set(x => x.CustomerId, evt.CustomerId) + .Set(x => x.Status, nameof(CartStatus.Opened)); + } +} diff --git a/src/Retail/ShoppingCart.Api/Registrations.cs b/src/Retail/ShoppingCart.Api/Registrations.cs index 132984c..aa47721 100644 --- a/src/Retail/ShoppingCart.Api/Registrations.cs +++ b/src/Retail/ShoppingCart.Api/Registrations.cs @@ -3,10 +3,19 @@ using Eventuous; using Eventuous.Diagnostics.OpenTelemetry; using Eventuous.EventStore; +using Eventuous.EventStore.Subscriptions; +using Eventuous.Projections.MongoDB; +using Eventuous.Subscriptions.Registrations; using Microsoft.Extensions.Diagnostics.HealthChecks; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using ShoppingCart.Api.Infrastructure; +using ShoppingCart.Api.Queries.Carts; +using ShoppingCart.Carts; +using ShoppingCart.Inventories; +using ShoppingCart.Prices; +using ShoppingCart.Products; #pragma warning disable CS0618 // Type or member is obsolete @@ -15,7 +24,6 @@ namespace ShoppingCart.Api; public static class Registrations { private const string OTelServiceName = "shoppingcart"; - private const string PostgresSchemaName = "shoppingcart"; public static void AddEventuous(this IServiceCollection services, IConfiguration configuration) { @@ -34,6 +42,23 @@ public static void AddEventuous(this IServiceCollection services, IConfiguration // other internal and core services services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // subscriptions: checkpoint stores + services.AddSingleton(Mongo.ConfigureMongo(configuration)); + services.AddCheckpointStore(); + + // subscriptions: projections + services.AddSubscription( + "UserCartProjections", + builder => builder + .UseCheckpointStore() + .AddEventHandler() + .WithPartitioningByStream(2)); + + // TODO: add additional mongo, postgresql, and other custom projections // health checks for subscription service services diff --git a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.Carts.http b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.Carts.http new file mode 100644 index 0000000..88981a4 --- /dev/null +++ b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.Carts.http @@ -0,0 +1,108 @@ +@ShoppingCart.Api_HostAddress = http://localhost:5262 +@cartId = 0190483c-e260-4c97-aa62-0d90d41ba833 +@productId = 36606-001 +@customerId = erik-123 + +### + +# curl -X 'GET' +# 'http://localhost:5262/carts/{{cartId}}' +# -H 'accept: text/plain' +GET http://localhost:5262/carts/{{cartId}} +accept: text/plain + +### + +# curl -X 'POST' +# 'http://localhost:5262/cart/open' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "customerId": "{{customerId}}" +#}' +POST http://localhost:5262/cart/open +accept: text/plain +Content-Type: application/json + +{ + "customerId": "{{customerId}}" +} + +### + +# curl -X 'POST' +# 'http://localhost:5262/cart/add-product' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "cartId": "{{cartId}}", +# "productId": "{{productId}}", +# "quantity": 2 +#}' +POST http://localhost:5262/cart/add-product +accept: text/plain +Content-Type: application/json + +{ + "cartId": "{{cartId}}", + "productId": "{{productId}}", + "quantity": 2 +} + +### + +# curl -X 'POST' +# 'http://localhost:5262/cart/remove-product' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "cartId": "{{shoppingCartId}", +# "productId": "{{productId}}", +# "quantity": 1 +#}' +POST http://localhost:5262/cart/remove-product +accept: text/plain +Content-Type: application/json + +{ + "cartId": "{{cartId}}", + "productId": "{{productId}}", + "quantity": 1 +} + +### + +# curl -X 'POST' +# 'http://localhost:5262/cart/prepare-checkout' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "cartId": "{{cartId}}" +#}' +POST http://localhost:5262/cart/prepare-checkout +accept: text/plain +Content-Type: application/json + +{ + "cartId": "{{cartId}}" +} + +### + +# curl -X 'POST' +# 'http://localhost:5262/cart/confirm' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "cartId": "{{cartId}" +#}' +POST http://localhost:5262/cart/confirm +accept: text/plain +Content-Type: application/json + +{ + "cartId": "{{cartId}}" +} + +### + diff --git a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj index edea4fd..869ade7 100644 --- a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj +++ b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj @@ -9,9 +9,12 @@ + + + diff --git a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.http b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.http deleted file mode 100644 index e096939..0000000 --- a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@ShoppingCart.Api_HostAddress = http://localhost:5146 - -GET {{ShoppingCart.Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/Retail/ShoppingCart.Api/appsettings.Development.json b/src/Retail/ShoppingCart.Api/appsettings.Development.json index e3c3837..8707552 100644 --- a/src/Retail/ShoppingCart.Api/appsettings.Development.json +++ b/src/Retail/ShoppingCart.Api/appsettings.Development.json @@ -11,5 +11,16 @@ }, "EventStore": { "ConnectionString": "esdb://localhost:2113?tls=false" + }, + "Mongo": { + "ConnectionString": "mongodb://localhost:27017", + "User": "mongoadmin", + "Password": "secret", + "Database": "ShoppingCart" + }, + "Postgres": { + "ConnectionString": "Server=127.0.0.1;Port=5432;Database=postgres;User Id=postgres;Password=Password123!;Include Error Detail=true;", + "Schema": "shopping_cart", + "InitializeDatabase": true } } diff --git a/src/Retail/ShoppingCart/CartState.cs b/src/Retail/ShoppingCart/CartState.cs deleted file mode 100644 index 26318b1..0000000 --- a/src/Retail/ShoppingCart/CartState.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Ecommerce.Eventuous.Exceptions; -using Eventuous; -using static ShoppingCart.CartEvents; - -namespace ShoppingCart; - -public record CartState : State -{ - public CartId Id { get; init; } = null!; - public CustomerId CustomerId { get; init; } = null!; - public CartStatus Status { get; init; } = CartStatus.Unset; - public ProductItems ProductItems { get; init; } = null!; - - public bool HasProductItems => ProductItems.IsEmpty; - - public CartState() - { - On(Handle); - On(Handle); - On(Handle); - On(Handle); - } - - private static CartState Handle(CartState state, V1.CartOpened @event) => state with - { - Id = new CartId(@event.CartId), - CustomerId = new CustomerId(@event.CustomerId), - Status = CartStatus.Opened, - ProductItems = ProductItems.Empty - }; - - private static CartState Handle(CartState state, V1.ProductAddedToCart @event) => state.Status switch - { - CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), - _ => state with { ProductItems = state.ProductItems.Add(ProductItem.Create(@event.ProductId, @event.Quantity)) } - }; - - private static CartState Handle(CartState state, V1.ProductRemovedFromCart @event) => state.Status switch - { - CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), - _ => state with { ProductItems = state.ProductItems.Remove(ProductItem.Create(@event.ProductId, @event.Quantity)) } - }; - - private static CartState Handle(CartState state, V1.CartConfirmed @event) => state.Status switch - { - CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), - _ => state with { Status = CartStatus.Confirmed } - }; - - public bool CanProceedToCheckout() => Status switch - { - CartStatus.Unset or CartStatus.Opened => false, - _ => HasProductItems - }; -} diff --git a/src/Retail/ShoppingCart/CartStatus.cs b/src/Retail/ShoppingCart/CartStatus.cs deleted file mode 100644 index 2613073..0000000 --- a/src/Retail/ShoppingCart/CartStatus.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ShoppingCart; - -public enum CartStatus -{ - Unset = 0, - Opened = 1, - Confirmed = 2 -} diff --git a/src/Retail/ShoppingCart/Carts/Cart.cs b/src/Retail/ShoppingCart/Carts/Cart.cs new file mode 100644 index 0000000..98578d8 --- /dev/null +++ b/src/Retail/ShoppingCart/Carts/Cart.cs @@ -0,0 +1,8 @@ +using Eventuous; + +namespace ShoppingCart.Carts; + +public class Cart : Aggregate +{ + +} diff --git a/src/Retail/ShoppingCart/CartCommands.cs b/src/Retail/ShoppingCart/Carts/CartCommands.cs similarity index 88% rename from src/Retail/ShoppingCart/CartCommands.cs rename to src/Retail/ShoppingCart/Carts/CartCommands.cs index f22b5a4..9d039d9 100644 --- a/src/Retail/ShoppingCart/CartCommands.cs +++ b/src/Retail/ShoppingCart/Carts/CartCommands.cs @@ -1,4 +1,4 @@ -namespace ShoppingCart; +namespace ShoppingCart.Carts; public static class CartCommands { @@ -20,11 +20,11 @@ public record RemoveProductFromCart( int Quantity ); - public record PrepareCartForCheckout( + public record ConfirmCart( string CartId ); - public record ConfirmCart( + public record CancelCart( string CartId ); } diff --git a/src/Retail/ShoppingCart/CartEvents.cs b/src/Retail/ShoppingCart/Carts/CartEvents.cs similarity index 70% rename from src/Retail/ShoppingCart/CartEvents.cs rename to src/Retail/ShoppingCart/Carts/CartEvents.cs index ccbfb4c..5a50c53 100644 --- a/src/Retail/ShoppingCart/CartEvents.cs +++ b/src/Retail/ShoppingCart/Carts/CartEvents.cs @@ -1,6 +1,6 @@ using Eventuous; -namespace ShoppingCart; +namespace ShoppingCart.Carts; public static class CartEvents { @@ -31,9 +31,21 @@ public record CartConfirmed( string CartId ); + [EventType("V1.CartCancelled")] + public record CartCancelled( + string CartId + ); + [EventType("V1.EmptyCartDetected")] public record EmptyCartDetected( string CartId ); + + [EventType("V1.CartHasProductsReminder")] + public record CartHasProductsReminder( + string CartId, + string CustomerId, + DateTime RemindAfter + ); } } diff --git a/src/Retail/ShoppingCart/CartFuncService.cs b/src/Retail/ShoppingCart/Carts/CartFuncService.cs similarity index 74% rename from src/Retail/ShoppingCart/CartFuncService.cs rename to src/Retail/ShoppingCart/Carts/CartFuncService.cs index e738da1..401573c 100644 --- a/src/Retail/ShoppingCart/CartFuncService.cs +++ b/src/Retail/ShoppingCart/Carts/CartFuncService.cs @@ -1,18 +1,17 @@ using Ecommerce.Core.Identities; using Eventuous; -using Commands = ShoppingCart.CartCommands.V1; -using Events = ShoppingCart.CartEvents.V1; +using Commands = ShoppingCart.Carts.CartCommands.V1; +using Events = ShoppingCart.Carts.CartEvents.V1; -namespace ShoppingCart; +namespace ShoppingCart.Carts; public class CartFuncService : FunctionalCommandService { [Obsolete("Obsolete according to Eventuous - TBU")] public CartFuncService( IEventStore store, - ICombIdGenerator idGenerator, - TypeMapper? typeMap = null) - : base(store, typeMap) + ICombIdGenerator idGenerator) + : base(store) { var generatedId = idGenerator.New(); @@ -25,8 +24,11 @@ public CartFuncService( OnExisting(cmd => GetStream(cmd.CartId), RemoveProductFromCart); - OnExisting(cmd - => GetStream(cmd.CartId), PrepareCartForCheckout); + OnExisting(cmd + => GetStream(cmd.CartId), ConfirmCart); + + OnExisting(cmd + => GetStream(cmd.CartId), CancelCart); static StreamName GetStream(string id) => new($"Cart-{id}"); @@ -67,13 +69,21 @@ static IEnumerable RemoveProductFromCart( yield return new Events.EmptyCartDetected(cmd.CartId); } - static IEnumerable PrepareCartForCheckout( + static IEnumerable ConfirmCart( CartState state, object[] originalEvents, - Commands.PrepareCartForCheckout cmd) + Commands.ConfirmCart cmd) { if (state.CanProceedToCheckout()) yield return new Events.CartConfirmed(cmd.CartId); } + + static IEnumerable CancelCart( + CartState state, + object[] originalEvents, + Commands.CancelCart cmd) + { + yield return new Events.CartCancelled(cmd.CartId); + } } } diff --git a/src/Retail/ShoppingCart/CartId.cs b/src/Retail/ShoppingCart/Carts/CartId.cs similarity index 69% rename from src/Retail/ShoppingCart/CartId.cs rename to src/Retail/ShoppingCart/Carts/CartId.cs index b3c8f50..b87a896 100644 --- a/src/Retail/ShoppingCart/CartId.cs +++ b/src/Retail/ShoppingCart/Carts/CartId.cs @@ -1,5 +1,5 @@ using Eventuous; -namespace ShoppingCart; +namespace ShoppingCart.Carts; public record CartId(string Value) : Id(Value); diff --git a/src/Retail/ShoppingCart/Carts/CartState.cs b/src/Retail/ShoppingCart/Carts/CartState.cs new file mode 100644 index 0000000..af4339c --- /dev/null +++ b/src/Retail/ShoppingCart/Carts/CartState.cs @@ -0,0 +1,65 @@ +using Ecommerce.Eventuous.Exceptions; +using Eventuous; +using ShoppingCart.Products; +using static ShoppingCart.Carts.CartEvents.V1; + +namespace ShoppingCart.Carts; + +public record CartState : State +{ + public CartId Id { get; init; } = null!; + public CustomerId CustomerId { get; init; } = null!; + public CartStatus Status { get; init; } = CartStatus.Unset; + public ProductItems ProductItems { get; init; } = null!; + + public bool HasProductItems => ProductItems.IsEmpty; + + public CartState() + { + On(Handle); + On(Handle); + On(Handle); + On(Handle); + On(Handle); + } + + private static CartState Handle(CartState state, CartOpened @event) => state with + { + Id = new CartId(@event.CartId), + CustomerId = new CustomerId(@event.CustomerId), + Status = CartStatus.Opened, + ProductItems = ProductItems.Empty + }; + + private static CartState Handle(CartState state, ProductAddedToCart @event) => state.Status switch + { + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + _ => state with { ProductItems = state.ProductItems.Add(ProductItem.From(@event.ProductId, @event.Quantity)) } + }; + + private static CartState Handle(CartState state, ProductRemovedFromCart @event) => state.Status switch + { + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + _ => state with { ProductItems = state.ProductItems.Remove(ProductItem.From(@event.ProductId, @event.Quantity)) } + }; + + private static CartState Handle(CartState state, CartConfirmed @event) => state.Status switch + { + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + _ => state with { Status = CartStatus.Confirmed } + }; + + private static CartState Handle(CartState state, CartCancelled @event) => state.Status switch + { + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + CartStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, CartStatus.Cancelled), + _ => state with { Status = CartStatus.Cancelled } + }; + + public bool CanProceedToCheckout() => Status switch + { + CartStatus.Unset => false, + CartStatus.Confirmed => false, + _ => HasProductItems + }; +} diff --git a/src/Retail/ShoppingCart/Carts/CartStatus.cs b/src/Retail/ShoppingCart/Carts/CartStatus.cs new file mode 100644 index 0000000..db8226a --- /dev/null +++ b/src/Retail/ShoppingCart/Carts/CartStatus.cs @@ -0,0 +1,9 @@ +namespace ShoppingCart.Carts; + +public enum CartStatus +{ + Unset = 0, + Opened = 1, + Confirmed = 2, + Cancelled = 4 +} diff --git a/src/Retail/ShoppingCart/CustomerId.cs b/src/Retail/ShoppingCart/Carts/CustomerId.cs similarity index 70% rename from src/Retail/ShoppingCart/CustomerId.cs rename to src/Retail/ShoppingCart/Carts/CustomerId.cs index b7dbe4d..7c3ab18 100644 --- a/src/Retail/ShoppingCart/CustomerId.cs +++ b/src/Retail/ShoppingCart/Carts/CustomerId.cs @@ -1,5 +1,5 @@ using Eventuous; -namespace ShoppingCart; +namespace ShoppingCart.Carts; public record CustomerId(string Value) : Id(Value); diff --git a/src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs b/src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs new file mode 100644 index 0000000..8ba8f61 --- /dev/null +++ b/src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs @@ -0,0 +1,8 @@ +using ShoppingCart.Products; + +namespace ShoppingCart.Inventories; + +public interface IInventoryChecker +{ + IReadOnlyList Check(params ProductId[] productIds); +} diff --git a/src/Retail/ShoppingCart/Inventories/InventoryChecker.cs b/src/Retail/ShoppingCart/Inventories/InventoryChecker.cs new file mode 100644 index 0000000..2f42f61 --- /dev/null +++ b/src/Retail/ShoppingCart/Inventories/InventoryChecker.cs @@ -0,0 +1,20 @@ +using ShoppingCart.Products; + +namespace ShoppingCart.Inventories; + +public class InventoryChecker : IInventoryChecker +{ + public IReadOnlyList Check(params ProductId[] productIds) + { + if (productIds.Length == 0) + throw new ArgumentOutOfRangeException(nameof(productIds.Length)); + + // TODO: access cache or make a gRPC call + + var rng = new Random(); + + return productIds + .Select(pi => ProductItem.From(pi, rng.Next(1, 50))) + .ToList(); + } +} diff --git a/src/Retail/ShoppingCart/Prices/IPriceQuoter.cs b/src/Retail/ShoppingCart/Prices/IPriceQuoter.cs new file mode 100644 index 0000000..d872448 --- /dev/null +++ b/src/Retail/ShoppingCart/Prices/IPriceQuoter.cs @@ -0,0 +1,8 @@ +using ShoppingCart.Products; + +namespace ShoppingCart.Prices; + +public interface IPriceQuoter +{ + IReadOnlyList Quote(params ProductItem[] productItems); +} diff --git a/src/Retail/ShoppingCart/Prices/PriceQuoter.cs b/src/Retail/ShoppingCart/Prices/PriceQuoter.cs new file mode 100644 index 0000000..7927fd8 --- /dev/null +++ b/src/Retail/ShoppingCart/Prices/PriceQuoter.cs @@ -0,0 +1,21 @@ +using ShoppingCart.Products; + +namespace ShoppingCart.Prices; + +public class PriceQuoter : IPriceQuoter +{ + public IReadOnlyList Quote(params ProductItem[] productItems) + { + if (productItems.Length == 0) + throw new ArgumentOutOfRangeException(nameof(productItems.Length)); + + // TODO: access cache or make a gRPC call + + var rng = new Random(); + + return productItems + .Select(pi => + PricedProductItem.Create(pi, Math.Round(new decimal(rng.NextDouble() * 299),2))) + .ToList(); + } +} diff --git a/src/Retail/ShoppingCart/Products/IProductValidator.cs b/src/Retail/ShoppingCart/Products/IProductValidator.cs new file mode 100644 index 0000000..5d577a0 --- /dev/null +++ b/src/Retail/ShoppingCart/Products/IProductValidator.cs @@ -0,0 +1,6 @@ +namespace ShoppingCart.Products; + +public interface IProductValidator +{ + IReadOnlyList<(ProductId productId, bool isValid)> Quote(params ProductId[] productIds); +} diff --git a/src/Retail/ShoppingCart/Products/PricedProductItem.cs b/src/Retail/ShoppingCart/Products/PricedProductItem.cs new file mode 100644 index 0000000..2c85c48 --- /dev/null +++ b/src/Retail/ShoppingCart/Products/PricedProductItem.cs @@ -0,0 +1,61 @@ +namespace ShoppingCart.Products; + +public class PricedProductItem +{ + public ProductId ProductId => ProductItem.ProductId; + + public int Quantity => ProductItem.Quantity; + + // TODO: IsDiscounted and DiscountAmount values + + public decimal UnitPrice { get; } + + public decimal TotalPrice => Quantity * UnitPrice; + public ProductItem ProductItem { get; } + + private PricedProductItem(ProductItem productItem, decimal unitPrice) + { + ProductItem = productItem; + UnitPrice = unitPrice; + } + + public static PricedProductItem Create(Guid? productId, int? quantity, decimal? unitPrice) => + Create( + ProductItem.From(productId, quantity), + unitPrice + ); + + public static PricedProductItem Create(ProductItem productItem, decimal? unitPrice) => + unitPrice switch + { + null => throw new ArgumentNullException(nameof(unitPrice)), + <= 0 => throw new ArgumentOutOfRangeException(nameof(unitPrice), + "Unit price has to be positive number"), + _ => new PricedProductItem(productItem, unitPrice.Value) + }; + + public bool MatchesProductAndPrice(PricedProductItem pricedProductItem) => + ProductId == pricedProductItem.ProductId && + UnitPrice == pricedProductItem.UnitPrice; + + public PricedProductItem MergeWith(PricedProductItem pricedProductItem) + { + if (!MatchesProductAndPrice(pricedProductItem)) + throw new ArgumentException("Product or price does not match."); + + return new PricedProductItem(ProductItem.MergeWith(pricedProductItem.ProductItem), UnitPrice); + } + + public PricedProductItem Subtract(PricedProductItem pricedProductItem) + { + if (!MatchesProductAndPrice(pricedProductItem)) + throw new ArgumentException("Product or price does not match."); + + return new PricedProductItem(ProductItem.Subtract(pricedProductItem.ProductItem), UnitPrice); + } + + public bool HasEnough(int quantity) => ProductItem.HasEnough(quantity); + + public bool HasTheSameQuantity(PricedProductItem pricedProductItem) => + ProductItem.HasTheSameQuantity(pricedProductItem.ProductItem); +} diff --git a/src/Retail/ShoppingCart/ProductId.cs b/src/Retail/ShoppingCart/Products/ProductId.cs similarity index 67% rename from src/Retail/ShoppingCart/ProductId.cs rename to src/Retail/ShoppingCart/Products/ProductId.cs index 25d7b46..0041ebe 100644 --- a/src/Retail/ShoppingCart/ProductId.cs +++ b/src/Retail/ShoppingCart/Products/ProductId.cs @@ -1,5 +1,5 @@ using Eventuous; -namespace ShoppingCart; +namespace ShoppingCart.Products; public record ProductId(string Value) : Id(Value); diff --git a/src/Retail/ShoppingCart/ProductItem.cs b/src/Retail/ShoppingCart/Products/ProductItem.cs similarity index 61% rename from src/Retail/ShoppingCart/ProductItem.cs rename to src/Retail/ShoppingCart/Products/ProductItem.cs index 64985b3..e5e4298 100644 --- a/src/Retail/ShoppingCart/ProductItem.cs +++ b/src/Retail/ShoppingCart/Products/ProductItem.cs @@ -1,6 +1,4 @@ -using System.Text.RegularExpressions; - -namespace ShoppingCart; +namespace ShoppingCart.Products; public record ProductItem { @@ -13,7 +11,7 @@ private ProductItem(ProductId productId, int quantity) Quantity = quantity; } - public static ProductItem Create(ProductId? productId, int? quantity) + public static ProductItem From(ProductId? productId, int? quantity) { if (productId is null || string.IsNullOrWhiteSpace(productId.Value)) throw new ArgumentNullException(nameof(productId)); @@ -26,11 +24,40 @@ public static ProductItem Create(ProductId? productId, int? quantity) }; } - public static ProductItem Create(string? productId, int? quantity) + public static ProductItem From(string? productId, int? quantity) + { + return From(new ProductId(productId!), quantity); + } + + public static ProductItem From(Guid? productId, int? quantity) + { + return From(new ProductId(productId.ToString()!), quantity); + } + + public ProductItem MergeWith(ProductItem productItem) + { + if (!MatchesProduct(productItem)) + throw new ArgumentException("Product does not match."); + + return From(ProductId, Quantity + productItem.Quantity); + } + + public ProductItem Subtract(ProductItem productItem) { - return Create(new ProductId(productId!), quantity); + if (!MatchesProduct(productItem)) + throw new ArgumentException("Product does not match."); + + return From(ProductId, Quantity - productItem.Quantity); } + public bool MatchesProduct(ProductItem productItem) => + ProductId == productItem.ProductId; + + public bool HasEnough(int quantity) => Quantity >= quantity; + + public bool HasTheSameQuantity(ProductItem productItem) => + Quantity == productItem.Quantity; + public void Deconstruct(out string ProductId, out int Quantity) { ProductId = this.ProductId; @@ -65,5 +92,4 @@ public class ProductItems : pi) .Where(pi => pi.Quantity > 0) .ToArray()); - } diff --git a/src/Retail/ShoppingCart/Products/ProductValidator.cs b/src/Retail/ShoppingCart/Products/ProductValidator.cs new file mode 100644 index 0000000..4bc0e70 --- /dev/null +++ b/src/Retail/ShoppingCart/Products/ProductValidator.cs @@ -0,0 +1,16 @@ +namespace ShoppingCart.Products; + +public class ProductValidator : IProductValidator +{ + public IReadOnlyList<(ProductId productId, bool isValid)> Quote(params ProductId[] productIds) + { + if (productIds.Length == 0) + throw new ArgumentOutOfRangeException(nameof(productIds.Length)); + + // TODO: access cache or make a gRPC call + + return productIds + .Select(pi => new ValueTuple(pi, true)) + .ToList(); + } +} diff --git a/src/Retail/ShoppingCart/Sku.cs b/src/Retail/ShoppingCart/Sku.cs new file mode 100644 index 0000000..1a79eb5 --- /dev/null +++ b/src/Retail/ShoppingCart/Sku.cs @@ -0,0 +1,22 @@ +using Eventuous; + +namespace ShoppingCart; + +public record Sku +{ + public string Value { get; internal init; } = string.Empty; + + internal Sku() { } + + public Sku(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("SKU value cannot be empty"); + + Value = value; + } + + public bool HasSameValue(Sku another) => Value == another.Value; + + public static implicit operator string(Sku sku) => sku.Value; +}