diff --git a/src/SomeCompany/Inventory/Inventory.cs b/src/SomeCompany/Inventory/Inventory.cs index 65301cf..e3a6600 100644 --- a/src/SomeCompany/Inventory/Inventory.cs +++ b/src/SomeCompany/Inventory/Inventory.cs @@ -3,20 +3,25 @@ using EventStore.Client; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Npgsql; using Transacto; using Transacto.Framework; +using Transacto.Framework.CommandHandling; +using Transacto.Infrastructure; namespace SomeCompany.Inventory { public class Inventory : IPlugin { public string Name { get; } = nameof(Inventory); - public void Configure(IEndpointRouteBuilder builder) - => builder.UseInventory(); + public void Configure(IEndpointRouteBuilder builder) => builder + .MapCommands(string.Empty, typeof(DefineInventoryItem)); public void ConfigureServices(IServiceCollection services) - => services.AddNpgSqlProjection(); + => services + .AddSingleton(provider => new InventoryItemModule( + provider.GetRequiredService(), + provider.GetRequiredService(), + TransactoSerializerOptions.Events)) + .AddNpgSqlProjection(); public IEnumerable MessageTypes { get { yield return typeof(InventoryItemDefined); } } } diff --git a/src/SomeCompany/Inventory/InventoryItem.cs b/src/SomeCompany/Inventory/InventoryItem.cs index 3879d41..91912fb 100644 --- a/src/SomeCompany/Inventory/InventoryItem.cs +++ b/src/SomeCompany/Inventory/InventoryItem.cs @@ -6,7 +6,10 @@ public class InventoryItem : AggregateRoot { public static readonly Func Factory = () => new InventoryItem(); public InventoryItemIdentifier Identifier { get; private set; } - public override string Id => Identifier.ToString(); + public override string Id => FormatStreamName(Identifier); + + public static string FormatStreamName(InventoryItemIdentifier identifier) => + $"inventoryItem-{identifier}"; private InventoryItem() { Register(e => Identifier = new InventoryItemIdentifier(e.InventoryItemId)); diff --git a/src/SomeCompany/Inventory/InventoryItemIdentifier.cs b/src/SomeCompany/Inventory/InventoryItemIdentifier.cs index baedc27..a1dc341 100644 --- a/src/SomeCompany/Inventory/InventoryItemIdentifier.cs +++ b/src/SomeCompany/Inventory/InventoryItemIdentifier.cs @@ -6,7 +6,7 @@ namespace SomeCompany.Inventory { public InventoryItemIdentifier(Guid value) { if (value == Guid.Empty) { - throw new ArgumentException(); + throw new ArgumentOutOfRangeException(nameof(value)); } _value = value; diff --git a/src/SomeCompany/Inventory/InventoryItemModule.cs b/src/SomeCompany/Inventory/InventoryItemModule.cs index cf6c7b1..50d0018 100644 --- a/src/SomeCompany/Inventory/InventoryItemModule.cs +++ b/src/SomeCompany/Inventory/InventoryItemModule.cs @@ -1,6 +1,7 @@ using System.Text.Json; using EventStore.Client; using Transacto.Framework; +using Transacto.Framework.CommandHandling; namespace SomeCompany.Inventory { public class InventoryItemModule : CommandHandlerModule { @@ -9,12 +10,14 @@ public InventoryItemModule(EventStoreClient eventStore, Build() .Log() .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { + .Handle(async (_, ct) => { var (unitOfWork, command) = _; var handlers = new InventoryItemHandlers( new InventoryItemRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); + await handlers.Handle(command, ct); + + return Position.Start; }); } } diff --git a/src/SomeCompany/Inventory/InventoryItemRepository.cs b/src/SomeCompany/Inventory/InventoryItemRepository.cs index 18958cf..100092c 100644 --- a/src/SomeCompany/Inventory/InventoryItemRepository.cs +++ b/src/SomeCompany/Inventory/InventoryItemRepository.cs @@ -1,5 +1,6 @@ using EventStore.Client; using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Infrastructure; namespace SomeCompany.Inventory { @@ -8,9 +9,8 @@ public class InventoryItemRepository { public InventoryItemRepository(EventStoreClient eventStore, IMessageTypeMapper messageTypeMapper, UnitOfWork unitOfWork) { - _inner = new EventStoreRepository(eventStore, unitOfWork, - InventoryItem.Factory, id => $"inventoryItem-{id}", messageTypeMapper, - TransactoSerializerOptions.Events); + _inner = new EventStoreRepository(eventStore, unitOfWork, InventoryItem.Factory, + messageTypeMapper, TransactoSerializerOptions.Events); } public void Add(InventoryItem inventoryItem) => _inner.Add(inventoryItem); diff --git a/src/SomeCompany/Inventory/InventoryLedger.cs b/src/SomeCompany/Inventory/InventoryLedgerProjection.cs similarity index 84% rename from src/SomeCompany/Inventory/InventoryLedger.cs rename to src/SomeCompany/Inventory/InventoryLedgerProjection.cs index 22157cb..4f7f181 100644 --- a/src/SomeCompany/Inventory/InventoryLedger.cs +++ b/src/SomeCompany/Inventory/InventoryLedgerProjection.cs @@ -5,8 +5,8 @@ using Transacto; namespace SomeCompany.Inventory { - public class InventoryLedger : NpgsqlProjection { - public InventoryLedger() : base(new Scripts()) { + public class InventoryLedgerProjection : NpgsqlProjection { + public InventoryLedgerProjection() : base(new Scripts()) { When(); When(e => new[] { diff --git a/src/SomeCompany/Inventory/InventoryMiddleware.cs b/src/SomeCompany/Inventory/InventoryMiddleware.cs deleted file mode 100644 index 368c492..0000000 --- a/src/SomeCompany/Inventory/InventoryMiddleware.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace SomeCompany.Inventory { - internal static class InventoryMiddleware { - public static void UseInventory(this IEndpointRouteBuilder builder) { } - } -} diff --git a/src/SomeCompany/Inventory/Scripts.cs b/src/SomeCompany/Inventory/Scripts.cs index 5b2027f..ee571f7 100644 --- a/src/SomeCompany/Inventory/Scripts.cs +++ b/src/SomeCompany/Inventory/Scripts.cs @@ -1,4 +1,3 @@ -using SomeCompany.Infrastructure; using Transacto; namespace SomeCompany.Inventory { diff --git a/src/SomeCompany/Program.cs b/src/SomeCompany/Program.cs index 87c7796..2a94bac 100644 --- a/src/SomeCompany/Program.cs +++ b/src/SomeCompany/Program.cs @@ -1,7 +1,10 @@ using System; +using System.Globalization; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Dapper; using EventStore.Client; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -14,12 +17,13 @@ namespace SomeCompany { internal class Program : IDisposable { private readonly CancellationTokenSource _exitedSource; - private readonly IStreamStore _streamStore; private readonly IHostBuilder _hostBuilder; private Program(SomeCompanyConfiguration configuration) { - Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + DefaultTypeMap.MatchNamesWithUnderscores = true; + Inflector.Inflector.SetDefaultCultureFunc = () => new CultureInfo("en-US"); Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() //.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .Enrich.FromLogContext() .WriteTo.Console( @@ -29,24 +33,19 @@ private Program(SomeCompanyConfiguration configuration) { _exitedSource = new CancellationTokenSource(); - var connectionStringBuilder = new NpgsqlConnectionStringBuilder(configuration.ConnectionString); - - _streamStore = new InMemoryStreamStore(); - - _hostBuilder = Host.CreateDefaultBuilder() - .ConfigureLogging(builder => builder.AddSerilog()) - .ConfigureWebHost(builder => builder - .UseKestrel() - .ConfigureServices(services => services - .AddEventStoreClient() - .AddSingleton(connectionStringBuilder) - .AddSingleton(_streamStore)) - .UseStartup(new Startup(GetPlugins()))); - - static IPlugin[] GetPlugins() => - typeof(Startup).Assembly.GetExportedTypes().Where(typeof(IPlugin).IsAssignableFrom) - .Select(t => (IPlugin)Activator.CreateInstance(t)!) - .ToArray(); + _hostBuilder = TransactoHost.Build(new ServiceCollection() + .AddEventStoreClient(settings => settings.CreateHttpMessageHandler = () => new SocketsHttpHandler { + SslOptions = { + RemoteCertificateValidationCallback = delegate { return true; } + } + }) + .AddSingleton(new HttpClientSqlStreamStore(new HttpClientSqlStreamStoreSettings { + BaseAddress = new UriBuilder { + Port = 5002 + }.Uri + })) + .AddSingleton(new NpgsqlConnectionStringBuilder(configuration.ConnectionString)) + .BuildServiceProvider()); } private async Task Run() { @@ -69,7 +68,6 @@ public static async Task Main(string[] args) { public void Dispose() { _exitedSource.Dispose(); - _streamStore.Dispose(); } } } diff --git a/src/SomeCompany/PurchaseOrders/PurchaseOrder.cs b/src/SomeCompany/PurchaseOrders/PurchaseOrder.cs index b292993..dd91fca 100644 --- a/src/SomeCompany/PurchaseOrders/PurchaseOrder.cs +++ b/src/SomeCompany/PurchaseOrders/PurchaseOrder.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; +using EventStore.Client; using Transacto.Domain; namespace SomeCompany.PurchaseOrders { @@ -37,5 +39,8 @@ public IEnumerable GetAdditionalChanges() { } public int? Version { get; set; } + + [JsonIgnore] + public long Position { get; set; } } } diff --git a/src/SomeCompany/PurchaseOrders/PurchaseOrderMiddleware.cs b/src/SomeCompany/PurchaseOrders/PurchaseOrderMiddleware.cs index e20d830..8e7b6e1 100644 --- a/src/SomeCompany/PurchaseOrders/PurchaseOrderMiddleware.cs +++ b/src/SomeCompany/PurchaseOrders/PurchaseOrderMiddleware.cs @@ -2,11 +2,13 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using EventStore.Client; using Hallo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Transacto; +using Transacto.Framework; namespace SomeCompany.PurchaseOrders { public static class PurchaseOrderMiddleware { @@ -16,7 +18,8 @@ public static void UsePurchaseOrders(this IEndpointRouteBuilder builder, .MapGet(string.Empty, async context => { var orders = await purchaseOrders.List(context.RequestAborted); - return new HalResponse(PurchaseOrderListRepresentation.Instance, orders); + return new HalResponse(context.Request, PurchaseOrderListRepresentation.Instance, + ETag.Create(orders.Max(x => x.Position)), new Optional(orders)); }) .MapPost(string.Empty, async (HttpContext context, PurchaseOrder purchaseOrder) => { if (purchaseOrder.PurchaseOrderId == Guid.Empty) { @@ -25,29 +28,31 @@ public static void UsePurchaseOrders(this IEndpointRouteBuilder builder, await purchaseOrders.Save(purchaseOrder, context.RequestAborted); - return new HalResponse(PurchaseOrderRepresentation.Instance, purchaseOrder) { + return new HalResponse(context.Request, PurchaseOrderRepresentation.Instance, + ETag.Create(purchaseOrder.Version), purchaseOrder) { StatusCode = HttpStatusCode.Created, Headers = { - ("location", purchaseOrder.PurchaseOrderId.ToString()) + Location = new Uri(purchaseOrder.PurchaseOrderId.ToString()) } }; }) .MapGet("{purchaseOrderId:guid}", async context => { if (!context.TryParseGuid(nameof(PurchaseOrder.PurchaseOrderId), out var purchaseOrderId)) { - return new HalResponse(PurchaseOrderRepresentation.Instance) { + return new HalResponse(context.Request, PurchaseOrderRepresentation.Instance) { StatusCode = HttpStatusCode.NotFound }; } var order = await purchaseOrders.Get(purchaseOrderId, context.RequestAborted); - return new HalResponse(PurchaseOrderRepresentation.Instance, order) { + return new HalResponse(context.Request, PurchaseOrderRepresentation.Instance, + ETag.Create(order.HasValue ? order.Value.Position : new long?()), order) { StatusCode = order.HasValue ? HttpStatusCode.OK : HttpStatusCode.NotFound }; }) .MapPut("{purchaseOrderId:guid}", async (HttpContext context, PurchaseOrder purchaseOrder) => { if (!context.TryParseGuid(nameof(purchaseOrder.PurchaseOrderId), out var purchaseOrderId)) { - return new HalResponse(PurchaseOrderRepresentation.Instance) { + return new HalResponse(context.Request, PurchaseOrderRepresentation.Instance) { StatusCode = HttpStatusCode.NotFound }; } @@ -56,7 +61,8 @@ public static void UsePurchaseOrders(this IEndpointRouteBuilder builder, await purchaseOrders.Save(purchaseOrder, context.RequestAborted); - return new HalResponse(PurchaseOrderRepresentation.Instance, purchaseOrder); + return new HalResponse(context.Request, PurchaseOrderRepresentation.Instance, + ETag.Create(purchaseOrder.Version), purchaseOrder); }) .MapBusinessTransaction("{purchaseOrderId:guid}"); } diff --git a/src/SomeCompany/PurchaseOrders/PurchaseOrderRepository.cs b/src/SomeCompany/PurchaseOrders/PurchaseOrderRepository.cs index 9a2c118..0633e62 100644 --- a/src/SomeCompany/PurchaseOrders/PurchaseOrderRepository.cs +++ b/src/SomeCompany/PurchaseOrders/PurchaseOrderRepository.cs @@ -13,13 +13,13 @@ namespace SomeCompany.PurchaseOrders { public class PurchaseOrderRepository { private readonly string _schema; private readonly Func> _connectionFactory; - private readonly SqlStreamStoreBusinessTransactionRepository _inner; + private readonly StreamStoreBusinessTransactionRepository _inner; public PurchaseOrderRepository(IStreamStore streamStore, string schema, Func> connectionFactory) { _schema = schema; _connectionFactory = connectionFactory; - _inner = new SqlStreamStoreBusinessTransactionRepository(streamStore, + _inner = new StreamStoreBusinessTransactionRepository(streamStore, order => GetStreamName(order.PurchaseOrderId), new JsonSerializerOptions()); } diff --git a/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.cs b/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.cs index e8f811a..b402b87 100644 --- a/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.cs +++ b/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using Transacto.Domain; -using Transacto.Framework; namespace SomeCompany.ReceiptOfGoods { partial class ReceiptOfGoods : IBusinessTransaction { @@ -11,7 +10,8 @@ private static (Credit, Debit) Accumulate((Credit, Debit) _, ReceiptOfGoodsItem return (inventoryInTransit + item.Total, inventoryOnHand + item.Total); } - public GeneralLedgerEntryNumber ReferenceNumber => new GeneralLedgerEntryNumber("goodsReceipt-" + ReceiptOfGoodsNumber); + public GeneralLedgerEntryNumber ReferenceNumber => + new GeneralLedgerEntryNumber("goodsReceipt", ReceiptOfGoodsNumber); public void Apply(GeneralLedgerEntry entry, ChartOfAccounts chartOfAccounts) { var (inventoryInTransit, inventoryOnHand) = ReceiptOfGoodsItems.Aggregate( diff --git a/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.schema.json b/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.schema.json index 3e5a5dc..5d0df4c 100644 --- a/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.schema.json +++ b/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoods.schema.json @@ -21,7 +21,7 @@ "title": "Purchase Order Id", "$ref": "#/definitions/uuid", "x-schema-form": { - "key": "purchaseOrderId", + "key": "purchaseOrderId", "type": "uuid" } }, diff --git a/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoodsItems.cs b/src/SomeCompany/ReceiptOfGoods/ReceiptOfGoodsItem.cs similarity index 100% rename from src/SomeCompany/ReceiptOfGoods/ReceiptOfGoodsItems.cs rename to src/SomeCompany/ReceiptOfGoods/ReceiptOfGoodsItem.cs diff --git a/src/SomeCompany/SomeCompany.csproj b/src/SomeCompany/SomeCompany.csproj index a51ee31..25ffd5f 100644 --- a/src/SomeCompany/SomeCompany.csproj +++ b/src/SomeCompany/SomeCompany.csproj @@ -5,11 +5,13 @@ $(RestoreSources);https://api.nuget.org/v3/index.json;https://nuget.pkg.github.com/thefringeninja/index.json true 8.0 + true + diff --git a/src/SomeCompany/yarn.lock b/src/SomeCompany/yarn.lock index 915b0c3..2c12ee0 100644 --- a/src/SomeCompany/yarn.lock +++ b/src/SomeCompany/yarn.lock @@ -2509,7 +2509,7 @@ debug@=3.1.0: dependencies: ms "2.0.0" -debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.6: +debug@^3.1.0, debug@^3.1.1, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== @@ -3108,9 +3108,9 @@ event-emitter@^0.3.5, event-emitter@~0.3.5: es5-ext "~0.10.14" eventemitter3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" - integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== events@^3.0.0: version "3.0.0" @@ -3517,11 +3517,9 @@ follow-redirects@1.5.10: debug "=3.1.0" follow-redirects@^1.0.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f" - integrity sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A== - dependencies: - debug "^3.0.0" + version "1.13.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" @@ -4140,9 +4138,9 @@ http-proxy-middleware@~0.17.4: micromatch "^2.3.11" http-proxy@^1.16.2: - version "1.18.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== dependencies: eventemitter3 "^4.0.0" follow-redirects "^1.0.0" diff --git a/src/Transacto.AspNetCore/BuilderExtensions.cs b/src/Transacto.AspNetCore/BuilderExtensions.cs index 90b6786..e6fd694 100644 --- a/src/Transacto.AspNetCore/BuilderExtensions.cs +++ b/src/Transacto.AspNetCore/BuilderExtensions.cs @@ -1,45 +1,67 @@ using System; using System.Linq; using System.Net; -using System.Net.Http; -using System.Reflection; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; +using EventStore.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; using Transacto.Domain; -using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Infrastructure; using Transacto.Messages; namespace Transacto { static partial class BuilderExtensions { - public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, string route, - Func> getResponse) { - var routeTemplate = TemplateParser.Parse(route); - var argumentCount = routeTemplate.Parameters.Count(p => p.IsParameter); - - if (argumentCount != 0) { - throw new Exception(); - } - - return builder.MapGet(route, values => null!, (_, ct) => getResponse(ct)); - } - public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, string route, Func> getResponse) { + var separator = new[] {'/'}; builder.MapGet(route, async context => { var response = await getResponse(context); + if (TryParsePosition(response.Headers.ETag, out var responsePosition)) { + var positions = context.Request.GetTypedHeaders() + .IfMatch + .Where(etag => etag.Tag.HasValue) + .Select(etag => TryParsePosition(etag, out var position) ? position : Position.Start) + .OrderByDescending(p => p); + + foreach (var requestedPosition in positions) { + if (responsePosition >= requestedPosition) { + await response.Write(context.Response); + return; + } + } + + await PreconditionFailedResponse.Instance.Write(context.Response); + + return; + } await response.Write(context.Response); }); return builder; + + bool TryParsePosition(EntityTagHeaderValue etag, out Position position) { + position = default; + var value = etag?.Tag.ToString(); + if (value == "*") { + position = Position.Start; + return true; + } + + var parts = value?.Split(separator, 2) ?? Array.Empty(); + + if (parts.Length != 2 || !ulong.TryParse(parts[0][1..], out var p) || !ulong.TryParse(parts[1][..^1], out var c)) { + return false; + } + + position = new Position(p, c); + return true; + } } public static IEndpointRouteBuilder MapPost(this IEndpointRouteBuilder builder, string route, @@ -76,30 +98,20 @@ public static IEndpointRouteBuilder MapPut(this IEndpointRouteBuilder builder return builder; } - public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, string route, - Func> getResponse) { - var routeTemplate = TemplateParser.Parse(route); - var argumentCount = routeTemplate.Parameters.Count(p => p.IsParameter); - - if (argumentCount == 0) { - throw new Exception(); - } - - if (argumentCount == 1) { - return builder.MapGet(route, values => (T)values[0], getResponse); - } - - var createDtoMethod = typeof(T).GetMethods(BindingFlags.Static | BindingFlags.Public) - .Single(mi => mi.Name == "Create" && - mi.IsGenericMethod && - mi.GetGenericArguments().Length == argumentCount); + public static IEndpointRouteBuilder MapCommands(this IEndpointRouteBuilder builder, string route, + params Type[] commandTypes) => + builder.MapCommandsInternal(route, TransactoSerializerOptions.Commands, commandTypes); - return builder.MapGet(route, values => (T)createDtoMethod.Invoke(null, values)!, getResponse); - } + public static IEndpointRouteBuilder MapBusinessTransaction(this IEndpointRouteBuilder builder, string route) + where T : IBusinessTransaction => + builder.MapCommandsInternal(route, TransactoSerializerOptions.BusinessTransactions(typeof(T)), + typeof(PostGeneralLedgerEntry)); - public static IEndpointRouteBuilder MapCommands(this IEndpointRouteBuilder builder, string route, + private static IEndpointRouteBuilder MapCommandsInternal(this IEndpointRouteBuilder builder, string route, + JsonSerializerOptions serializerOptions, params Type[] commandTypes) { var dispatcher = new CommandDispatcher(builder.ServiceProvider.GetServices()); + var map = commandTypes.ToDictionary(commandType => commandType.Name); builder.MapPost(route, async context => { @@ -108,61 +120,30 @@ public static IEndpointRouteBuilder MapCommands(this IEndpointRouteBuilder build return new Response {StatusCode = HttpStatusCode.UnsupportedMediaType}; } - if (context.Request.Form.Files.Count != 1 || - !context.Request.Form.TryGetValue("command", out var commandName) || - !map.TryGetValue(commandName, out var commandType)) { - return new Response {StatusCode = HttpStatusCode.BadRequest}; + if (!context.Request.Form.TryGetValue("command", out var commandName)) { + return new TextResponse($"No command type was specified.") { + StatusCode = HttpStatusCode.BadRequest + }; } - await using var commandStream = context.Request.Form.Files[0].OpenReadStream(); - var command = await JsonSerializer.DeserializeAsync(commandStream, commandType, - TransactoSerializerOptions.Commands); - - await dispatcher.Handle(command, context.RequestAborted); - - return new Response(); - }); - - return builder; - } - - public static IEndpointRouteBuilder MapBusinessTransaction(this IEndpointRouteBuilder builder, string route) - where T : IBusinessTransaction { - var dispatcher = new CommandDispatcher(builder.ServiceProvider.GetServices()); - var serializerOptions = TransactoSerializerOptions.BusinessTransactions(typeof(T)); - - builder.MapPost(route, async context => { - if (!MediaTypeHeaderValue.TryParse(context.Request.ContentType, out var mediaType) || - !mediaType.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase)) { - return new Response {StatusCode = HttpStatusCode.UnsupportedMediaType}; + if (!map.TryGetValue(commandName, out var commandType)) { + return new TextResponse($"The command type '{commandName}' was not recognized.") { + StatusCode = HttpStatusCode.BadRequest + }; } - if (context.Request.Form.Files.Count != 1 || - !context.Request.Form.TryGetValue("command", out var commandName) || - commandName != nameof(PostGeneralLedgerEntry)) { - return new Response {StatusCode = HttpStatusCode.BadRequest}; + if (context.Request.Form.Files.Count != 1) { + return new TextResponse("No command was found on the request.") + {StatusCode = HttpStatusCode.BadRequest}; } await using var commandStream = context.Request.Form.Files[0].OpenReadStream(); - var command = await JsonSerializer.DeserializeAsync(commandStream, typeof(PostGeneralLedgerEntry), + var command = await JsonSerializer.DeserializeAsync(commandStream, commandType, serializerOptions); - await dispatcher.Handle(command, context.RequestAborted); - - return new Response(); - }); - - return builder; - } - - private static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, string route, - Func getDto, Func> getResponse) { - builder.MapMethods(route, new[] {HttpMethod.Get.Method}, async context => { - var dto = getDto(context.GetRouteData().Values.Values.ToArray()); - - var response = await getResponse(dto, context.RequestAborted); + var position = await dispatcher.Handle(command, context.RequestAborted); - await response.Write(context.Response); + return new CommandHandledResponse(position); }); return builder; diff --git a/src/Transacto.AspNetCore/ChartOfAccountRepresentation.cs b/src/Transacto.AspNetCore/ChartOfAccountRepresentation.cs deleted file mode 100644 index d4bc85f..0000000 --- a/src/Transacto.AspNetCore/ChartOfAccountRepresentation.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using Hallo; - -namespace Transacto { - internal class ChartOfAccountRepresentation : Hal>, - IHalLinks>, - IHalState> { - public IEnumerable LinksFor(SortedDictionary resource) { - yield break; - } - - public object StateFor(SortedDictionary resource) => resource; - } -} diff --git a/src/Transacto.AspNetCore/CommandDispatcher.cs b/src/Transacto.AspNetCore/CommandDispatcher.cs index 2d3cb31..c672beb 100644 --- a/src/Transacto.AspNetCore/CommandDispatcher.cs +++ b/src/Transacto.AspNetCore/CommandDispatcher.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using EventStore.Client; using Transacto.Framework; +using Transacto.Framework.CommandHandling; namespace Transacto { internal class CommandDispatcher { @@ -11,7 +13,7 @@ public CommandDispatcher(IEnumerable commandHandlerModules _resolver = CommandResolve.WhenEqualToHandlerMessageType(commandHandlerModules); } - public ValueTask Handle(object command, CancellationToken cancellationToken = default) => + public ValueTask Handle(object command, CancellationToken cancellationToken = default) => _resolver.Invoke(command).Handler(command, cancellationToken); } } diff --git a/src/Transacto.AspNetCore/CommandHandledResponse.cs b/src/Transacto.AspNetCore/CommandHandledResponse.cs new file mode 100644 index 0000000..d23b495 --- /dev/null +++ b/src/Transacto.AspNetCore/CommandHandledResponse.cs @@ -0,0 +1,19 @@ +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client; + +namespace Transacto { + public sealed class CommandHandledResponse : Response { + private readonly Position _position; + + public CommandHandledResponse(Position position) { + _position = position; + } + + protected internal override ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) => + stream.WriteAsync(Encoding.UTF8.GetBytes($"{_position.CommitPosition}/{_position.PreparePosition}"), + cancellationToken); + } +} diff --git a/src/Transacto.AspNetCore/ETag.cs b/src/Transacto.AspNetCore/ETag.cs new file mode 100644 index 0000000..0eeb102 --- /dev/null +++ b/src/Transacto.AspNetCore/ETag.cs @@ -0,0 +1,31 @@ +using System; +using EventStore.Client; +using Transacto.Framework; +using HashCode = System.HashCode; + +namespace Transacto { + public struct ETag : IEquatable { + public static readonly ETag None = default; + private readonly string _value; + + public static ETag Create(Optional position) => + Create(position.HasValue ? position.Value : Position.Start); + + public static ETag Create(long? position) => position.HasValue + ? new ETag(position.Value.ToString()) + : None; + + private static ETag Create(Position position) => new ETag($"{position.CommitPosition}/{position.PreparePosition}"); + + private ETag(string value) { + _value = value; + } + + public bool Equals(ETag other) => _value == other._value; + public override bool Equals(object? obj) => obj is ETag other && Equals(other); + public override int GetHashCode() => HashCode.Combine(_value); + public static bool operator ==(ETag left, ETag right) => left.Equals(right); + public static bool operator !=(ETag left, ETag right) => !left.Equals(right); + public override string ToString() => _value; + } +} diff --git a/src/Transacto.AspNetCore/HalResponse.cs b/src/Transacto.AspNetCore/HalResponse.cs index bc4c898..8490911 100644 --- a/src/Transacto.AspNetCore/HalResponse.cs +++ b/src/Transacto.AspNetCore/HalResponse.cs @@ -1,34 +1,139 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Hallo; using Hallo.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; +using RazorLight; +using Transacto.Framework; +using Transacto.Views; namespace Transacto { public class HalResponse : Response { private static readonly object EmptyBody = new object(); - private readonly object _resource; - private readonly IHal _hal; - - private static readonly JsonSerializerOptions SerializerOptions - = new JsonSerializerOptions { - Converters = { - new LinksConverter(), - new HalRepresentationConverter() - }, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - public HalResponse(IHal hal, object? resource = null) { - _resource = resource ?? EmptyBody; - _hal = hal; - Headers.Add(("content-type", "application/hal+json")); + private static readonly MediaType HalJson = new MediaType("application/hal+json"); + private static readonly MediaType Html = new MediaType("text/html"); + + private readonly Response _inner; + + public override ResponseHeaders Headers => _inner.Headers; + public override HttpStatusCode StatusCode { get => _inner.StatusCode; set => _inner.StatusCode = value; } + + public HalResponse(HttpRequest request, IHal hal, ETag etag = default) : this(request, hal, + etag, null!) { + } + + public HalResponse(HttpRequest request, IHal hal, ETag etag, Optional resource) { + _inner = request.Headers["accept"].Count == 0 + ? new HalJsonResponse(hal, resource) + : request.Headers["accept"].Select(MediaType).Select(Negotiate).FirstOrDefault() ?? + NotAcceptableResponse.Instance; + + if (etag != ETag.None) { + _inner.Headers.ETag = new EntityTagHeaderValue($@"""{etag.ToString()}"""); + } + + Response Negotiate(MediaType m) => + (HalJson.IsSubsetOf(m), Html.IsSubsetOf(m) || m.SubTypeSuffix == "html") switch { + (true, false) => new HalJsonResponse(hal, resource), + (false, true) => new HalHtmlResponse(hal, resource), + _ => NotAcceptableResponse.Instance + }; + + static MediaType MediaType(string x) => new MediaType(x ?? string.Empty); + } + + protected internal override ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) => _inner + .WriteBody(stream, cancellationToken); + + private sealed class HalHtmlResponse : Response { + private static readonly ConcurrentDictionary Engines = + new ConcurrentDictionary(); + + private static readonly MediaTypeHeaderValue ContentType = new MediaTypeHeaderValue("text/html"); + + private readonly object _resource; + private readonly IHal _hal; + + public HalHtmlResponse(IHal hal, Optional resource) { + _resource = resource.HasValue ? resource.Value : EmptyBody; + _hal = hal; + + Headers.ContentType = ContentType; + } + + protected internal override async ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) { + var representation = await _hal.RepresentationOfAsync(_resource); + await stream.WriteAsync(Encoding.UTF8.GetBytes(""), cancellationToken); + + await stream.WriteAsync(Encoding.UTF8.GetBytes(await Render(typeof(Links), representation)), + cancellationToken); + await stream.WriteAsync(Encoding.UTF8.GetBytes(await Render(_hal.GetType(), representation)), + cancellationToken); + + await stream.WriteAsync(Encoding.UTF8.GetBytes(""), cancellationToken); + } + + private static Task Render(Type type, HalRepresentation representation) => Engines + .GetOrAdd(type.Assembly, assembly => new RazorLightEngineBuilder() + .UseEmbeddedResourcesProject(assembly) + .UseMemoryCachingProvider() + .Build()) + .CompileRenderAsync(type.FullName, representation.State); } - protected override async ValueTask WriteBody(Stream stream, CancellationToken cancellationToken = default) { - var representation = await _hal.RepresentationOfAsync(_resource); - await JsonSerializer.SerializeAsync(stream, representation, SerializerOptions, cancellationToken); + private sealed class HalJsonResponse : Response { + private static readonly JsonSerializerOptions SerializerOptions + = new JsonSerializerOptions { + Converters = { + new LinksConverter(), + new HalRepresentationConverter() + }, + + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static readonly MediaTypeHeaderValue ContentType = new MediaTypeHeaderValue("application/hal+json"); + + private readonly object _resource; + private readonly IHal _hal; + + public HalJsonResponse(IHal hal, Optional resource) { + _resource = resource.HasValue ? resource.Value : EmptyBody; + _hal = hal; + Headers.ContentType = ContentType; + } + + protected internal override async ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) { + var representation = await _hal.RepresentationOfAsync(_resource); + await JsonSerializer.SerializeAsync(stream, representation, SerializerOptions, cancellationToken); + } + + private class EnumerableOfDictionaryEntryConverter : JsonConverter> { + public override IEnumerable Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, IEnumerable value, + JsonSerializerOptions options) { + foreach (var entry in value) { + //writer.WritePropertyName(entry.Key.ToString()); + //writer.WriteNullValue(); + } + } + } } } } diff --git a/src/Transacto.AspNetCore/InMemoryProjectionBuilder.cs b/src/Transacto.AspNetCore/InMemoryProjectionBuilder.cs new file mode 100644 index 0000000..294478c --- /dev/null +++ b/src/Transacto.AspNetCore/InMemoryProjectionBuilder.cs @@ -0,0 +1,20 @@ +using System; +using Projac; + +namespace Transacto { + public class InMemoryProjectionBuilder { + private readonly AnonymousProjectionBuilder _inner; + + public InMemoryProjectionBuilder() : this(new AnonymousProjectionBuilder()) { + } + + private InMemoryProjectionBuilder(AnonymousProjectionBuilder inner) { + _inner = inner; + } + + public InMemoryProjectionBuilder When(Action> handler) => + new InMemoryProjectionBuilder(_inner.When(handler)); + + public AnonymousProjection Build() => _inner.Build(); + } +} diff --git a/src/Transacto.AspNetCore/InMemoryProjectionHost.cs b/src/Transacto.AspNetCore/InMemoryProjectionHost.cs index 778a3f1..44bf928 100644 --- a/src/Transacto.AspNetCore/InMemoryProjectionHost.cs +++ b/src/Transacto.AspNetCore/InMemoryProjectionHost.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; @@ -18,7 +17,6 @@ public class InMemoryProjectionHost : IHostedService { private readonly InMemoryReadModel _target; private readonly CancellationTokenSource _stopped; - private int _retryCount; private int _subscribed; private StreamSubscription? _subscription; private CancellationTokenRegistration? _stoppedRegistration; @@ -31,7 +29,6 @@ public InMemoryProjectionHost(EventStoreClient eventStore, IMessageTypeMapper me _target = target; _stopped = new CancellationTokenSource(); - _retryCount = 0; _subscribed = 0; _subscription = null; _stoppedRegistration = null; @@ -53,20 +50,15 @@ private async Task Subscribe(CancellationToken cancellationToken) { await registration.Value.DisposeAsync(); } - Interlocked.Exchange(ref _subscription, await _eventStore.SubscribeToAllAsync(ProjectAsync, + Interlocked.Exchange(ref _subscription, await _eventStore.SubscribeToAllAsync( + Position.Start, + ProjectAsync, subscriptionDropped: (_, reason, ex) => { if (reason == SubscriptionDroppedReason.Disposed) { return; } - if (Interlocked.Increment(ref _retryCount) == 5) { - Log.Error(ex, "Subscription dropped: {reason}", reason); - return; - } - - Log.Warning(ex, "Subscription dropped: {reason}; resubscribing...", reason); - Interlocked.Exchange(ref _subscribed, 0); - Task.Run(() => Subscribe(cancellationToken), cancellationToken); + Log.Error(ex, "Subscription dropped: {reason}", reason); }, filterOptions: new SubscriptionFilterOptions(EventTypeFilter.ExcludeSystemEvents()), userCredentials: new UserCredentials("admin", "changeit"), @@ -80,7 +72,7 @@ Task ProjectAsync(StreamSubscription s, ResolvedEvent e, CancellationToken ct) { return Task.CompletedTask; var message = JsonSerializer.Deserialize( e.Event.Data.Span, type, TransactoSerializerOptions.Events); - return _projector.ProjectAsync(_target, message, ct); + return _projector.ProjectAsync(_target, Envelope.Create(message, e.OriginalEvent.Position), ct); } } diff --git a/src/Transacto.AspNetCore/InMemoryReadModel.cs b/src/Transacto.AspNetCore/InMemoryReadModel.cs index e21ae92..f36aad7 100644 --- a/src/Transacto.AspNetCore/InMemoryReadModel.cs +++ b/src/Transacto.AspNetCore/InMemoryReadModel.cs @@ -1,48 +1,95 @@ using System; -using System.Collections; using System.Collections.Concurrent; +using System.Threading; namespace Transacto { public class InMemoryReadModel { - private readonly ConcurrentDictionary _readModels; + private readonly ConcurrentDictionary _readModels; public InMemoryReadModel() { - _readModels = new ConcurrentDictionary(); + _readModels = new ConcurrentDictionary(); } - public void Update(string key, Action action, Func factory) { - var maybeTarget = _readModels.GetOrAdd(key, _ => factory()); - - if (!(maybeTarget is T target)) { - return; + public bool TryGetValue(string key, out InMemoryReadModelEntry? value) where T : class { + if (!_readModels.TryGetValue(key, out var maybeEntry) || + !(maybeEntry is InMemoryReadModelEntry entry)) { + value = default; + return false; } - if (!(target is IEnumerable)) { - action(target); - return; - } - lock (target) { - action(target); + value = entry; + return true; + } + + public bool TryRemove(string key, out T? value) where T: class { + if (!_readModels.TryGetValue(key, out var maybeEntry) || !(maybeEntry is InMemoryReadModelEntry entry)) { + value = default; + return false; } + + _readModels.TryRemove(key, out _); + value = entry.Item; + return true; } - public bool TryGet(string key, Func clone, out T target) => TryGet(key, clone, out target); + public void AddOrUpdate(string key, Func factory, Action update) where T : class { + _readModels.AddOrUpdate(key, _ => { + var entry = factory(); + update(entry); + return new InMemoryReadModelEntry(entry); + }, (_, maybeEntry) => { + if (!(maybeEntry is InMemoryReadModelEntry entry)) throw new InvalidOperationException(); + using (maybeEntry.Write()) { + update(entry.Item); + } - public bool TryGet(string key, Func transform, out TTransformed target) { - if (!_readModels.TryGetValue(key, out var maybeTarget) || !(maybeTarget is T value)) { - target = default!; - return false; + return maybeEntry; + }); + } + } + + public interface IInMemoryReadModelEntry : IDisposable { + object Item { get; } + IDisposable Read(); + IDisposable Write(); + } + + public class InMemoryReadModelEntry : IInMemoryReadModelEntry where T : class { + private readonly ReaderWriterLockSlim _locker; + object IInMemoryReadModelEntry.Item => Item; + public T Item { get; } + + public InMemoryReadModelEntry(T item) { + Item = item; + _locker = new ReaderWriterLockSlim(); } - if (value is IEnumerable) { - lock (value) { - target = transform(value); + public IDisposable Read() => new ReadLockToken(_locker); + + public IDisposable Write() => new WriteLockToken(_locker); + + public void Dispose() => _locker.Dispose(); + + private class ReadLockToken : IDisposable { + private readonly ReaderWriterLockSlim _locker; + + public ReadLockToken(ReaderWriterLockSlim locker) { + _locker = locker; + _locker.EnterReadLock(); } - } else { - target = transform(value); + + public void Dispose() => _locker.ExitReadLock(); } - return true; + private class WriteLockToken : IDisposable { + private readonly ReaderWriterLockSlim _locker; + + public WriteLockToken(ReaderWriterLockSlim locker) { + _locker = locker; + _locker.EnterWriteLock(); + } + + public void Dispose() => _locker.ExitWriteLock(); + } } - } } diff --git a/src/Transacto.AspNetCore/Negotiate.cs b/src/Transacto.AspNetCore/Negotiate.cs new file mode 100644 index 0000000..c78b02f --- /dev/null +++ b/src/Transacto.AspNetCore/Negotiate.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Http; + +namespace Transacto { + public static class Negotiate { + public static Response Content(HttpRequest request, params Response[] responses) { + foreach (var acceptHeader in request.Headers.GetCommaSeparatedValues("accept") + .Select(MediaTypeWithQualityHeaderValue.Parse) + .OrderByDescending(h => h.Quality) + .Select(h => new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(h.MediaType))) { + var match = responses.FirstOrDefault(response => response.Headers.ContentType + .IsSubsetOf(acceptHeader)); + if (match == null) { + continue; + } + + return match; + } + + return new Response { + StatusCode = HttpStatusCode.NotAcceptable + }; + } + } +} diff --git a/src/Transacto.AspNetCore/NotAcceptableResponse.cs b/src/Transacto.AspNetCore/NotAcceptableResponse.cs new file mode 100644 index 0000000..c0a1797 --- /dev/null +++ b/src/Transacto.AspNetCore/NotAcceptableResponse.cs @@ -0,0 +1,11 @@ +using System.Net; + +namespace Transacto { + public sealed class NotAcceptableResponse : Response { + public static readonly Response Instance = new NotAcceptableResponse(); + + private NotAcceptableResponse() { + StatusCode = HttpStatusCode.NotAcceptable; + } + } +} diff --git a/src/Transacto.AspNetCore/NotFoundResponse.cs b/src/Transacto.AspNetCore/NotFoundResponse.cs index 25fa18d..531cc4c 100644 --- a/src/Transacto.AspNetCore/NotFoundResponse.cs +++ b/src/Transacto.AspNetCore/NotFoundResponse.cs @@ -9,7 +9,7 @@ public NotFoundResponse() { StatusCode = HttpStatusCode.NotFound; } - protected override ValueTask WriteBody(Stream stream, CancellationToken cancellationToken = default) => + protected internal override ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) => new ValueTask(Task.CompletedTask); } } diff --git a/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheet.cs b/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheet.cs index cba9a1d..5ff2771 100644 --- a/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheet.cs +++ b/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheet.cs @@ -1,17 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; -using Hallo; -using Microsoft.AspNetCore.Http; +using EventStore.Client; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Projac; +using Transacto.Framework; using Transacto.Messages; namespace Transacto.Plugins.BalanceSheet { - internal class BalanceSheet : IPlugin { + internal class + BalanceSheet : IPlugin { public string Name { get; } = nameof(BalanceSheet); public void Configure(IEndpointRouteBuilder builder) => builder @@ -20,89 +19,115 @@ public void Configure(IEndpointRouteBuilder builder) => builder var thru = DateTimeOffset.Parse(context.GetRouteValue("thru")!.ToString()!); - return !readModel.TryGet(nameof(BalanceSheet), - balanceSheet => new BalanceSheetReport { - Thru = thru.UtcDateTime, - LineItems = balanceSheet.GetLines(thru.UtcDateTime), - LineItemGroupings = balanceSheet.GetGroupings(thru.UtcDateTime) - }, out var report) - ? new ValueTask(new NotFoundResponse()) - : new ValueTask(new HalResponse(new BalanceSheetReportRepresentation(), report)); + if (!readModel.TryGetValue(nameof(BalanceSheet), out var entry)) { + return new ValueTask(new NotFoundResponse()); + } + + using (entry!.Read()) { + return new ValueTask(new HalResponse(context.Request, + new BalanceSheetReportRepresentation(), ETag.Create(entry.Item.Checkpoint), new BalanceSheetReport { + Thru = thru.UtcDateTime, + LineItems = entry.Item.GetLines(thru.UtcDateTime), + LineItemGroupings = entry.Item.GetGroupings(thru.UtcDateTime) + })); + } }); - public void ConfigureServices(IServiceCollection services) - => services.AddInMemoryProjection(new AnonymousProjectionBuilder() + public void ConfigureServices(IServiceCollection services) => services + .AddInMemoryProjection(new InMemoryProjectionBuilder() .When((readModel, e) => - readModel.Update(nameof(BalanceSheet), _ => _.AccountNames[e.AccountNumber] = e.AccountName, - ReadModel.Factory)) + readModel.AddOrUpdate( + nameof(BalanceSheet), + ReadModel.Factory, _ => { + _.AccountNames[e.Message.AccountNumber] = e.Message.AccountName; + _.Checkpoint = e.Position; + })) .When((readModel, e) => - readModel.Update(nameof(BalanceSheet), _ => _.AccountNames[e.AccountNumber] = e.NewAccountName, - ReadModel.Factory)) + readModel.AddOrUpdate( + nameof(BalanceSheet), + ReadModel.Factory, _ => { + _.AccountNames[e.Message.AccountNumber] = e.Message.NewAccountName; + _.Checkpoint = e.Position; + })) .When((readModel, e) => - readModel.Update(nameof(BalanceSheet), _ => _.UnpostedEntries.TryAdd(e.GeneralLedgerEntryId, - new Entry { - CreatedOn = e.CreatedOn.UtcDateTime - }), ReadModel.Factory)) + readModel.AddOrUpdate( + nameof(BalanceSheet), ReadModel.Factory, _ => { + _.UnpostedEntries.TryAdd(e.Message.GeneralLedgerEntryId, + new Entry { + CreatedOn = e.Message.CreatedOn.UtcDateTime + }); + _.Checkpoint = e.Position; + })) .When((readModel, e) => - readModel.Update(nameof(BalanceSheet), - _ => _.UnpostedEntries[e.GeneralLedgerEntryId].Debits[e.AccountNumber] = - _.UnpostedEntries[e.GeneralLedgerEntryId].Debits.ContainsKey(e.AccountNumber) - ? _.UnpostedEntries[e.GeneralLedgerEntryId].Debits[e.AccountNumber] + e.Amount - : e.Amount, - ReadModel.Factory)) + readModel.AddOrUpdate( + nameof(BalanceSheet), + ReadModel.Factory, _ => { + _.UnpostedEntries[e.Message.GeneralLedgerEntryId].Debits[e.Message.AccountNumber] = + _.UnpostedEntries[e.Message.GeneralLedgerEntryId].Debits + .ContainsKey(e.Message.AccountNumber) + ? _.UnpostedEntries[e.Message.GeneralLedgerEntryId] + .Debits[e.Message.AccountNumber] + + e.Message.Amount + : e.Message.Amount; + _.Checkpoint = e.Position; + })) .When((readModel, e) => - readModel.Update(nameof(BalanceSheet), - _ => _.UnpostedEntries[e.GeneralLedgerEntryId].Credits[e.AccountNumber] = - _.UnpostedEntries[e.GeneralLedgerEntryId].Credits.ContainsKey(e.AccountNumber) - ? _.UnpostedEntries[e.GeneralLedgerEntryId].Credits[e.AccountNumber] + e.Amount - : e.Amount, - ReadModel.Factory)) + readModel.AddOrUpdate( + nameof(BalanceSheet), + ReadModel.Factory, _ => { + _.UnpostedEntries[e.Message.GeneralLedgerEntryId].Credits[e.Message.AccountNumber] = + _.UnpostedEntries[e.Message.GeneralLedgerEntryId].Credits + .ContainsKey(e.Message.AccountNumber) + ? _.UnpostedEntries[e.Message.GeneralLedgerEntryId] + .Credits[e.Message.AccountNumber] + + e.Message.Amount + : e.Message.Amount; + _.Checkpoint = e.Position; + })) .When((readModel, e) => - readModel.Update(nameof(BalanceSheet), - _ => { - var entry = _.UnpostedEntries[e.GeneralLedgerEntryId]; - _.UnpostedEntries.Remove(e.GeneralLedgerEntryId); - _.PostedEntries[e.GeneralLedgerEntryId] = entry; - }, ReadModel.Factory)) + readModel.AddOrUpdate( + nameof(BalanceSheet), ReadModel.Factory, _ => { + var entry = _.UnpostedEntries[e.Message.GeneralLedgerEntryId]; + _.UnpostedEntries.Remove(e.Message.GeneralLedgerEntryId); + _.PostedEntries[e.Message.GeneralLedgerEntryId] = entry; + _.Checkpoint = e.Position; + })) .When((readModel, e) => { - readModel.Update(nameof(BalanceSheet), _ => { - foreach (var id in e.GeneralLedgerEntryIds.Concat(new[] {e.ClosingGeneralLedgerEntryId})) { - var entry = _.PostedEntries[id]; - _.PostedEntries.Remove(id); - - foreach (var (accountNumber, amount) in entry.Debits) { - _.ClosedBalance[accountNumber] = _.ClosedBalance.TryGetValue(accountNumber, out var a) - ? a + amount - : amount; - } - - foreach (var (accountNumber, amount) in entry.Credits) { - _.ClosedBalance[accountNumber] = _.ClosedBalance.TryGetValue(accountNumber, out var a) - ? a - amount - : -amount; + readModel.AddOrUpdate( + nameof(BalanceSheet), ReadModel.Factory, _ => { + foreach (var id in e.Message.GeneralLedgerEntryIds.Concat(new[] + {e.Message.ClosingGeneralLedgerEntryId})) { + var entry = _.PostedEntries[id]; + _.PostedEntries.Remove(id); + + foreach (var (accountNumber, amount) in entry.Debits) { + _.ClosedBalance[accountNumber] = + _.ClosedBalance.TryGetValue(accountNumber, out var a) + ? a + amount + : amount; + } + + foreach (var (accountNumber, amount) in entry.Credits) { + _.ClosedBalance[accountNumber] = + _.ClosedBalance.TryGetValue(accountNumber, out var a) + ? a - amount + : -amount; + } } - } - }, ReadModel.Factory); + }); }) .Build()); public IEnumerable MessageTypes => Enumerable.Empty(); - private class BalanceSheetReportRepresentation : Hal, IHalLinks, - IHalState { - public IEnumerable LinksFor(BalanceSheetReport resource) { - yield break; - } - - public object StateFor(BalanceSheetReport resource) => resource; - } - private class ReadModel { public static ReadModel Factory() => new ReadModel(); private ReadModel() { } + public Optional Checkpoint { get; set; } = Optional.Empty; + public Dictionary UnpostedEntries { get; } = new Dictionary(); public Dictionary PostedEntries { get; } = new Dictionary(); public Dictionary ClosedBalance { get; } = new Dictionary(); @@ -112,17 +137,20 @@ public IList GetGroupings(DateTime thru) { var groupings = AccountNames.ToDictionary(x => x.Key, pair => new LineItemGrouping { Name = pair.Value, LineItems = { - new LineItem {AccountNumber = pair.Key, Name = pair.Value, Balance = { - DecimalValue = ClosedBalance.TryGetValue(pair.Key, out var amount) - ? amount - : decimal.Zero - }} + new LineItem { + AccountNumber = pair.Key, Name = pair.Value, Balance = { + DecimalValue = ClosedBalance.TryGetValue(pair.Key, out var amount) + ? amount + : decimal.Zero + } + } } }); foreach (var posted in PostedEntries.Values.Where(x => x.CreatedOn <= thru)) { foreach (var (accountNumber, amount) in posted.Debits) { groupings[accountNumber].LineItems[0].Balance.DecimalValue += amount; } + foreach (var (accountNumber, amount) in posted.Credits) { groupings[accountNumber].LineItems[0].Balance.DecimalValue -= amount; } @@ -145,6 +173,7 @@ public IList GetLines(DateTime thru) { foreach (var (accountNumber, amount) in posted.Debits) { groupings[accountNumber].Balance.DecimalValue += amount; } + foreach (var (accountNumber, amount) in posted.Credits) { groupings[accountNumber].Balance.DecimalValue -= amount; } diff --git a/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheetReportRepresentation.cs b/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheetReportRepresentation.cs new file mode 100644 index 0000000..5276794 --- /dev/null +++ b/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheetReportRepresentation.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Hallo; + +namespace Transacto.Plugins.BalanceSheet { + internal class BalanceSheetReportRepresentation : Hal, IHalLinks, + IHalState { + public IEnumerable LinksFor(BalanceSheetReport resource) { + yield break; + } + + public object StateFor(BalanceSheetReport resource) => resource; + } +} diff --git a/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheetReportRepresentation.cshtml b/src/Transacto.AspNetCore/Plugins/BalanceSheet/BalanceSheetReportRepresentation.cshtml new file mode 100644 index 0000000..e69de29 diff --git a/src/Transacto.AspNetCore/Plugins/ChartOfAccounts.cs b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts.cs deleted file mode 100644 index 20eef82..0000000 --- a/src/Transacto.AspNetCore/Plugins/ChartOfAccounts.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Projac; -using Transacto.Messages; - -namespace Transacto.Plugins { - internal class ChartOfAccounts : IPlugin { - public string Name { get; } = nameof(ChartOfAccounts); - - public void Configure(IEndpointRouteBuilder builder) => builder - .MapGet(string.Empty, (CancellationToken ct) => { - var readModel = builder.ServiceProvider.GetRequiredService(); - var response = - !readModel.TryGet, IDictionary>( - nameof(ChartOfAccounts), - value => new SortedDictionary( - value.ToDictionary(x => x.Key.ToString(), x => x.Value.Item1)), - out var chartOfAccounts) - ? (Response)new NotFoundResponse() - : new HalResponse(new ChartOfAccountRepresentation(), chartOfAccounts); - - return new ValueTask(response); - }) - .MapCommands(string.Empty, - typeof(DefineAccount), - typeof(RenameAccount), - typeof(DeactivateAccount), - typeof(ReactivateAccount)); - - public void ConfigureServices(IServiceCollection services) => services - .AddInMemoryProjection(new AnonymousProjectionBuilder() - .When((readModel, e) => - readModel.Update( - nameof(ChartOfAccounts), - rm => rm.Add(e.AccountNumber, (e.AccountName, true)), - ReadModel)) - .When((readModel, e) => - readModel.Update( - nameof(ChartOfAccounts), - rm => rm[e.AccountNumber] = (rm[e.AccountNumber].accountName, false), - ReadModel)) - .When((readModel, e) => - readModel.Update( - nameof(ChartOfAccounts), - rm => rm[e.AccountNumber] = (rm[e.AccountNumber].accountName, true), - ReadModel)) - .When((readModel, e) => - readModel.Update( - nameof(ChartOfAccounts), - rm => rm[e.AccountNumber] = (e.NewAccountName, rm[e.AccountNumber].active), - ReadModel)) - .Build()); - - public IEnumerable MessageTypes => Enumerable.Empty(); - - private static Dictionary ReadModel() => - new Dictionary(); - } -} diff --git a/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccounts.cs b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccounts.cs new file mode 100644 index 0000000..f9c3fa6 --- /dev/null +++ b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccounts.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using EventStore.Client; +using Hallo; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Transacto.Framework; +using Transacto.Messages; + +namespace Transacto.Plugins.ChartOfAccounts { + internal class ChartOfAccounts : IPlugin { + public string Name { get; } = nameof(ChartOfAccounts); + + public void Configure(IEndpointRouteBuilder builder) => builder + .MapGet(string.Empty, context => { + var readModel = context.RequestServices.GetRequiredService(); + + var hasValue = readModel.TryGetValue( + nameof(ChartOfAccounts), + out var entry); + + var statusCode = hasValue + ? HttpStatusCode.OK + : HttpStatusCode.NotFound; + var response = new HalResponse(context.Request, new ChartOfAccountsRepresentation(), + ETag.Create(entry?.Item.Checkpoint ?? Optional.Empty), + hasValue ? new Optional(entry?.Item!) : Optional.Empty); + if (response.StatusCode != HttpStatusCode.NotAcceptable) { + response.StatusCode = statusCode; + } + + return new ValueTask(response); + }) + .MapCommands(string.Empty, + typeof(DefineAccount), + typeof(RenameAccount), + typeof(DeactivateAccount), + typeof(ReactivateAccount)); + + public void ConfigureServices(IServiceCollection services) => services + .AddInMemoryProjection(new InMemoryProjectionBuilder() + .When((readModel, e) => + readModel.AddOrUpdate( + nameof(ChartOfAccounts), + ReadModel.Factory, rm => { + rm.Checkpoint = e.Position; + rm.List.TryAdd(e.Message.AccountNumber, (e.Message.AccountName, true)); + })) + .When((readModel, e) => + readModel.AddOrUpdate( + nameof(ChartOfAccounts), + ReadModel.Factory, rm => { + rm.Checkpoint = e.Position; + rm.List[e.Message.AccountNumber] = (rm.List[e.Message.AccountNumber].accountName, false); + })) + .When((readModel, e) => + readModel.AddOrUpdate( + nameof(ChartOfAccounts), + ReadModel.Factory, rm => { + rm.Checkpoint = e.Position; + rm.List[e.Message.AccountNumber] = (rm.List[e.Message.AccountNumber].accountName, true); + })) + .When((readModel, e) => + readModel.AddOrUpdate( + nameof(ChartOfAccounts), + ReadModel.Factory, rm => { + rm.Checkpoint = e.Position; + rm.List[e.Message.AccountNumber] = + (e.Message.NewAccountName, rm.List[e.Message.AccountNumber].active); + })) + .Build()); + + public IEnumerable MessageTypes => Enumerable.Empty(); + + private class ChartOfAccountsRepresentation : Hal, + IHalLinks, + IHalState { + public IEnumerable LinksFor(ReadModel resource) { + yield break; + } + + public object StateFor(ReadModel resource) => + new SortedDictionary(resource.List.ToDictionary(x => x.Key.ToString(), + x => x.Value.accountName)); + } + + private class ReadModel { + public static ReadModel Factory() => new ReadModel(); + + public ConcurrentDictionary List { get; } = + new ConcurrentDictionary(); + + public Optional Checkpoint { get; set; } + } + } +} diff --git a/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccountsRepresentation.cs b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccountsRepresentation.cs new file mode 100644 index 0000000..d18d001 --- /dev/null +++ b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccountsRepresentation.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; +using Hallo; + +namespace Transacto.Plugins.ChartOfAccounts { +} diff --git a/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccountsRepresentation.cshtml b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccountsRepresentation.cshtml new file mode 100644 index 0000000..6e32e00 --- /dev/null +++ b/src/Transacto.AspNetCore/Plugins/ChartOfAccounts/ChartOfAccountsRepresentation.cshtml @@ -0,0 +1,10 @@ +@model System.Collections.Generic.SortedDictionary + +
+
    + @foreach (var (key, value) in Model) + { +
  • @key - @value
  • + } +
+
diff --git a/src/Transacto.AspNetCore/Plugins/GeneralLedger.cs b/src/Transacto.AspNetCore/Plugins/GeneralLedger.cs deleted file mode 100644 index 51a6ba8..0000000 --- a/src/Transacto.AspNetCore/Plugins/GeneralLedger.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNetCore.Routing; -using Transacto.Domain; -using Transacto.Messages; - -namespace Transacto.Plugins { - internal class GeneralLedger : IPlugin { - public string Name { get; } = nameof(GeneralLedger); - - public void Configure(IEndpointRouteBuilder builder) => builder - .MapBusinessTransaction("/entries") - .MapCommands(string.Empty, - typeof(OpenGeneralLedger), - typeof(BeginClosingAccountingPeriod)) - ; - - public IEnumerable MessageTypes => Enumerable.Empty(); - } -} diff --git a/src/Transacto.AspNetCore/Plugins/GeneralLedger/GeneralLedger.cs b/src/Transacto.AspNetCore/Plugins/GeneralLedger/GeneralLedger.cs new file mode 100644 index 0000000..640eeb2 --- /dev/null +++ b/src/Transacto.AspNetCore/Plugins/GeneralLedger/GeneralLedger.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using Transacto.Domain; +using Transacto.Framework; +using Transacto.Infrastructure; +using Transacto.Messages; +using Transacto.Modules; + +namespace Transacto.Plugins.GeneralLedger { + internal class GeneralLedger : IPlugin { + public string Name { get; } = nameof(GeneralLedger); + + public void Configure(IEndpointRouteBuilder builder) => builder + .MapBusinessTransaction("/entries") + .MapCommands(string.Empty, + typeof(OpenGeneralLedger), + typeof(BeginClosingAccountingPeriod)); + + public void ConfigureServices(IServiceCollection services) => services + .AddInMemoryProjection(new InMemoryProjectionBuilder() + .When((readModel, e) => + readModel.AddOrUpdate(e.Message.Period, + () => new List {e.Message.GeneralLedgerEntryId}, + l => l.Add(e.Message.GeneralLedgerEntryId))) + .When((readModel, e) => { + if (!readModel.TryRemove>(e.Message.Period, out var value)) { + return; + } + + var notClosed = value.Except(e.Message.GeneralLedgerEntryIds) + .Except(new[] {e.Message.ClosingGeneralLedgerEntryId}) + .ToList(); + + if (notClosed.Count == 0) { + return; + } + + readModel.AddOrUpdate(nameof(notClosed), () => notClosed, x => x.AddRange(notClosed)); + }) + .Build()); + + public IEnumerable MessageTypes => Enumerable.Empty(); + + private class AccountClosingProcess : IHostedService { + private EventStoreClient _eventStore; + private IMessageTypeMapper _messageTypeMapper; + private CancellationTokenSource _stopped; + private int _subscribed; + private StreamSubscription? _subscription; + private CancellationTokenRegistration? _stoppedRegistration; + private readonly CommandDispatcher _dispatcher; + + + public AccountClosingProcess(EventStoreClient eventStore, IMessageTypeMapper messageTypeMapper) { + _eventStore = eventStore; + _messageTypeMapper = messageTypeMapper; + _stopped = new CancellationTokenSource(); + + _subscribed = 0; + _subscription = null; + _stoppedRegistration = null; + _dispatcher = new CommandDispatcher(new[] { + new GeneralLedgerModule(eventStore, messageTypeMapper, TransactoSerializerOptions.Events) + }); + } + + public Task StartAsync(CancellationToken cancellationToken) => Subscribe(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) { + _stopped.Cancel(); + _stoppedRegistration?.Dispose(); + return Task.CompletedTask; + } + + private async Task Subscribe(CancellationToken cancellationToken) { + if (Interlocked.CompareExchange(ref _subscribed, 1, 0) == 1) { + return; + } + + var registration = _stoppedRegistration; + if (registration != null) { + await registration.Value.DisposeAsync(); + } + + Interlocked.Exchange(ref _subscription, await _eventStore.SubscribeToStreamAsync( + Domain.GeneralLedger.Identifier, HandleAsync, subscriptionDropped: (_, reason, ex) => { + if (reason == SubscriptionDroppedReason.Disposed) { + return; + } + + Log.Error(ex, "Subscription dropped: {reason}", reason); + }, + userCredentials: new UserCredentials("admin", "changeit"), + cancellationToken: _stopped.Token)); + + _stoppedRegistration = _stopped.Token.Register(_subscription.Dispose); + + async Task HandleAsync(StreamSubscription s, ResolvedEvent e, CancellationToken ct) { + var type = _messageTypeMapper.Map(e.Event.EventType); + if (type == null) { + return; + } + + var message = JsonSerializer.Deserialize( + e.Event.Data.Span, type, TransactoSerializerOptions.Events); + if (message is AccountingPeriodClosing) { + await _dispatcher.Handle(e, ct); + } + } + } + } + } +} diff --git a/src/Transacto.AspNetCore/Plugins/Standard.cs b/src/Transacto.AspNetCore/Plugins/Standard.cs index c66213a..e477c77 100644 --- a/src/Transacto.AspNetCore/Plugins/Standard.cs +++ b/src/Transacto.AspNetCore/Plugins/Standard.cs @@ -1,6 +1,9 @@ namespace Transacto.Plugins { public static class Standard { - public static readonly IPlugin[] Plugins = - {new BalanceSheet.BalanceSheet(), new GeneralLedger(), new ChartOfAccounts()}; + public static readonly IPlugin[] Plugins = { + new ChartOfAccounts.ChartOfAccounts(), + new BalanceSheet.BalanceSheet(), + new GeneralLedger.GeneralLedger() + }; } } diff --git a/src/Transacto.AspNetCore/PreconditionFailedResponse.cs b/src/Transacto.AspNetCore/PreconditionFailedResponse.cs new file mode 100644 index 0000000..cd32c82 --- /dev/null +++ b/src/Transacto.AspNetCore/PreconditionFailedResponse.cs @@ -0,0 +1,11 @@ +using System.Net; + +namespace Transacto { + public sealed class PreconditionFailedResponse : Response { + public static PreconditionFailedResponse Instance = new PreconditionFailedResponse(); + + private PreconditionFailedResponse() { + StatusCode = HttpStatusCode.PreconditionFailed; + } + } +} diff --git a/src/Transacto.AspNetCore/Response.cs b/src/Transacto.AspNetCore/Response.cs index 672ae73..269f400 100644 --- a/src/Transacto.AspNetCore/Response.cs +++ b/src/Transacto.AspNetCore/Response.cs @@ -1,33 +1,31 @@ -using System.Collections.Generic; using System.IO; using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; +using Microsoft.AspNetCore.Http.Headers; #nullable enable namespace Transacto { public class Response { - public IList<(string, StringValues)> Headers { get; } - public HttpStatusCode StatusCode { get; set; } + public virtual ResponseHeaders Headers { get; } + public virtual HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; public Response() { - Headers = new List<(string, StringValues)>(); - StatusCode = HttpStatusCode.OK; + Headers = new ResponseHeaders(new HeaderDictionary()); } public ValueTask Write(HttpResponse response) { response.StatusCode = (int)StatusCode; - foreach (var (key, value) in Headers) { + foreach (var (key, value) in Headers.Headers) { response.Headers.AppendCommaSeparatedValues(key, value); } return WriteBody(response.Body, response.HttpContext.RequestAborted); } - protected virtual ValueTask WriteBody(Stream stream, CancellationToken cancellationToken = default) => + protected internal virtual ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) => new ValueTask(Task.CompletedTask); } } diff --git a/src/Transacto.AspNetCore/ServiceCollectionExtensions.cs b/src/Transacto.AspNetCore/ServiceCollectionExtensions.cs index a895bc7..fa06819 100644 --- a/src/Transacto.AspNetCore/ServiceCollectionExtensions.cs +++ b/src/Transacto.AspNetCore/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Projac; using SqlStreamStore; using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Infrastructure; using Transacto.Modules; using Transacto.Plugins; @@ -68,11 +69,19 @@ public static IServiceCollection AddTransacto(this IServiceCollection services, plugin.ConfigureServices(pluginServices); var pluginProvider = pluginServices + .AddSingleton(rootProvider.GetRequiredService()) + .AddSingleton(rootProvider.GetRequiredService()) + .AddSingleton(rootProvider.GetRequiredService()) + .AddSingleton(provider => rootProvider + .GetRequiredService>().Invoke(plugin)) .AddHostedService(provider => new InMemoryProjectionHost( provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetServices[]>().ToArray())) + .AddSingleton>(provider => () => rootProvider + .GetRequiredService>() + .Invoke(plugin)) .AddHostedService(provider => new NpgSqlProjectionHost( provider.GetRequiredService(), provider.GetRequiredService(), @@ -83,38 +92,12 @@ public static IServiceCollection AddTransacto(this IServiceCollection services, provider.GetRequiredService(), provider.GetRequiredService(), provider.GetServices().ToArray())) - .AddHostedService(provider => new InMemoryProjectionHost( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetServices[]>().ToArray())) - .AddHostedService(provider => new NpgSqlProjectionHost( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>(), - provider.GetServices().ToArray())) - .AddHostedService(provider => new StreamStoreProjectionHost( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetServices().ToArray())) - .AddSingleton>(provider => () => rootProvider - .GetRequiredService>() - .Invoke(plugin)) - .AddSingleton(provider => rootProvider - .GetRequiredService>() - .Invoke(plugin)) - .AddSingleton(rootProvider.GetRequiredService()) - .AddSingleton(rootProvider.GetRequiredService()) - .AddSingleton(rootProvider.GetRequiredService()) .BuildServiceProvider(); return pluginProvider .GetServices() .Aggregate(services.AddSingleton(Tuple.Create(plugin, (IServiceProvider)pluginProvider)), - (services, service) => { - services.TryAddEnumerable(new ServiceDescriptor(typeof(IHostedService), service)); - return services; - }); + (services, service) => services.AddSingleton(service)); }); + } } diff --git a/src/Transacto.AspNetCore/Startup.cs b/src/Transacto.AspNetCore/Startup.cs deleted file mode 100644 index fc4c234..0000000 --- a/src/Transacto.AspNetCore/Startup.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; - -namespace Transacto { - public class Startup : IStartup { - private readonly IPlugin[] _plugins; - - public Startup(IPlugin[] plugins) { - _plugins = plugins; - } - - public void Configure(IApplicationBuilder app) => app.UseTransacto(_plugins); - - public IServiceProvider ConfigureServices(IServiceCollection services) => services - .AddTransacto(_plugins).BuildServiceProvider(); - } -} diff --git a/src/Transacto.AspNetCore/StreamStoreProjectionHost.cs b/src/Transacto.AspNetCore/StreamStoreProjectionHost.cs index 4e189b7..ce9987e 100644 --- a/src/Transacto.AspNetCore/StreamStoreProjectionHost.cs +++ b/src/Transacto.AspNetCore/StreamStoreProjectionHost.cs @@ -114,7 +114,7 @@ public Task ProjectAsync(StreamSubscription subscription, ResolvedEvent e, e.Event.Data.Span, type, TransactoSerializerOptions.Events); return Task.WhenAll(_projectors.Where(x => x.checkpoint < e.OriginalPosition) .Select(_ => _.projector.ProjectAsync(_streamStore, - Envelope.Create(message, e.OriginalPosition!.Value), cancellationToken))); + Envelope.Create(message, e.OriginalEvent.Position), cancellationToken))); } } } diff --git a/src/Transacto.AspNetCore/TextResponse.cs b/src/Transacto.AspNetCore/TextResponse.cs new file mode 100644 index 0000000..1207c88 --- /dev/null +++ b/src/Transacto.AspNetCore/TextResponse.cs @@ -0,0 +1,20 @@ +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Net.Http.Headers; + +namespace Transacto { + public sealed class TextResponse : Response { + private static readonly MediaTypeHeaderValue ContentType = new MediaTypeHeaderValue("text/plain"); + private readonly string _body; + + public TextResponse(string body) { + Headers.ContentType = ContentType; + _body = body; + } + + protected internal override ValueTask WriteBody(Stream stream, CancellationToken cancellationToken) => stream + .WriteAsync(Encoding.UTF8.GetBytes(_body), cancellationToken); + } +} diff --git a/src/Transacto.AspNetCore/Transacto.AspNetCore.csproj b/src/Transacto.AspNetCore/Transacto.AspNetCore.csproj index 4eae43b..f5165e6 100644 --- a/src/Transacto.AspNetCore/Transacto.AspNetCore.csproj +++ b/src/Transacto.AspNetCore/Transacto.AspNetCore.csproj @@ -7,23 +7,24 @@ enable true 8.0 - $(RestoreSources);https://api.nuget.org/v3/index.json;https://nuget.pkg.github.com/thefringeninja/index.json + $(RestoreSources);https://api.nuget.org/v3/index.json;https://nuget.pkg.github.com/thefringeninja/index.json;https://nuget.pkg.github.com/EventStore/index.json - - + + + @@ -39,6 +40,7 @@ + diff --git a/src/Transacto.AspNetCore/TransactoHost.cs b/src/Transacto.AspNetCore/TransactoHost.cs index e89bad3..87922f8 100644 --- a/src/Transacto.AspNetCore/TransactoHost.cs +++ b/src/Transacto.AspNetCore/TransactoHost.cs @@ -1,5 +1,5 @@ using System; -using Autofac.Extensions.DependencyInjection; +using EventStore.Client; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -9,16 +9,16 @@ namespace Transacto { public class TransactoHost { - public static IHostBuilder Build(IServiceProvider serviceProvider, params IPlugin[] plugins) => Host - .CreateDefaultBuilder() - .ConfigureLogging(builder => builder.AddSerilog()) - .UseServiceProviderFactory(new AutofacServiceProviderFactory()) - .ConfigureWebHost(builder => builder - .UseKestrel() - .ConfigureServices(services => services - .AddEventStoreClient() - .AddSingleton(serviceProvider.GetRequiredService()) - .AddSingleton(serviceProvider.GetRequiredService())) - .UseStartup(new Startup(plugins))); + public static IHostBuilder Build(IServiceProvider serviceProvider, params IPlugin[] plugins) => + new HostBuilder() + .ConfigureLogging(builder => builder.AddSerilog()) + .ConfigureWebHost(builder => builder + .UseKestrel() + .Configure(app => app.UseTransacto(plugins)) + .ConfigureServices(services => services + .AddSingleton(serviceProvider.GetRequiredService()) + .AddSingleton(serviceProvider.GetRequiredService()) + .AddSingleton(serviceProvider.GetRequiredService()) + .AddTransacto(plugins))); } } diff --git a/src/Transacto.AspNetCore/Views/Links.cs b/src/Transacto.AspNetCore/Views/Links.cs new file mode 100644 index 0000000..a82a3f2 --- /dev/null +++ b/src/Transacto.AspNetCore/Views/Links.cs @@ -0,0 +1,4 @@ +namespace Transacto.Views { + internal static class Links { + } +} diff --git a/src/Transacto.AspNetCore/Views/Links.cshtml b/src/Transacto.AspNetCore/Views/Links.cshtml new file mode 100644 index 0000000..16165c1 --- /dev/null +++ b/src/Transacto.AspNetCore/Views/Links.cshtml @@ -0,0 +1,8 @@ +@model System.Collections.Generic.IEnumerable + +
+@foreach (var link in Model) +{ + @link.Title +} +
diff --git a/src/Transacto.AspNetCore/WebHostBuilderExtensions.cs b/src/Transacto.AspNetCore/WebHostBuilderExtensions.cs index 2ab7999..3201a2b 100644 --- a/src/Transacto.AspNetCore/WebHostBuilderExtensions.cs +++ b/src/Transacto.AspNetCore/WebHostBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Microsoft.Extensions.DependencyInjection; // ReSharper disable CheckNamespace @@ -5,8 +6,12 @@ namespace Microsoft.AspNetCore.Hosting { // ReSharper restore CheckNamespace internal static class WebHostBuilderExtensions { - public static IWebHostBuilder UseStartup(this IWebHostBuilder builder, IStartup startup) - => builder - .ConfigureServices(services => services.AddSingleton(startup)); + public static IWebHostBuilder UseStartup(this IWebHostBuilder builder, IStartup startup) { + var startupType = startup.GetType(); + var startupAssemblyName = startupType.GetTypeInfo().Assembly.GetName().Name; + + return builder.UseSetting(WebHostDefaults.ApplicationKey, startupAssemblyName) + .ConfigureServices(services => services.AddSingleton(startup)); + } } } diff --git a/src/Transacto/Application/AccountingPeriodClosingProcess.cs b/src/Transacto/Application/AccountingPeriodClosingProcess.cs deleted file mode 100644 index c21929e..0000000 --- a/src/Transacto/Application/AccountingPeriodClosingProcess.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Transacto.Domain; -using Transacto.Messages; - -namespace Transacto.Application { - public class AccountingPeriodClosingProcess { - private readonly IGeneralLedgerRepository _generalLedger; - private readonly IGeneralLedgerEntryRepository _generalLedgerEntries; - private readonly IChartOfAccountsRepository _chartOfAccounts; - - public AccountingPeriodClosingProcess(IGeneralLedgerRepository generalLedger, - IGeneralLedgerEntryRepository generalLedgerEntries, - IChartOfAccountsRepository chartOfAccounts) { - _generalLedger = generalLedger; - _generalLedgerEntries = generalLedgerEntries; - _chartOfAccounts = chartOfAccounts; - } - - public async ValueTask Handle(AccountingPeriodClosing @event, CancellationToken cancellationToken) { - var retainedEarningsAccountNumber = new AccountNumber(@event.RetainedEarningsAccountNumber); - AccountType.OfAccountNumber(retainedEarningsAccountNumber).MustBe(AccountType.Equity); - var generalLedger = await _generalLedger.Get(cancellationToken); - foreach (var id in @event.GeneralLedgerEntryIds) { - var generalLedgerEntry = - await _generalLedgerEntries.Get(new GeneralLedgerEntryIdentifier(id), cancellationToken); - generalLedger.TransferEntry(generalLedgerEntry); - } - - var chartOfAccounts = await _chartOfAccounts.Get(cancellationToken); - - generalLedger.CompleteClosingPeriod(chartOfAccounts, retainedEarningsAccountNumber); - } - } -} diff --git a/src/Transacto/Application/ChartOfAccountsHandlers.cs b/src/Transacto/Application/ChartOfAccountsHandlers.cs index e80f0f3..db73a48 100644 --- a/src/Transacto/Application/ChartOfAccountsHandlers.cs +++ b/src/Transacto/Application/ChartOfAccountsHandlers.cs @@ -1,44 +1,45 @@ using System.Threading; using System.Threading.Tasks; +using EventStore.Client; using Transacto.Domain; using Transacto.Messages; namespace Transacto.Application { - public class ChartOfAccountsHandlers { - private readonly IChartOfAccountsRepository _chartOfAccounts; + public class ChartOfAccountsHandlers { + private readonly IChartOfAccountsRepository _chartOfAccounts; - public ChartOfAccountsHandlers(IChartOfAccountsRepository chartOfAccounts) { - _chartOfAccounts = chartOfAccounts; - } + public ChartOfAccountsHandlers(IChartOfAccountsRepository chartOfAccounts) { + _chartOfAccounts = chartOfAccounts; + } public async ValueTask Handle(DefineAccount command, CancellationToken cancellationToken = default) { var optionalChart = await _chartOfAccounts.GetOptional(cancellationToken); - var chart = optionalChart.HasValue ? optionalChart.Value : ChartOfAccounts.Factory(); + var chart = optionalChart.HasValue ? optionalChart.Value : ChartOfAccounts.Factory(); - chart.DefineAccount(new AccountName(command.AccountName!), new AccountNumber(command.AccountNumber)); + chart.DefineAccount(new AccountName(command.AccountName!), new AccountNumber(command.AccountNumber)); - if (!optionalChart.HasValue) { - _chartOfAccounts.Add(chart); - } - } + if (!optionalChart.HasValue) { + _chartOfAccounts.Add(chart); + } + } public async ValueTask Handle(RenameAccount command, CancellationToken cancellationToken = default) { var chart = await _chartOfAccounts.Get(cancellationToken); - chart.RenameAccount(new AccountNumber(command.AccountNumber), new AccountName(command.NewAccountName!)); - } + chart.RenameAccount(new AccountNumber(command.AccountNumber), new AccountName(command.NewAccountName!)); + } public async ValueTask Handle(DeactivateAccount command, CancellationToken cancellationToken = default) { var chart = await _chartOfAccounts.Get(cancellationToken); - chart.DeactivateAccount(new AccountNumber(command.AccountNumber)); - } + chart.DeactivateAccount(new AccountNumber(command.AccountNumber)); + } public async ValueTask Handle(ReactivateAccount command, CancellationToken cancellationToken = default) { var chart = await _chartOfAccounts.Get(cancellationToken); - chart.ReactivateAccount(new AccountNumber(command.AccountNumber)); - } - } + chart.ReactivateAccount(new AccountNumber(command.AccountNumber)); + } + } } diff --git a/src/Transacto/Application/GeneralLedgerEntryHandlers.cs b/src/Transacto/Application/GeneralLedgerEntryHandlers.cs index 7b5d1fb..6a43a01 100644 --- a/src/Transacto/Application/GeneralLedgerEntryHandlers.cs +++ b/src/Transacto/Application/GeneralLedgerEntryHandlers.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using EventStore.Client; using Transacto.Domain; using Transacto.Messages; diff --git a/src/Transacto/Application/GeneralLedgerHandlers.cs b/src/Transacto/Application/GeneralLedgerHandlers.cs index c02ef55..898fa34 100644 --- a/src/Transacto/Application/GeneralLedgerHandlers.cs +++ b/src/Transacto/Application/GeneralLedgerHandlers.cs @@ -1,15 +1,22 @@ using System; using System.Threading; using System.Threading.Tasks; +using EventStore.Client; using Transacto.Domain; using Transacto.Messages; namespace Transacto.Application { public class GeneralLedgerHandlers { private readonly IGeneralLedgerRepository _generalLedger; + private readonly IGeneralLedgerEntryRepository _generalLedgerEntries; + private readonly IChartOfAccountsRepository _chartOfAccounts; - public GeneralLedgerHandlers(IGeneralLedgerRepository generalLedger) { + public GeneralLedgerHandlers(IGeneralLedgerRepository generalLedger, + IGeneralLedgerEntryRepository generalLedgerEntries, + IChartOfAccountsRepository chartOfAccounts) { _generalLedger = generalLedger; + _generalLedgerEntries = generalLedgerEntries; + _chartOfAccounts = chartOfAccounts; } public ValueTask Handle(OpenGeneralLedger command, CancellationToken cancellationToken = default) { @@ -30,5 +37,20 @@ public async ValueTask Handle(BeginClosingAccountingPeriod command, Array.ConvertAll(command.GeneralLedgerEntryIds, id => new GeneralLedgerEntryIdentifier(id)), command.ClosingOn); } + + public async ValueTask Handle(AccountingPeriodClosing @event, CancellationToken cancellationToken) { + var retainedEarningsAccountNumber = new AccountNumber(@event.RetainedEarningsAccountNumber); + AccountType.OfAccountNumber(retainedEarningsAccountNumber).MustBe(AccountType.Equity); + var generalLedger = await _generalLedger.Get(cancellationToken); + foreach (var id in @event.GeneralLedgerEntryIds) { + var generalLedgerEntry = + await _generalLedgerEntries.Get(new GeneralLedgerEntryIdentifier(id), cancellationToken); + generalLedger.TransferEntry(generalLedgerEntry); + } + + var chartOfAccounts = await _chartOfAccounts.Get(cancellationToken); + + generalLedger.CompleteClosingPeriod(chartOfAccounts, retainedEarningsAccountNumber); + } } } diff --git a/src/Transacto/Domain/AccountDeactivatedException.cs b/src/Transacto/Domain/AccountDeactivatedException.cs new file mode 100644 index 0000000..b3c6a02 --- /dev/null +++ b/src/Transacto/Domain/AccountDeactivatedException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Transacto.Domain { + public class AccountDeactivatedException : Exception { + public AccountNumber AccountNumber { get; } + + public AccountDeactivatedException(AccountNumber accountNumber) : base( + $"Account {accountNumber} was deactivated.") { + AccountNumber = accountNumber; + } + } +} diff --git a/src/Transacto/Domain/AccountExistsException.cs b/src/Transacto/Domain/AccountExistsException.cs new file mode 100644 index 0000000..dc81f23 --- /dev/null +++ b/src/Transacto/Domain/AccountExistsException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Transacto.Domain { + public class AccountExistsException : Exception { + public AccountNumber AccountNumber { get; } + + public AccountExistsException(AccountNumber accountNumber) : base($"Account {accountNumber} already exists.") { + AccountNumber = accountNumber; + } + } +} diff --git a/src/Transacto/Domain/AccountName.cs b/src/Transacto/Domain/AccountName.cs index aa2cf62..895ee15 100644 --- a/src/Transacto/Domain/AccountName.cs +++ b/src/Transacto/Domain/AccountName.cs @@ -1,23 +1,27 @@ using System; namespace Transacto.Domain { - public readonly struct AccountName : IEquatable { - public const int MaxLength = 256; - private readonly string _value; + public readonly struct AccountName : IEquatable { + public const int MaxLength = 256; + private readonly string _value; - public AccountName(string value) { - if (value.Length == 0 || value.Length > MaxLength) { - throw new ArgumentException(); - } + public AccountName(string value) { + if (value.Length == 0) { + throw new ArgumentException("Input was empty.", nameof(value)); + } - _value = value; - } + if (value.Length > MaxLength) { + throw new ArgumentException("Input was too long.", nameof(value)); + } - public bool Equals(AccountName other) => _value == other._value; - public override bool Equals(object? obj) => obj is AccountName other && Equals(other); - public override int GetHashCode() => _value.GetHashCode(); - public static bool operator ==(AccountName left, AccountName right) => left.Equals(right); - public static bool operator !=(AccountName left, AccountName right) => !left.Equals(right); - public override string ToString() => _value; - } + _value = value; + } + + public bool Equals(AccountName other) => _value == other._value; + public override bool Equals(object? obj) => obj is AccountName other && Equals(other); + public override int GetHashCode() => _value.GetHashCode(); + public static bool operator ==(AccountName left, AccountName right) => left.Equals(right); + public static bool operator !=(AccountName left, AccountName right) => !left.Equals(right); + public override string ToString() => _value; + } } diff --git a/src/Transacto/Domain/AccountNotFoundException.cs b/src/Transacto/Domain/AccountNotFoundException.cs new file mode 100644 index 0000000..d896be1 --- /dev/null +++ b/src/Transacto/Domain/AccountNotFoundException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Transacto.Domain { + public class AccountNotFoundException : Exception { + public AccountNumber AccountNumber { get; } + + public AccountNotFoundException(AccountNumber accountNumber) : base($"Account {accountNumber} was not found.") { + AccountNumber = accountNumber; + } + } +} diff --git a/src/Transacto/Domain/AccountNumber.cs b/src/Transacto/Domain/AccountNumber.cs index 766be40..0998918 100644 --- a/src/Transacto/Domain/AccountNumber.cs +++ b/src/Transacto/Domain/AccountNumber.cs @@ -6,12 +6,12 @@ namespace Transacto.Domain { public AccountNumber(int value) { if (value < 1000 || value >= 9000) { - throw new InvalidOperationException(); + throw new ArgumentOutOfRangeException(nameof(value)); } + Value = value; } - public bool Equals(AccountNumber other) => Value == other.Value; public override bool Equals(object? obj) => obj is AccountNumber other && Equals(other); public override int GetHashCode() => Value.GetHashCode(); diff --git a/src/Transacto/Domain/AccountType.cs b/src/Transacto/Domain/AccountType.cs index 119034a..bcde92f 100644 --- a/src/Transacto/Domain/AccountType.cs +++ b/src/Transacto/Domain/AccountType.cs @@ -1,7 +1,8 @@ using System; +using System.Collections.Generic; namespace Transacto.Domain { - public class AccountType { + public abstract class AccountType { public static readonly AccountType Asset = new AssetAccount(); public static readonly AccountType Liability = new LiabilityAccount(); public static readonly AccountType Equity = new EquityAccount(); @@ -10,8 +11,14 @@ public class AccountType { public static readonly AccountType Expenses = new ExpenseAccount(); public static readonly AccountType OtherIncome = new IncomeAccount(); public static readonly AccountType OtherExpenses = new ExpenseAccount(); - public bool AppearsOnBalanceSheet => this is AssetAccount || this is LiabilityAccount || this is EquityAccount; - public bool AppearsOnProfitAndLoss => this is IncomeAccount || this is ExpenseAccount; + + public static readonly IReadOnlyList All = new[] { + Asset, Liability, Equity, Income, CostOfGoodsSold, Expenses, OtherIncome, OtherExpenses + }; + + public string Name { get; } + public abstract bool AppearsOnBalanceSheet { get; } + public abstract bool AppearsOnProfitAndLoss { get; } public static AccountType OfAccountNumber(AccountNumber value) => value switch { var x when x.Value >= 1000 && x.Value < 2000 => Asset, @@ -25,25 +32,39 @@ public class AccountType { _ => throw new ArgumentOutOfRangeException(nameof(value)) }; + protected AccountType() { + Name = GetType().Name; + } + public void MustBe(AccountType other) { if (this != other) { - throw new InvalidOperationException(); + throw new InvalidAccountTypeException(other, this); } } public class AssetAccount : AccountType { + public override bool AppearsOnBalanceSheet { get; } = true; + public override bool AppearsOnProfitAndLoss { get; } = false; } public class LiabilityAccount : AccountType { + public override bool AppearsOnBalanceSheet { get; } = true; + public override bool AppearsOnProfitAndLoss { get; } = false; } public class EquityAccount : AccountType { + public override bool AppearsOnBalanceSheet { get; } = true; + public override bool AppearsOnProfitAndLoss { get; } = false; } public class IncomeAccount : AccountType { + public override bool AppearsOnBalanceSheet { get; } = false; + public override bool AppearsOnProfitAndLoss { get; } = true; } public class ExpenseAccount : AccountType { + public override bool AppearsOnBalanceSheet { get; } = false; + public override bool AppearsOnProfitAndLoss { get; } = true; } } } diff --git a/src/Transacto/Domain/BusinessTransactionExtensions.cs b/src/Transacto/Domain/BusinessTransactionExtensions.cs new file mode 100644 index 0000000..47ae2f4 --- /dev/null +++ b/src/Transacto/Domain/BusinessTransactionExtensions.cs @@ -0,0 +1,10 @@ +using Transacto.Framework; + +namespace Transacto.Domain { + public static class BusinessTransactionExtensions { + public static T WithVersion(this T source, Optional version) where T : IBusinessTransaction { + source.Version = version.HasValue ? version.Value : new int?(); + return source; + } + } +} diff --git a/src/Transacto/Domain/ChartOfAccounts.cs b/src/Transacto/Domain/ChartOfAccounts.cs index 2680c14..91ab3f6 100644 --- a/src/Transacto/Domain/ChartOfAccounts.cs +++ b/src/Transacto/Domain/ChartOfAccounts.cs @@ -1,16 +1,18 @@ using System; using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; using Transacto.Framework; using Transacto.Messages; namespace Transacto.Domain { public class ChartOfAccounts : AggregateRoot { + public const string Identifier = "chartOfAccounts"; public static readonly Func Factory = () => new ChartOfAccounts(); private readonly HashSet _accountNumbers; private readonly HashSet _deactivatedAccountNumbers; - public override string Id { get; } = "chartOfAccounts"; + public override string Id { get; } = Identifier; private ChartOfAccounts() { _accountNumbers = new HashSet(); @@ -30,7 +32,7 @@ private ChartOfAccounts() { public void MustNotBeDeactivated(AccountNumber accountNumber) { if (_deactivatedAccountNumbers.Contains(accountNumber)) { - throw new InvalidOperationException(); + throw new AccountDeactivatedException(accountNumber); } } @@ -77,22 +79,22 @@ public void RenameAccount(AccountNumber accountNumber, AccountName newAccountNam } private void MustNotContainAccountNumber(AccountNumber accountNumber) { - if (!IsActive(accountNumber) && !IsUnactive(accountNumber)) { + if (!IsActive(accountNumber) && !IsInactive(accountNumber)) { return; } - throw new InvalidOperationException(); + throw new AccountExistsException(accountNumber); } private void MustContainAccountNumber(AccountNumber accountNumber) { - if (IsActive(accountNumber) || IsUnactive(accountNumber)) { + if (IsActive(accountNumber) || IsInactive(accountNumber)) { return; } - throw new InvalidOperationException(); + throw new AccountNotFoundException(accountNumber); } - private bool IsUnactive(AccountNumber accountNumber) => _deactivatedAccountNumbers.Contains(accountNumber); + private bool IsInactive(AccountNumber accountNumber) => _deactivatedAccountNumbers.Contains(accountNumber); private bool IsActive(AccountNumber accountNumber) => _accountNumbers.Contains(accountNumber); } diff --git a/src/Transacto/Domain/ChartOfAccountsNotFoundException.cs b/src/Transacto/Domain/ChartOfAccountsNotFoundException.cs new file mode 100644 index 0000000..7117f21 --- /dev/null +++ b/src/Transacto/Domain/ChartOfAccountsNotFoundException.cs @@ -0,0 +1,8 @@ +using System; + +namespace Transacto.Domain { + public class ChartOfAccountsNotFoundException : Exception { + public ChartOfAccountsNotFoundException() : base("The Chart of Accounts was not found.") { + } + } +} diff --git a/src/Transacto/Domain/ClosingDateBeforePeriodException.cs b/src/Transacto/Domain/ClosingDateBeforePeriodException.cs new file mode 100644 index 0000000..abd870f --- /dev/null +++ b/src/Transacto/Domain/ClosingDateBeforePeriodException.cs @@ -0,0 +1,14 @@ +using System; + +namespace Transacto.Domain { + public class ClosingDateBeforePeriodException : Exception { + public Period Period { get; } + public DateTimeOffset Date { get; } + + public ClosingDateBeforePeriodException(Period period, DateTimeOffset date) + : base($"Closing date {date:O} is before period {period}.") { + Period = period; + Date = date; + } + } +} diff --git a/src/Transacto/Domain/Credit.cs b/src/Transacto/Domain/Credit.cs index 4e63463..d3f4272 100644 --- a/src/Transacto/Domain/Credit.cs +++ b/src/Transacto/Domain/Credit.cs @@ -1,44 +1,43 @@ using System; namespace Transacto.Domain { - public readonly struct Credit : IEquatable { - public AccountNumber AccountNumber { get; } - public Money Amount { get; } + public readonly struct Credit : IEquatable { + public AccountNumber AccountNumber { get; } + public Money Amount { get; } - private readonly AccountType _accountType; + private readonly AccountType _accountType; - public Credit(AccountNumber accountNumber) : this(accountNumber, Money.Zero) { - } + public Credit(AccountNumber accountNumber) : this(accountNumber, Money.Zero) { + } - public Credit(AccountNumber accountNumber, Money amount) { - if (amount < Money.Zero) { - throw new ArgumentOutOfRangeException(nameof(amount)); - } + public Credit(AccountNumber accountNumber, Money amount) { + if (amount < Money.Zero) { + throw new ArgumentOutOfRangeException(nameof(amount)); + } - Amount = amount; - AccountNumber = accountNumber; - _accountType = AccountType.OfAccountNumber(accountNumber); - } + Amount = amount; + AccountNumber = accountNumber; + _accountType = AccountType.OfAccountNumber(accountNumber); + } - public bool AppearsOnBalanceSheet => _accountType.AppearsOnBalanceSheet; - public bool AppearsOnProfitAndLoss => _accountType.AppearsOnProfitAndLoss; - public override int GetHashCode() => HashCode.Combine(Amount, AccountNumber); - public bool Equals(Credit other) => Amount.Equals(other.Amount) && AccountNumber.Equals(other.AccountNumber); - public override bool Equals(object? obj) => obj is Credit other && Equals(other); - public static bool operator ==(Credit left, Credit right) => left.Equals(right); - public static bool operator !=(Credit left, Credit right) => !left.Equals(right); + public bool AppearsOnBalanceSheet => _accountType.AppearsOnBalanceSheet; + public bool AppearsOnProfitAndLoss => _accountType.AppearsOnProfitAndLoss; + public override int GetHashCode() => HashCode.Combine(Amount, AccountNumber); + public bool Equals(Credit other) => Amount.Equals(other.Amount) && AccountNumber.Equals(other.AccountNumber); + public override bool Equals(object? obj) => obj is Credit other && Equals(other); + public static bool operator ==(Credit left, Credit right) => left.Equals(right); + public static bool operator !=(Credit left, Credit right) => !left.Equals(right); - public static Credit operator +(Credit left, Money right) => - new Credit(left.AccountNumber, left.Amount + right); + public static Credit operator +(Credit left, Money right) => + new Credit(left.AccountNumber, left.Amount + right); - public static Credit operator -(Credit left, Money right) => - new Credit(left.AccountNumber, left.Amount - right); + public static Credit operator -(Credit left, Money right) => + new Credit(left.AccountNumber, left.Amount - right); - public static Credit operator +(Credit left, decimal right) => - new Credit(left.AccountNumber, left.Amount + right); + public static Credit operator +(Credit left, decimal right) => + new Credit(left.AccountNumber, left.Amount + right); - public static Credit operator -(Credit left, decimal right) => - new Credit(left.AccountNumber, left.Amount - right); - - } + public static Credit operator -(Credit left, decimal right) => + new Credit(left.AccountNumber, left.Amount - right); + } } diff --git a/src/Transacto/Domain/Debit.cs b/src/Transacto/Domain/Debit.cs index 486caee..bdb0e07 100644 --- a/src/Transacto/Domain/Debit.cs +++ b/src/Transacto/Domain/Debit.cs @@ -1,35 +1,35 @@ using System; namespace Transacto.Domain { - public readonly struct Debit { - public Money Amount { get; } - public AccountNumber AccountNumber { get; } + public readonly struct Debit { + public Money Amount { get; } + public AccountNumber AccountNumber { get; } - private readonly AccountType _accountType; + private readonly AccountType _accountType; - public Debit(AccountNumber accountNumber) : this(accountNumber, Money.Zero) { - } + public Debit(AccountNumber accountNumber) : this(accountNumber, Money.Zero) { + } - public Debit(AccountNumber accountNumber, Money amount) { - if (amount < Money.Zero) { - throw new ArgumentOutOfRangeException(nameof(amount)); - } + public Debit(AccountNumber accountNumber, Money amount) { + if (amount < Money.Zero) { + throw new ArgumentOutOfRangeException(nameof(amount)); + } - Amount = amount; - AccountNumber = accountNumber; - _accountType = AccountType.OfAccountNumber(accountNumber); - } + Amount = amount; + AccountNumber = accountNumber; + _accountType = AccountType.OfAccountNumber(accountNumber); + } - public bool AppearsOnBalanceSheet => _accountType.AppearsOnBalanceSheet; - - public override int GetHashCode() => HashCode.Combine(Amount, AccountNumber); - public bool Equals(Debit other) => Amount.Equals(other.Amount) && AccountNumber.Equals(other.AccountNumber); - public override bool Equals(object? obj) => obj is Debit other && Equals(other); - public static bool operator ==(Debit left, Debit right) => left.Equals(right); - public static bool operator !=(Debit left, Debit right) => !left.Equals(right); - public static Debit operator +(Debit left, Money right) => new Debit(left.AccountNumber, left.Amount + right); - public static Debit operator -(Debit left, Money right) => new Debit(left.AccountNumber, left.Amount - right); - public static Debit operator +(Debit left, decimal right) => new Debit(left.AccountNumber, left.Amount + right); - public static Debit operator -(Debit left, decimal right) => new Debit(left.AccountNumber, left.Amount - right); - } + public bool AppearsOnBalanceSheet => _accountType.AppearsOnBalanceSheet; + public bool AppearsOnProfitAndLoss => _accountType.AppearsOnProfitAndLoss; + public override int GetHashCode() => HashCode.Combine(Amount, AccountNumber); + public bool Equals(Debit other) => Amount.Equals(other.Amount) && AccountNumber.Equals(other.AccountNumber); + public override bool Equals(object? obj) => obj is Debit other && Equals(other); + public static bool operator ==(Debit left, Debit right) => left.Equals(right); + public static bool operator !=(Debit left, Debit right) => !left.Equals(right); + public static Debit operator +(Debit left, Money right) => new Debit(left.AccountNumber, left.Amount + right); + public static Debit operator -(Debit left, Money right) => new Debit(left.AccountNumber, left.Amount - right); + public static Debit operator +(Debit left, decimal right) => new Debit(left.AccountNumber, left.Amount + right); + public static Debit operator -(Debit left, decimal right) => new Debit(left.AccountNumber, left.Amount - right); + } } diff --git a/src/Transacto/Domain/GeneralLedger.cs b/src/Transacto/Domain/GeneralLedger.cs index fac8667..c6b86de 100644 --- a/src/Transacto/Domain/GeneralLedger.cs +++ b/src/Transacto/Domain/GeneralLedger.cs @@ -10,7 +10,7 @@ public class GeneralLedger : AggregateRoot { public const string Identifier = "generalLedger"; public static readonly Func Factory = () => new GeneralLedger(); - private readonly BalanceSheet _balanceSheet; + private readonly TrialBalance _trialBalance; private readonly List _untransferredEntryIdentifiers; private readonly List _entryIdentifiers; @@ -20,7 +20,7 @@ public class GeneralLedger : AggregateRoot { private DateTimeOffset _closingOn; private GeneralLedgerEntryIdentifier _closingGeneralLedgerEntryIdentifier; - public override string Id => Identifier; + public override string Id { get; } = Identifier; public static GeneralLedger Open(DateTimeOffset openedOn) { var generalLedger = new GeneralLedger(); @@ -33,7 +33,7 @@ public static GeneralLedger Open(DateTimeOffset openedOn) { private GeneralLedger() { _untransferredEntryIdentifiers = new List(); _entryIdentifiers = new List(); - _balanceSheet = BalanceSheet.None; + _trialBalance = TrialBalance.None; _closingOn = default; _profitAndLoss = null!; @@ -52,7 +52,7 @@ private GeneralLedger() { _untransferredEntryIdentifiers.Clear(); _entryIdentifiers.Clear(); foreach (var (accountNumber, amount) in e.Balance) { - _balanceSheet.Apply(new AccountNumber(accountNumber), new Money(amount)); + _trialBalance.Apply(new AccountNumber(accountNumber), new Money(amount)); } _period = Period.Parse(e.Period).Next(); @@ -67,15 +67,13 @@ public GeneralLedgerEntry Create(GeneralLedgerEntryIdentifier identifier, Genera private static GeneralLedgerEntry Create(GeneralLedgerEntryIdentifier identifier, GeneralLedgerEntryNumber number, DateTimeOffset createdOn, Period period) => - period.Contains(createdOn) - ? new GeneralLedgerEntry(identifier, number, period, createdOn) - : throw new InvalidOperationException(); + new GeneralLedgerEntry(identifier, number, period, createdOn); public void BeginClosingPeriod(AccountNumber retainedEarningsAccountNumber, GeneralLedgerEntryIdentifier closingGeneralLedgerEntryIdentifier, GeneralLedgerEntryIdentifier[] generalLedgerEntryIdentifiers, DateTimeOffset closingOn) { if (_periodClosing) { - throw new InvalidOperationException(); + throw new PeriodClosingInProcessException(_period); } _period.MustNotBeAfter(closingOn); @@ -91,13 +89,13 @@ public void BeginClosingPeriod(AccountNumber retainedEarningsAccountNumber, public void TransferEntry(GeneralLedgerEntry generalLedgerEntry) { if (!_periodClosing) { - throw new InvalidOperationException(); + throw new PeriodClosingInProcessException(_period); } generalLedgerEntry.MustBeInBalance(); generalLedgerEntry.MustBePosted(); - _balanceSheet.Transfer(generalLedgerEntry); + _trialBalance.Transfer(generalLedgerEntry); _profitAndLoss.Transfer(generalLedgerEntry); _untransferredEntryIdentifiers.Remove(generalLedgerEntry.Identifier); } @@ -105,14 +103,15 @@ public void TransferEntry(GeneralLedgerEntry generalLedgerEntry) { public void CompleteClosingPeriod(ChartOfAccounts chartOfAccounts, AccountNumber retainedEarningsAccountNumber) { if (!_periodClosing) { - throw new InvalidOperationException(); + throw new PeriodOpenException(_period); } if (_untransferredEntryIdentifiers.Count > 0) { - throw new InvalidOperationException(); + throw new PeriodContainsUntransferredEntriesException(_period, + _untransferredEntryIdentifiers.ToArray()); } - _balanceSheet.MustBeInBalance(); + _trialBalance.MustBeInBalance(); var closingEntry = _profitAndLoss.GetClosingEntry(chartOfAccounts, retainedEarningsAccountNumber, _closingOn, _closingGeneralLedgerEntryIdentifier); @@ -121,15 +120,15 @@ public void CompleteClosingPeriod(ChartOfAccounts chartOfAccounts, Apply(change); } - _balanceSheet.Transfer(closingEntry); + _trialBalance.Transfer(closingEntry); - _balanceSheet.MustBeInBalance(); + _trialBalance.MustBeInBalance(); Apply(new AccountingPeriodClosed { GeneralLedgerEntryIds = _entryIdentifiers.Select(x => x.ToGuid()).ToArray(), ClosingGeneralLedgerEntryId = closingEntry.Identifier.ToGuid(), Period = _period.ToString(), - Balance = _balanceSheet.ToDictionary(x => x.Key.ToInt32(), x => x.Value.ToDecimal()) + Balance = _trialBalance.ToDictionary(x => x.Key.ToInt32(), x => x.Value.ToDecimal()) }); } @@ -148,20 +147,36 @@ public GeneralLedgerEntry GetClosingEntry(ChartOfAccounts chartOfAccounts, AccountNumber retainedEarningsAccountNumber, DateTimeOffset closedOn, GeneralLedgerEntryIdentifier closingGeneralLedgerEntryIdentifier) { var entry = new GeneralLedgerEntry(closingGeneralLedgerEntryIdentifier, - new GeneralLedgerEntryNumber($"closingEntry-{_period}"), _period, closedOn); + new GeneralLedgerEntryNumber("jec", int.Parse(_period.ToString())), _period, closedOn); foreach (var (accountNumber, amount) in _income) { - entry.ApplyDebit(new Debit(accountNumber, amount), chartOfAccounts); + if (amount == Money.Zero) { + continue; + } + + if (amount > Money.Zero) { + entry.ApplyCredit(new Credit(accountNumber, amount), chartOfAccounts); + } else { + entry.ApplyDebit(new Debit(accountNumber, -amount), chartOfAccounts); + } } foreach (var (accountNumber, amount) in _expenses) { - entry.ApplyCredit(new Credit(accountNumber, amount), chartOfAccounts); + if (amount == Money.Zero) { + continue; + } + + if (amount < Money.Zero) { + entry.ApplyCredit(new Credit(accountNumber, amount), chartOfAccounts); + } else { + entry.ApplyDebit(new Debit(accountNumber, -amount), chartOfAccounts); + } } var retainedEarnings = entry.Debits.Select(x => x.Amount).Sum() - entry.Credits.Select(x => x.Amount).Sum(); if (retainedEarnings < Money.Zero) { - entry.ApplyDebit(new Debit(retainedEarningsAccountNumber, retainedEarnings), chartOfAccounts); + entry.ApplyDebit(new Debit(retainedEarningsAccountNumber, -retainedEarnings), chartOfAccounts); } else if (retainedEarnings > Money.Zero) { entry.ApplyCredit(new Credit(retainedEarningsAccountNumber, retainedEarnings), chartOfAccounts); } @@ -200,51 +215,38 @@ public void Transfer(GeneralLedgerEntry generalLedgerEntry) { } } - private class BalanceSheet : IEnumerable> { - public static readonly BalanceSheet None = new BalanceSheet(); + private class TrialBalance : IEnumerable> { + public static TrialBalance None => new TrialBalance(); private readonly IDictionary _inner; - private BalanceSheet() { + private TrialBalance() { _inner = new Dictionary(); } public void Transfer(GeneralLedgerEntry generalLedgerEntry) { - foreach (var debit in generalLedgerEntry.Debits.Where(x => x.AppearsOnBalanceSheet)) { + foreach (var debit in generalLedgerEntry.Debits) { _inner[debit.AccountNumber] = _inner.TryGetValue(debit.AccountNumber, out var amount) - ? amount + GetBalance(debit) - : GetBalance(debit); + ? amount + debit.Amount + : debit.Amount; } - foreach (var credit in generalLedgerEntry.Credits.Where(x => x.AppearsOnBalanceSheet)) { + foreach (var credit in generalLedgerEntry.Credits) { _inner[credit.AccountNumber] = _inner.TryGetValue(credit.AccountNumber, out var amount) - ? amount + GetBalance(credit) - : GetBalance(credit); + ? amount - credit.Amount + : -credit.Amount; } } public void Apply(AccountNumber accountNumber, Money amount) => _inner[accountNumber] = amount; public void MustBeInBalance() { - if (_inner.Values.Sum() != Money.Zero) { - throw new InvalidOperationException(); + var balance = _inner.Values.Sum(); + if (balance != Money.Zero) { + throw new TrialBalanceFailedException(balance); } } - private static Money GetBalance(Debit debit) => AccountType.OfAccountNumber(debit.AccountNumber) switch { - AccountType.AssetAccount _ => debit.Amount, - AccountType.EquityAccount _ => -debit.Amount, - AccountType.ExpenseAccount _ => -debit.Amount, - _ => throw new InvalidOperationException() - }; - - private static Money GetBalance(Credit credit) => AccountType.OfAccountNumber(credit.AccountNumber) switch { - AccountType.AssetAccount _ => -credit.Amount, - AccountType.EquityAccount _ => credit.Amount, - AccountType.ExpenseAccount _ => credit.Amount, - _ => throw new InvalidOperationException() - }; - public IEnumerator> GetEnumerator() => _inner.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_inner).GetEnumerator(); } diff --git a/src/Transacto/Domain/GeneralLedgerEntry.cs b/src/Transacto/Domain/GeneralLedgerEntry.cs index d819b50..cdd5623 100644 --- a/src/Transacto/Domain/GeneralLedgerEntry.cs +++ b/src/Transacto/Domain/GeneralLedgerEntry.cs @@ -7,8 +7,8 @@ namespace Transacto.Domain { public class GeneralLedgerEntry : AggregateRoot { public static readonly Func Factory = () => new GeneralLedgerEntry(); - private GeneralLedgerEntryIdentifier _identifier; + private GeneralLedgerEntryIdentifier _identifier; private bool _posted; private readonly List _debits; @@ -21,11 +21,19 @@ public class GeneralLedgerEntry : AggregateRoot { public bool IsInBalance => Balance == Money.Zero; public IEnumerable Debits => _debits.AsReadOnly(); public IEnumerable Credits => _credits.AsReadOnly(); - public override string Id => _identifier.ToString(); + public override string Id => FormatStreamIdentifier(_identifier); + public GeneralLedgerEntryIdentifier Identifier => _identifier; + public static string FormatStreamIdentifier(GeneralLedgerEntryIdentifier identifier) => + $"generalLedgerEntry-{identifier}"; + internal GeneralLedgerEntry(GeneralLedgerEntryIdentifier identifier, GeneralLedgerEntryNumber number, Period period, DateTimeOffset createdOn) : this() { + if (!period.Contains(createdOn)) { + throw new GeneralLedgerEntryNotInPeriodException(number, createdOn, period); + } + Apply(new GeneralLedgerEntryCreated { GeneralLedgerEntryId = identifier.ToGuid(), Number = number.ToString(), @@ -96,19 +104,19 @@ public void Post() { private void MustNotBePosted() { if (_posted) { - throw new InvalidOperationException(); + throw new GeneralLedgerEntryWasPostedException(_identifier); } } public void MustBePosted() { - if (_posted) { - throw new InvalidOperationException(); + if (!_posted) { + throw new GeneralLedgerEntryWasNotPostedException(_identifier); } } public void MustBeInBalance() { - if (Balance != Money.Zero) { - throw new InvalidOperationException(); + if (!IsInBalance) { + throw new GeneralLedgerEntryNotInBalanceException(_identifier); } } } diff --git a/src/Transacto/Domain/GeneralLedgerEntryIdentifier.cs b/src/Transacto/Domain/GeneralLedgerEntryIdentifier.cs index 198d074..ede81dc 100644 --- a/src/Transacto/Domain/GeneralLedgerEntryIdentifier.cs +++ b/src/Transacto/Domain/GeneralLedgerEntryIdentifier.cs @@ -1,28 +1,28 @@ using System; namespace Transacto.Domain { - public readonly struct GeneralLedgerEntryIdentifier : IEquatable { - private readonly Guid _value; + public readonly struct GeneralLedgerEntryIdentifier : IEquatable { + private readonly Guid _value; - public GeneralLedgerEntryIdentifier(Guid value) { - if (value == Guid.Empty) { - throw new ArgumentException(); - } + public GeneralLedgerEntryIdentifier(Guid value) { + if (value == Guid.Empty) { + throw new ArgumentOutOfRangeException(nameof(value)); + } - _value = value; - } + _value = value; + } - public bool Equals(GeneralLedgerEntryIdentifier other) => _value.Equals(other._value); - public override bool Equals(object? obj) => obj is GeneralLedgerEntryIdentifier other && Equals(other); - public override int GetHashCode() => _value.GetHashCode(); + public bool Equals(GeneralLedgerEntryIdentifier other) => _value.Equals(other._value); + public override bool Equals(object? obj) => obj is GeneralLedgerEntryIdentifier other && Equals(other); + public override int GetHashCode() => _value.GetHashCode(); - public static bool operator ==(GeneralLedgerEntryIdentifier left, GeneralLedgerEntryIdentifier right) => - left.Equals(right); + public static bool operator ==(GeneralLedgerEntryIdentifier left, GeneralLedgerEntryIdentifier right) => + left.Equals(right); - public static bool operator !=(GeneralLedgerEntryIdentifier left, GeneralLedgerEntryIdentifier right) => - !left.Equals(right); + public static bool operator !=(GeneralLedgerEntryIdentifier left, GeneralLedgerEntryIdentifier right) => + !left.Equals(right); - public Guid ToGuid() => _value; - public override string ToString() => _value.ToString("n"); - } + public Guid ToGuid() => _value; + public override string ToString() => _value.ToString("n"); + } } diff --git a/src/Transacto/Domain/GeneralLedgerEntryNotFoundException.cs b/src/Transacto/Domain/GeneralLedgerEntryNotFoundException.cs new file mode 100644 index 0000000..73fed80 --- /dev/null +++ b/src/Transacto/Domain/GeneralLedgerEntryNotFoundException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Transacto.Domain { + public class GeneralLedgerEntryNotFoundException : Exception { + public GeneralLedgerEntryNotFoundException(GeneralLedgerEntryIdentifier generalLedgerEntryIdentifier) + : base($"General Ledger Entry {generalLedgerEntryIdentifier} was not found.") { + } + } +} diff --git a/src/Transacto/Domain/GeneralLedgerEntryNotInBalanceException.cs b/src/Transacto/Domain/GeneralLedgerEntryNotInBalanceException.cs new file mode 100644 index 0000000..b8926e8 --- /dev/null +++ b/src/Transacto/Domain/GeneralLedgerEntryNotInBalanceException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Transacto.Domain { + public class GeneralLedgerEntryNotInBalanceException : Exception { + public GeneralLedgerEntryIdentifier GeneralLedgerEntryIdentifier { get; } + + public GeneralLedgerEntryNotInBalanceException(GeneralLedgerEntryIdentifier generalLedgerEntryIdentifier) + : base("The general ledger entry was not in balance.") { + GeneralLedgerEntryIdentifier = generalLedgerEntryIdentifier; + } + } +} diff --git a/src/Transacto/Domain/GeneralLedgerEntryNotInPeriodException.cs b/src/Transacto/Domain/GeneralLedgerEntryNotInPeriodException.cs new file mode 100644 index 0000000..584e34b --- /dev/null +++ b/src/Transacto/Domain/GeneralLedgerEntryNotInPeriodException.cs @@ -0,0 +1,10 @@ +using System; + +namespace Transacto.Domain { + public class GeneralLedgerEntryNotInPeriodException : Exception { + public GeneralLedgerEntryNotInPeriodException(GeneralLedgerEntryNumber number, DateTimeOffset createdOn, + Period period) : base( + $"General ledger entry {number} had a creation date of {createdOn}, but the current period is {period}") { + } + } +} diff --git a/src/Transacto/Domain/GeneralLedgerEntryNumber.cs b/src/Transacto/Domain/GeneralLedgerEntryNumber.cs index 1ac220c..fd91252 100644 --- a/src/Transacto/Domain/GeneralLedgerEntryNumber.cs +++ b/src/Transacto/Domain/GeneralLedgerEntryNumber.cs @@ -1,26 +1,69 @@ using System; +using System.Linq; namespace Transacto.Domain { - public readonly struct GeneralLedgerEntryNumber : IEquatable { - private readonly string _value; + public readonly struct GeneralLedgerEntryNumber : IEquatable { + public const int MaxPrefixLength = 5; + public string Prefix { get; } + public int SequenceNumber { get; } - public GeneralLedgerEntryNumber(string value) { - if (!value.Contains("-")) { - throw new ArgumentException(); - } + public GeneralLedgerEntryNumber(string prefix, int sequenceNumber) { + if (prefix == string.Empty) { + throw new ArgumentException("Prefix may not be empty.", nameof(prefix)); + } - _value = value; - } + if (prefix.Length > MaxPrefixLength) { + throw new ArgumentException($"Prefix may not exceed {MaxPrefixLength} characters.", nameof(prefix)); + } - public bool Equals(GeneralLedgerEntryNumber other) => _value == other._value; - public override bool Equals(object? obj) => obj is GeneralLedgerEntryNumber other && Equals(other); - public override int GetHashCode() => _value.GetHashCode(); - public override string ToString() => _value; + if (prefix.Any(char.IsWhiteSpace)) { + throw new ArgumentException("Prefix may not contain whitespace.", nameof(prefix)); + } - public static bool operator ==(GeneralLedgerEntryNumber left, GeneralLedgerEntryNumber right) => - left.Equals(right); + if (sequenceNumber <= 0) { + throw new ArgumentOutOfRangeException(nameof(sequenceNumber)); + } - public static bool operator !=(GeneralLedgerEntryNumber left, GeneralLedgerEntryNumber right) => - !left.Equals(right); - } + Prefix = prefix; + SequenceNumber = sequenceNumber; + } + + public static GeneralLedgerEntryNumber Parse(string value) => + TryParse(value, out var result) + ? result + : throw new FormatException(); + + public static bool TryParse(string value, out GeneralLedgerEntryNumber generalLedgerEntryNumber) { + generalLedgerEntryNumber = default; + var indexOfDelimiter = value.IndexOf('-'); + if (indexOfDelimiter < 1 || indexOfDelimiter != value.LastIndexOf('-')) { + return false; + } + + var prefix = value[..indexOfDelimiter]; + if (string.IsNullOrWhiteSpace(prefix)) { + return false; + } + + if (!int.TryParse(value[(indexOfDelimiter + 1)..], out var sequenceNumber)) { + return false; + } + + generalLedgerEntryNumber = new GeneralLedgerEntryNumber(prefix, sequenceNumber); + return true; + } + + public bool Equals(GeneralLedgerEntryNumber other) => + Prefix == other.Prefix && SequenceNumber == other.SequenceNumber; + + public override bool Equals(object? obj) => obj is GeneralLedgerEntryNumber other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Prefix, SequenceNumber); + public override string ToString() => $"{Prefix}-{SequenceNumber}"; + + public static bool operator ==(GeneralLedgerEntryNumber left, GeneralLedgerEntryNumber right) => + left.Equals(right); + + public static bool operator !=(GeneralLedgerEntryNumber left, GeneralLedgerEntryNumber right) => + !left.Equals(right); + } } diff --git a/src/Transacto/Domain/GeneralLedgerEntryWasNotPostedException.cs b/src/Transacto/Domain/GeneralLedgerEntryWasNotPostedException.cs new file mode 100644 index 0000000..45ecf52 --- /dev/null +++ b/src/Transacto/Domain/GeneralLedgerEntryWasNotPostedException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Transacto.Domain { + public class GeneralLedgerEntryWasNotPostedException : Exception { + public GeneralLedgerEntryIdentifier GeneralLedgerEntryIdentifier { get; } + + public GeneralLedgerEntryWasNotPostedException(GeneralLedgerEntryIdentifier generalLedgerEntryIdentifier) + : base("The general ledger entry was not posted.") { + GeneralLedgerEntryIdentifier = generalLedgerEntryIdentifier; + } + } +} diff --git a/src/Transacto/Domain/GeneralLedgerEntryWasPostedException.cs b/src/Transacto/Domain/GeneralLedgerEntryWasPostedException.cs new file mode 100644 index 0000000..03b91d5 --- /dev/null +++ b/src/Transacto/Domain/GeneralLedgerEntryWasPostedException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Transacto.Domain { + public class GeneralLedgerEntryWasPostedException : Exception { + public GeneralLedgerEntryIdentifier GeneralLedgerEntryIdentifier { get; } + + public GeneralLedgerEntryWasPostedException(GeneralLedgerEntryIdentifier generalLedgerEntryIdentifier) + : base("The general ledger entry was posted.") { + GeneralLedgerEntryIdentifier = generalLedgerEntryIdentifier; + } + } +} diff --git a/src/Transacto/Domain/IBusinessTransaction.cs b/src/Transacto/Domain/IBusinessTransaction.cs index de38f34..4e89541 100644 --- a/src/Transacto/Domain/IBusinessTransaction.cs +++ b/src/Transacto/Domain/IBusinessTransaction.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Transacto.Framework; namespace Transacto.Domain { public interface IBusinessTransaction { @@ -8,11 +7,4 @@ public interface IBusinessTransaction { IEnumerable GetAdditionalChanges(); int? Version { get; set; } } - - public static class BusinessTransactionExtensions { - public static T WithVersion(this T source, Optional version) where T : IBusinessTransaction { - source.Version = version.HasValue ? version.Value : new int?(); - return source; - } - } } diff --git a/src/Transacto/Domain/IChartOfAccountsRepository.cs b/src/Transacto/Domain/IChartOfAccountsRepository.cs index c73c1fa..dc200e2 100644 --- a/src/Transacto/Domain/IChartOfAccountsRepository.cs +++ b/src/Transacto/Domain/IChartOfAccountsRepository.cs @@ -3,9 +3,9 @@ using Transacto.Framework; namespace Transacto.Domain { - public interface IChartOfAccountsRepository { - ValueTask> GetOptional(CancellationToken cancellationToken = default); - ValueTask Get(CancellationToken cancellationToken = default); - void Add(ChartOfAccounts chartOfAccounts); - } + public interface IChartOfAccountsRepository { + ValueTask> GetOptional(CancellationToken cancellationToken = default); + ValueTask Get(CancellationToken cancellationToken = default); + void Add(ChartOfAccounts chartOfAccounts); + } } diff --git a/src/Transacto/Domain/IGeneralLedgerEntryRepository.cs b/src/Transacto/Domain/IGeneralLedgerEntryRepository.cs index c305b87..991b00a 100644 --- a/src/Transacto/Domain/IGeneralLedgerEntryRepository.cs +++ b/src/Transacto/Domain/IGeneralLedgerEntryRepository.cs @@ -2,10 +2,10 @@ using System.Threading.Tasks; namespace Transacto.Domain { - public interface IGeneralLedgerEntryRepository { - ValueTask Get(GeneralLedgerEntryIdentifier identifier, - CancellationToken cancellationToken = default); + public interface IGeneralLedgerEntryRepository { + ValueTask Get(GeneralLedgerEntryIdentifier identifier, + CancellationToken cancellationToken = default); - void Add(GeneralLedgerEntry generalLedgerEntry); - } + void Add(GeneralLedgerEntry generalLedgerEntry); + } } diff --git a/src/Transacto/Domain/IGeneralLedgerRepository.cs b/src/Transacto/Domain/IGeneralLedgerRepository.cs index 4fdc5a3..0b68c49 100644 --- a/src/Transacto/Domain/IGeneralLedgerRepository.cs +++ b/src/Transacto/Domain/IGeneralLedgerRepository.cs @@ -2,8 +2,8 @@ using System.Threading.Tasks; namespace Transacto.Domain { - public interface IGeneralLedgerRepository { - ValueTask Get(CancellationToken cancellationToken = default); - void Add(GeneralLedger generalLedger); - } + public interface IGeneralLedgerRepository { + ValueTask Get(CancellationToken cancellationToken = default); + void Add(GeneralLedger generalLedger); + } } diff --git a/src/Transacto/Domain/InvalidAccountTypeException.cs b/src/Transacto/Domain/InvalidAccountTypeException.cs new file mode 100644 index 0000000..76f82ee --- /dev/null +++ b/src/Transacto/Domain/InvalidAccountTypeException.cs @@ -0,0 +1,14 @@ +using System; + +namespace Transacto.Domain { + public class InvalidAccountTypeException : Exception { + public AccountType Expected { get; } + public AccountType Actual { get; } + + public InvalidAccountTypeException(AccountType expected, AccountType actual) : base( + $"Expected an account type of '{expected.Name}', received '{actual.Name}'.") { + Expected = expected; + Actual = actual; + } + } +} diff --git a/src/Transacto/Domain/JournalEntry.cs b/src/Transacto/Domain/JournalEntry.cs index 2c4e15b..3602db3 100644 --- a/src/Transacto/Domain/JournalEntry.cs +++ b/src/Transacto/Domain/JournalEntry.cs @@ -4,7 +4,7 @@ namespace Transacto.Domain { public class JournalEntry : IBusinessTransaction { GeneralLedgerEntryNumber IBusinessTransaction.ReferenceNumber => - new GeneralLedgerEntryNumber($"je-{ReferenceNumber}"); + new GeneralLedgerEntryNumber("je", ReferenceNumber); public int ReferenceNumber { get; set; } public Item[] Credits { get; set; } = Array.Empty(); diff --git a/src/Transacto/Domain/Money.cs b/src/Transacto/Domain/Money.cs index 5ddb17e..fc0ebb4 100644 --- a/src/Transacto/Domain/Money.cs +++ b/src/Transacto/Domain/Money.cs @@ -1,30 +1,32 @@ using System; namespace Transacto.Domain { - public readonly struct Money : IEquatable, IComparable { - private readonly decimal _value; + public readonly struct Money : IEquatable, IComparable { + private readonly decimal _value; - public static readonly Money Zero = new Money(decimal.Zero); + public static readonly Money Zero = new Money(decimal.Zero); - public Money(decimal value) { - _value = value; - } + public Money(decimal value) { + _value = value; + } - public bool Equals(Money other) => _value == other._value; - public override bool Equals(object? obj) => obj is Money other && Equals(other); - public override int GetHashCode() => _value.GetHashCode(); - public decimal ToDecimal() => _value; - public int CompareTo(Money other) => _value.CompareTo(other._value); - public static bool operator ==(Money left, Money right) => left.Equals(right); - public static bool operator !=(Money left, Money right) => !left.Equals(right); - public static bool operator <(Money left, Money right) => left._value < right._value; - public static bool operator >(Money left, Money right) => left._value > right._value; - public static bool operator <=(Money left, Money right) => left._value <= right._value; - public static bool operator >=(Money left, Money right) => left._value >= right._value; - public static Money operator +(Money left, Money right) => new Money(left._value + right._value); - public static Money operator -(Money left, Money right) => new Money(left._value - right._value); - public static Money operator +(Money left, decimal right) => new Money(left._value + right); - public static Money operator -(Money left, decimal right) => new Money(left._value - right); - public static Money operator -(Money value) => new Money(-value._value); - } + public bool Equals(Money other) => _value == other._value; + public override bool Equals(object? obj) => obj is Money other && Equals(other); + public override int GetHashCode() => _value.GetHashCode(); + public decimal ToDecimal() => _value; + public int CompareTo(Money other) => _value.CompareTo(other._value); + public override string ToString() => _value.ToString(); + + public static bool operator ==(Money left, Money right) => left.Equals(right); + public static bool operator !=(Money left, Money right) => !left.Equals(right); + public static bool operator <(Money left, Money right) => left._value < right._value; + public static bool operator >(Money left, Money right) => left._value > right._value; + public static bool operator <=(Money left, Money right) => left._value <= right._value; + public static bool operator >=(Money left, Money right) => left._value >= right._value; + public static Money operator +(Money left, Money right) => new Money(left._value + right._value); + public static Money operator -(Money left, Money right) => new Money(left._value - right._value); + public static Money operator +(Money left, decimal right) => new Money(left._value + right); + public static Money operator -(Money left, decimal right) => new Money(left._value - right); + public static Money operator -(Money value) => new Money(-value._value); + } } diff --git a/src/Transacto/Domain/Period.cs b/src/Transacto/Domain/Period.cs index b4a1b2e..cb0c285 100644 --- a/src/Transacto/Domain/Period.cs +++ b/src/Transacto/Domain/Period.cs @@ -1,15 +1,15 @@ using System; namespace Transacto.Domain { - public readonly struct Period : IEquatable { + public readonly struct Period : IEquatable, IComparable { public static readonly Period Empty = default; public int Month { get; } public int Year { get; } public static bool TryParse(string period, out Period value) { if (string.IsNullOrEmpty(period) || period.Length != 6 || - !int.TryParse(period[..2], out var month) || NotAMonth(month) || - !int.TryParse(period[2..], out var year)) { + !int.TryParse(period[4..], out var month) || + !int.TryParse(period[..4], out var year)) { value = default; return false; } @@ -27,15 +27,17 @@ public static Period Open(DateTimeOffset dateTimeOffset) => new Period(dateTimeOffset.UtcDateTime.Month, dateTimeOffset.UtcDateTime.Year); private Period(int month, int year) { - if (NotAMonth(month)) { - throw new ArgumentOutOfRangeException(nameof(month)); - } + MustBeAMonth(month); Month = month; Year = year; } - private static bool NotAMonth(int month) => month < 1 || month > 12; + private static void MustBeAMonth(int month) { + if (month < 1 || month > 12) { + throw new ArgumentOutOfRangeException(nameof(month)); + } + } public Period Next() => Month == 12 ? new Period(1, Year + 1) @@ -45,16 +47,25 @@ public bool Contains(DateTimeOffset dateTimeOffset) => dateTimeOffset.UtcDateTime.Month == Month && dateTimeOffset.UtcDateTime.Year == Year; public void MustNotBeAfter(DateTimeOffset closingOn) { - if (closingOn.UtcDateTime < new DateTime(Year, Month, 1)) { - throw new InvalidOperationException(); + if (closingOn.UtcDateTime < new DateTime(Year, Month, 1, 0, 0, 0, DateTimeKind.Utc)) { + throw new ClosingDateBeforePeriodException(this, closingOn); } } + public int CompareTo(Period other) { + var yearComparison = Year.CompareTo(other.Year); + return yearComparison != 0 ? yearComparison : Month.CompareTo(other.Month); + } + public bool Equals(Period other) => Month == other.Month && Year == other.Year; public override bool Equals(object? obj) => obj is Period other && Equals(other); public static bool operator ==(Period left, Period right) => left.Equals(right); public static bool operator !=(Period left, Period right) => !left.Equals(right); - public override string ToString() => $"{Month:D2}{Year:D4}"; + public override string ToString() => $"{Year:D4}{Month:D2}"; public override int GetHashCode() => HashCode.Combine(Month, Year); + public static bool operator <(Period left, Period right) => left.CompareTo(right) < 0; + public static bool operator >(Period left, Period right) => left.CompareTo(right) > 0; + public static bool operator <=(Period left, Period right) => left.CompareTo(right) <= 0; + public static bool operator >=(Period left, Period right) => left.CompareTo(right) >= 0; } } diff --git a/src/Transacto/Domain/PeriodClosingInProcessException.cs b/src/Transacto/Domain/PeriodClosingInProcessException.cs new file mode 100644 index 0000000..5e2236b --- /dev/null +++ b/src/Transacto/Domain/PeriodClosingInProcessException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Transacto.Domain { + public class PeriodClosingInProcessException : Exception { + public PeriodClosingInProcessException(Period period) : + base($"Closing period {period} is already in process.") { + } + } +} diff --git a/src/Transacto/Domain/PeriodContainsUntransferredEntriesException.cs b/src/Transacto/Domain/PeriodContainsUntransferredEntriesException.cs new file mode 100644 index 0000000..179e683 --- /dev/null +++ b/src/Transacto/Domain/PeriodContainsUntransferredEntriesException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Transacto.Domain { + public class PeriodContainsUntransferredEntriesException : Exception { + public Period Period { get; } + public GeneralLedgerEntryIdentifier[] UntransferredGeneralLedgerEntryIdentifiers { get; } + + public PeriodContainsUntransferredEntriesException(Period period, + GeneralLedgerEntryIdentifier[] untransferredGeneralLedgerEntryIdentifiers) : base( + $"Period {period} contains un-transferred entries: {string.Join(", ", untransferredGeneralLedgerEntryIdentifiers)}") { + Period = period; + UntransferredGeneralLedgerEntryIdentifiers = untransferredGeneralLedgerEntryIdentifiers; + } + } +} diff --git a/src/Transacto/Domain/PeriodOpenException.cs b/src/Transacto/Domain/PeriodOpenException.cs new file mode 100644 index 0000000..77af9e7 --- /dev/null +++ b/src/Transacto/Domain/PeriodOpenException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Transacto.Domain { + public class PeriodOpenException : Exception { + public Period Period { get; } + + public PeriodOpenException(Period period) : base($"Period {period} is still open.") { + Period = period; + } + } +} diff --git a/src/Transacto/Domain/TrialBalanceFailedException.cs b/src/Transacto/Domain/TrialBalanceFailedException.cs new file mode 100644 index 0000000..77ed9bc --- /dev/null +++ b/src/Transacto/Domain/TrialBalanceFailedException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Transacto.Domain { + public class TrialBalanceFailedException : Exception { + public Money Balance { get; } + + public TrialBalanceFailedException(Money balance) : base( + $"Expected a balance of {Money.Zero}, current trial balance is {balance}.") { + Balance = balance; + } + } +} diff --git a/src/Transacto/Framework/CommandHandler.TMetadata.cs b/src/Transacto/Framework/CommandHandler.TMetadata.cs deleted file mode 100644 index 7f4510e..0000000 --- a/src/Transacto/Framework/CommandHandler.TMetadata.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Transacto.Framework { - public class CommandHandler { - public CommandHandler(Type command, Func handler) { - Command = command; - Handler = handler; - } - - public Type Command { get; } - public Func Handler { get; } - - public CommandHandler Pipe(Func< - Func, - Func> pipe) => - new CommandHandler(Command, pipe(Handler)); - } -} diff --git a/src/Transacto/Framework/CommandHandler.cs b/src/Transacto/Framework/CommandHandler.cs deleted file mode 100644 index d8d1aea..0000000 --- a/src/Transacto/Framework/CommandHandler.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Transacto.Framework { - public class CommandHandler { - public CommandHandler(Type command, Func handler) { - Command = command; - Handler = handler; - } - - public Type Command { get; } - public Func Handler { get; } - - public CommandHandler Pipe(Func< - Func, - Func> pipe) => new CommandHandler(Command, pipe(Handler)); - } -} diff --git a/src/Transacto/Framework/CommandHandlerEnumerator.TMetadata.cs b/src/Transacto/Framework/CommandHandlerEnumerator.TMetadata.cs deleted file mode 100644 index 275f413..0000000 --- a/src/Transacto/Framework/CommandHandlerEnumerator.TMetadata.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; - -namespace Transacto.Framework { - public class CommandHandlerEnumerator : IEnumerator> { - private readonly CommandHandler[] _handlers; - private int _index; - - public CommandHandlerEnumerator(CommandHandler[] handlers) { - _handlers = handlers; - _index = -1; - } - - public bool MoveNext() => _index < _handlers.Length && - ++_index < _handlers.Length; - - public void Reset() { - _index = -1; - } - - public CommandHandler Current { - get { - if (_index == -1) - throw new InvalidOperationException("Enumeration has not started. Call MoveNext."); - if (_index == _handlers.Length) - throw new InvalidOperationException("Enumeration has already ended. Call Reset."); - - return _handlers[_index]; - } - } - - object IEnumerator.Current => Current; - - public void Dispose() { - } - } -} diff --git a/src/Transacto/Framework/CommandHandling/CommandHandler.TMetadata.cs b/src/Transacto/Framework/CommandHandling/CommandHandler.TMetadata.cs new file mode 100644 index 0000000..290b01b --- /dev/null +++ b/src/Transacto/Framework/CommandHandling/CommandHandler.TMetadata.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Transacto.Framework.CommandHandling { + public class CommandHandler { + public CommandHandler(Type command, Func handler) { + Command = command; + Handler = handler; + } + + public Type Command { get; } + public Func Handler { get; } + + public CommandHandler Pipe(Func< + Func, + Func> pipe) => + new CommandHandler(Command, pipe(Handler)); + } +} diff --git a/src/Transacto/Framework/CommandHandling/CommandHandler.cs b/src/Transacto/Framework/CommandHandling/CommandHandler.cs new file mode 100644 index 0000000..bef2680 --- /dev/null +++ b/src/Transacto/Framework/CommandHandling/CommandHandler.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client; + +namespace Transacto.Framework.CommandHandling { + public class CommandHandler { + public CommandHandler(Type command, Func> handler) { + Command = command; + Handler = handler; + } + + public Type Command { get; } + public Func> Handler { get; } + + public CommandHandler Pipe(Func< + Func>, + Func>> pipe) => new CommandHandler(Command, pipe(Handler)); + } +} diff --git a/src/Transacto/Framework/CommandHandlerBuilder.TMetadata.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerBuilder.TMetadata.cs similarity index 98% rename from src/Transacto/Framework/CommandHandlerBuilder.TMetadata.cs rename to src/Transacto/Framework/CommandHandling/CommandHandlerBuilder.TMetadata.cs index aa04436..342f96c 100644 --- a/src/Transacto/Framework/CommandHandlerBuilder.TMetadata.cs +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerBuilder.TMetadata.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { internal class CommandHandlerBuilder : ICommandHandlerBuilder { private readonly Action> _build; diff --git a/src/Transacto/Framework/CommandHandlerBuilder.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerBuilder.cs similarity index 54% rename from src/Transacto/Framework/CommandHandlerBuilder.cs rename to src/Transacto/Framework/CommandHandling/CommandHandlerBuilder.cs index b5c1f96..5d805f4 100644 --- a/src/Transacto/Framework/CommandHandlerBuilder.cs +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerBuilder.cs @@ -1,75 +1,86 @@ using System; using System.Threading; using System.Threading.Tasks; +using EventStore.Client; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { internal class CommandHandlerBuilder : ICommandHandlerBuilder { - private readonly Action> _build; + private readonly Action>> _build; - public CommandHandlerBuilder(Action> build) { + public CommandHandlerBuilder(Action>> build) { _build = build; } public ICommandHandlerBuilder Pipe( - Func, Func> pipe) => + Func>, + Func>> pipe) => new WithPipeline(_build, pipe); public ICommandHandlerBuilder Transform( - Func, Func> pipe) => + Func>, + Func>> pipe) => new WithTransformedPipeline(_build, pipe); - public void Handle(Func handler) => _build(handler); + public void Handle(Func> handler) => _build(handler); private class WithPipeline : ICommandHandlerBuilder { - private readonly Action> _build; + private readonly Action>> _build; - private readonly Func, - Func> + private readonly Func>, + Func>> _pipeline; - public WithPipeline(Action> build, - Func, Func> + public WithPipeline(Action>> build, + Func>, + Func>> pipeline) { _build = build; _pipeline = pipeline; } public ICommandHandlerBuilder Pipe( - Func, Func> + Func>, + Func>> pipe) => new WithPipeline(_build, next => _pipeline(pipe(next))); public ICommandHandlerBuilder Transform( - Func, Func> pipe) => + Func>, + Func>> pipe) => new WithTransformedPipeline(_build, next => _pipeline(pipe(next))); - public void Handle(Func handler) => _build(_pipeline(handler)); + public void Handle(Func> handler) => + _build(_pipeline(handler)); } private class WithTransformedPipeline : ICommandHandlerBuilder { - private readonly Action> _build; + private readonly Action>> _build; - private readonly Func, - Func> + private readonly Func>, + Func>> _pipeline; - public WithTransformedPipeline(Action> build, - Func, Func> - pipeline) { + public WithTransformedPipeline(Action>> build, + Func>, + Func>> pipeline) { _build = build; _pipeline = pipeline; } public ICommandHandlerBuilder Pipe( - Func, Func> + Func>, + Func>> pipe) => new WithTransformedPipeline(_build, next => _pipeline(pipe(next))); public ICommandHandlerBuilder Transform( - Func, Func> pipe) => + Func>, + Func>> + pipe) => new WithTransformedPipeline(_build, next => _pipeline(pipe(next))); - public void Handle(Func handler) => _build(_pipeline(handler)); + public void Handle(Func> handler) => + _build(_pipeline(handler)); } } } diff --git a/src/Transacto/Framework/CommandHandling/CommandHandlerEnumerator.TMetadata.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerEnumerator.TMetadata.cs new file mode 100644 index 0000000..ea694c1 --- /dev/null +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerEnumerator.TMetadata.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Transacto.Framework.CommandHandling { + public class CommandHandlerEnumerator : IEnumerator> { + private readonly CommandHandler[] _handlers; + private int _index; + + public CommandHandlerEnumerator(CommandHandler[] handlers) { + _handlers = handlers; + _index = -1; + } + + public bool MoveNext() => _index < _handlers.Length && + ++_index < _handlers.Length; + + public void Reset() { + _index = -1; + } + + public CommandHandler Current { + get { + if (_index == -1) + throw new InvalidOperationException("Enumeration has not started. Call MoveNext."); + if (_index == _handlers.Length) + throw new InvalidOperationException("Enumeration has already ended. Call Reset."); + + return _handlers[_index]; + } + } + + object IEnumerator.Current => Current; + + public void Dispose() { + } + } +} diff --git a/src/Transacto/Framework/CommandHandlerEnumerator.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerEnumerator.cs similarity index 94% rename from src/Transacto/Framework/CommandHandlerEnumerator.cs rename to src/Transacto/Framework/CommandHandling/CommandHandlerEnumerator.cs index 20b8026..1f7b3ea 100644 --- a/src/Transacto/Framework/CommandHandlerEnumerator.cs +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerEnumerator.cs @@ -2,7 +2,7 @@ using System.Collections; using System.Collections.Generic; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { public class CommandHandlerEnumerator : IEnumerator { private readonly CommandHandler[] _handlers; private int _index; diff --git a/src/Transacto/Framework/CommandHandlerModule.TMetadata.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerModule.TMetadata.cs similarity index 96% rename from src/Transacto/Framework/CommandHandlerModule.TMetadata.cs rename to src/Transacto/Framework/CommandHandling/CommandHandlerModule.TMetadata.cs index 9541591..279b486 100644 --- a/src/Transacto/Framework/CommandHandlerModule.TMetadata.cs +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerModule.TMetadata.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { public abstract class CommandHandlerModule : IEnumerable> { private readonly List> _handlers; diff --git a/src/Transacto/Framework/CommandHandlerModule.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerModule.cs similarity index 91% rename from src/Transacto/Framework/CommandHandlerModule.cs rename to src/Transacto/Framework/CommandHandling/CommandHandlerModule.cs index 0925926..97b1222 100644 --- a/src/Transacto/Framework/CommandHandlerModule.cs +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerModule.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using EventStore.Client; - -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { public abstract class CommandHandlerModule : IEnumerable { private readonly List _handlers; @@ -19,7 +19,7 @@ protected ICommandHandlerBuilder Build() where TCommand : cl (command, token) => handler((TCommand)command, token))); }); - protected void Handle(Func handler) => + protected void Handle(Func> handler) => _handlers.Add(new CommandHandler(typeof(TCommand), (command, token) => handler((TCommand)command, token))); public CommandHandler[] Handlers => _handlers.ToArray(); diff --git a/src/Transacto/Framework/CommandHandling/CommandHandlerResolver.cs b/src/Transacto/Framework/CommandHandling/CommandHandlerResolver.cs new file mode 100644 index 0000000..f892217 --- /dev/null +++ b/src/Transacto/Framework/CommandHandling/CommandHandlerResolver.cs @@ -0,0 +1,3 @@ +namespace Transacto.Framework.CommandHandling { + public delegate CommandHandler CommandHandlerResolver(object command); +} diff --git a/src/Transacto/Framework/Resolve.cs b/src/Transacto/Framework/CommandHandling/CommandResolve.cs similarity index 82% rename from src/Transacto/Framework/Resolve.cs rename to src/Transacto/Framework/CommandHandling/CommandResolve.cs index 809ef4a..db9e01e 100644 --- a/src/Transacto/Framework/Resolve.cs +++ b/src/Transacto/Framework/CommandHandling/CommandResolve.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { public static class CommandResolve { public static CommandHandlerResolver WhenEqualToHandlerMessageType(IEnumerable modules) { var cache = modules.SelectMany(m => m.Handlers).ToLookup(h => h.Command); @@ -18,6 +18,4 @@ public static CommandHandlerResolver WhenEqualToHandlerMessageType(IEnumerable { ICommandHandlerBuilder Pipe( Func, Func> diff --git a/src/Transacto/Framework/CommandHandling/ICommandHandlerBuilder.cs b/src/Transacto/Framework/CommandHandling/ICommandHandlerBuilder.cs new file mode 100644 index 0000000..e4558f9 --- /dev/null +++ b/src/Transacto/Framework/CommandHandling/ICommandHandlerBuilder.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client; + +namespace Transacto.Framework.CommandHandling { + public interface ICommandHandlerBuilder { + ICommandHandlerBuilder Pipe( + Func>, + Func>> pipe); + + ICommandHandlerBuilder Transform( + Func>, + Func>> pipe); + + void Handle(Func> handler); + } +} diff --git a/src/Transacto/Framework/LoggingExtensions.cs b/src/Transacto/Framework/CommandHandling/LoggingExtensions.cs similarity index 87% rename from src/Transacto/Framework/LoggingExtensions.cs rename to src/Transacto/Framework/CommandHandling/LoggingExtensions.cs index 2c8f8ea..adc0c23 100644 --- a/src/Transacto/Framework/LoggingExtensions.cs +++ b/src/Transacto/Framework/CommandHandling/LoggingExtensions.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { public static class LoggingExtensions { public static ICommandHandlerBuilder Log(this ICommandHandlerBuilder builder, ILoggerFactory? loggerFactory = null) where T : class { @@ -14,11 +14,13 @@ public static ICommandHandlerBuilder Log(this ICommandHandlerBuilder bu } try { - await next(m, ct); + return await next(m, ct); } catch (Exception ex) { if (log.IsEnabled(LogLevel.Error)) { log.LogError(ex.ToString()); } + + throw; } }); } diff --git a/src/Transacto/Framework/UnitOfWork.cs b/src/Transacto/Framework/CommandHandling/UnitOfWork.cs similarity index 98% rename from src/Transacto/Framework/UnitOfWork.cs rename to src/Transacto/Framework/CommandHandling/UnitOfWork.cs index fe67346..ab91c26 100644 --- a/src/Transacto/Framework/UnitOfWork.cs +++ b/src/Transacto/Framework/CommandHandling/UnitOfWork.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { /// /// Tracks changes of attached aggregates. /// diff --git a/src/Transacto/Framework/UnitOfWorkExtensions.cs b/src/Transacto/Framework/CommandHandling/UnitOfWorkExtensions.cs similarity index 53% rename from src/Transacto/Framework/UnitOfWorkExtensions.cs rename to src/Transacto/Framework/CommandHandling/UnitOfWorkExtensions.cs index 5d8db80..2983048 100644 --- a/src/Transacto/Framework/UnitOfWorkExtensions.cs +++ b/src/Transacto/Framework/CommandHandling/UnitOfWorkExtensions.cs @@ -1,9 +1,10 @@ using System; using System.Linq; using System.Text.Json; +using System.Threading.Tasks; using EventStore.Client; -namespace Transacto.Framework { +namespace Transacto.Framework.CommandHandling { public static class UnitOfWorkExtensions { public static ICommandHandlerBuilder<(UnitOfWork, TCommand)> UnitOfWork( this ICommandHandlerBuilder builder, EventStoreClient eventStore, @@ -15,29 +16,32 @@ public static class UnitOfWorkExtensions { await next((unitOfWork, message), ct); if (!unitOfWork.HasChanges) { - return; + return Position.Start; } var (streamName, aggregateRoot, expectedVersion) = unitOfWork.GetChanges().Single(); - if (!expectedVersion.HasValue) { - await eventStore.AppendToStreamAsync(streamName, - StreamState.NoStream, - aggregateRoot.GetChanges().Select(e => new EventData(Uuid.NewUuid(), - messageTypeMapper.Map(e.GetType()) ?? throw new InvalidOperationException(), - JsonSerializer.SerializeToUtf8Bytes(e, eventSerializerOptions))), - cancellationToken: ct); - } else { - await eventStore.AppendToStreamAsync(streamName, - new StreamRevision(Convert.ToUInt64(expectedVersion.Value)), - aggregateRoot.GetChanges().Select(e => new EventData(Uuid.NewUuid(), - messageTypeMapper.Map(e.GetType()) ?? throw new InvalidOperationException(), - JsonSerializer.SerializeToUtf8Bytes(e, eventSerializerOptions))), - cancellationToken: ct); + var eventData = aggregateRoot.GetChanges().Select(e => new EventData(Uuid.NewUuid(), + messageTypeMapper.Map(e.GetType()) ?? throw new InvalidOperationException(), + JsonSerializer.SerializeToUtf8Bytes(e, eventSerializerOptions))); - } + var result = await Append(); aggregateRoot.MarkChangesAsCommitted(); + + return result.LogPosition; + + Task Append() => expectedVersion.HasValue + ? eventStore.AppendToStreamAsync(streamName, + new StreamRevision(Convert.ToUInt64(expectedVersion.Value)), + eventData, + options => options.TimeoutAfter = TimeSpan.FromMinutes(4), + cancellationToken: ct) + : eventStore.AppendToStreamAsync(streamName, + StreamState.NoStream, + eventData, + options => options.TimeoutAfter = TimeSpan.FromMinutes(4), + cancellationToken: ct); }); } } diff --git a/src/Transacto/Framework/ICommandHandlerBuilder.cs b/src/Transacto/Framework/ICommandHandlerBuilder.cs deleted file mode 100644 index 5a412fc..0000000 --- a/src/Transacto/Framework/ICommandHandlerBuilder.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Transacto.Framework { - public interface ICommandHandlerBuilder { - ICommandHandlerBuilder Pipe( - Func, Func> pipe); - - ICommandHandlerBuilder Transform( - Func, Func> pipe); - - void Handle(Func handler); - } -} diff --git a/src/Transacto/Framework/IMessageTypeMapper.cs b/src/Transacto/Framework/IMessageTypeMapper.cs index 068bc16..6061614 100644 --- a/src/Transacto/Framework/IMessageTypeMapper.cs +++ b/src/Transacto/Framework/IMessageTypeMapper.cs @@ -3,8 +3,12 @@ namespace Transacto.Framework { public interface IMessageTypeMapper { - string? Map(Type type); - Type? Map(string storageType); + string Map(Type type) => !TryMap(type, out var t) ? throw new InvalidOperationException() : t!; + Type Map(string storageType) => !TryMap(storageType, out var t) ? throw new InvalidOperationException() : t!; + + bool TryMap(string storageType, out Type? type); + bool TryMap(Type type, out string? storageType); + IEnumerable StorageTypes { get; } IEnumerable Types { get; } } diff --git a/src/Transacto/Framework/MessageTypeMapper.cs b/src/Transacto/Framework/MessageTypeMapper.cs new file mode 100644 index 0000000..dd718f4 --- /dev/null +++ b/src/Transacto/Framework/MessageTypeMapper.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Transacto.Messages; + +namespace Transacto.Framework { + public class MessageTypeMapper : IMessageTypeMapper { + private readonly IDictionary _storageTypeToType; + private readonly IDictionary _typeToStorageType; + + public IEnumerable StorageTypes => _storageTypeToType.Keys; + public IEnumerable Types => _typeToStorageType.Keys; + + public static IMessageTypeMapper ScopedFromType(Type type) => + new ReflectionMessageTypeMapper(type.Assembly, type.Namespace); + + public static IMessageTypeMapper Create(params MessageTypeMapper[] messageTypeMappers) + => new CompositeMessageTypeMapper(messageTypeMappers.Concat(new[] {TransactoMessageTypeMapper.Instance}) + .ToArray()); + + public MessageTypeMapper(IEnumerable types) { + _typeToStorageType = types.ToDictionary(type => type, type => type.Name); + _storageTypeToType = _typeToStorageType.ToDictionary(pair => pair.Value, pair => pair.Key); + } + + public bool TryMap(string storageType, out Type? type) => _storageTypeToType.TryGetValue(storageType, out type); + public bool TryMap(Type type, out string? storageType) => _typeToStorageType.TryGetValue(type, out storageType); + + private class TransactoMessageTypeMapper : IMessageTypeMapper { + public static readonly IMessageTypeMapper Instance = new TransactoMessageTypeMapper(); + + private readonly IMessageTypeMapper _inner; + + public bool TryMap(string storageType, out Type? type) => _inner.TryMap(storageType, out type); + public bool TryMap(Type type, out string? storageType) => _inner.TryMap(type, out storageType); + + public IEnumerable StorageTypes => _inner.StorageTypes; + public IEnumerable Types => _inner.Types; + + private TransactoMessageTypeMapper() => _inner = ScopedFromType(typeof(AccountDefined)); + } + + private class CompositeMessageTypeMapper : IMessageTypeMapper { + private readonly IEnumerable _messageTypeMappers; + + public IEnumerable StorageTypes => _messageTypeMappers.SelectMany(m => m.StorageTypes); + public IEnumerable Types => _messageTypeMappers.SelectMany(m => m.Types); + + public CompositeMessageTypeMapper(params IMessageTypeMapper[] messageTypeMappers) { + var duplicates = (from m in messageTypeMappers + from s in m.StorageTypes + group s by s + into g + where g.Count() > 1 + select g.Key).ToArray(); + if (duplicates.Length > 0) { + throw new ArgumentException("Duplicate types registered.", nameof(messageTypeMappers)); + } + + _messageTypeMappers = messageTypeMappers; + } + + public bool TryMap(string storageType, out Type? type) { + type = default; + foreach (var messageTypeMapper in _messageTypeMappers) { + if (messageTypeMapper.TryMap(storageType, out type)) { + return true; + } + } + + return false; + } + + public bool TryMap(Type type, out string? storageType) { + storageType = default; + foreach (var messageTypeMapper in _messageTypeMappers) { + if (messageTypeMapper.TryMap(type, out storageType)) { + return true; + } + } + + return false; + } + } + + private class ReflectionMessageTypeMapper : IMessageTypeMapper { + private readonly IMessageTypeMapper _inner; + + public ReflectionMessageTypeMapper(Assembly messageAssembly, string? messageNamespace) { + if (messageNamespace == null) { + throw new ArgumentNullException(messageNamespace); + } + + _inner = new MessageTypeMapper(messageAssembly.DefinedTypes.Where(IsMessageType(messageNamespace))); + } + + private static Func IsMessageType(string messageNamespace) => type => + type.Namespace?.Equals(messageNamespace) ?? false; + + public bool TryMap(string storageType, out Type? type) => _inner.TryMap(storageType, out type); + public bool TryMap(Type type, out string? storageType) => _inner.TryMap(type, out storageType); + public IEnumerable StorageTypes => _inner.StorageTypes; + public IEnumerable Types => _inner.Types; + } + } +} diff --git a/src/Transacto/Framework/Optional.cs b/src/Transacto/Framework/Optional.cs index 164045a..a43c112 100644 --- a/src/Transacto/Framework/Optional.cs +++ b/src/Transacto/Framework/Optional.cs @@ -3,7 +3,7 @@ namespace Transacto.Framework { public readonly struct Optional : IEquatable> { - public static readonly Optional Empty = new Optional(); + public static readonly Optional Empty = default; public bool HasValue { get; } public T Value { diff --git a/src/Transacto/Framework/ReflectionMessageTypeMapper.cs b/src/Transacto/Framework/ReflectionMessageTypeMapper.cs deleted file mode 100644 index 3e3eb25..0000000 --- a/src/Transacto/Framework/ReflectionMessageTypeMapper.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Transacto.Messages; - -namespace Transacto.Framework { - internal class TransactoMessageTypeMapper : IMessageTypeMapper { - public static IMessageTypeMapper Instance = new TransactoMessageTypeMapper(); - - private readonly IMessageTypeMapper _inner; - - public IEnumerable StorageTypes => _inner.StorageTypes; - public IEnumerable Types => _inner.Types; - - private TransactoMessageTypeMapper() => _inner = MessageTypeMapper.ScopedFromType(typeof(AccountDefined)); - - public string? Map(Type type) => _inner.Map(type); - - public Type? Map(string storageType) => _inner.Map(storageType); - } - - internal class CompositeMessageTypeMapper : IMessageTypeMapper { - private readonly IEnumerable _messageTypeMappers; - - public IEnumerable StorageTypes => _messageTypeMappers.SelectMany(m => m.StorageTypes); - public IEnumerable Types => _messageTypeMappers.SelectMany(m => m.Types); - - public CompositeMessageTypeMapper(params IMessageTypeMapper[] messageTypeMappers) { - var duplicates = (from m in messageTypeMappers - from s in m.StorageTypes - group s by s - into g - where g.Count() > 1 - select g.Key).ToArray(); - if (duplicates.Length > 0) { - throw new ArgumentException(); - } - - _messageTypeMappers = messageTypeMappers; - } - - public string? Map(Type type) => _messageTypeMappers.Select(x => x.Map(type)).FirstOrDefault(t => t != null); - - public Type? Map(string storageType) => - _messageTypeMappers.Select(x => x.Map(storageType)).FirstOrDefault(t => t != null); - } - - internal class ReflectionMessageTypeMapper : IMessageTypeMapper { - private readonly IMessageTypeMapper _inner; - - public ReflectionMessageTypeMapper(Assembly messageAssembly, string messageNamespace) { - _inner = new MessageTypeMapper(messageAssembly.DefinedTypes.Where(IsMessageType(messageNamespace))); - } - - private static Func IsMessageType(string messageNamespace) => - type => type.Namespace?.Equals(messageNamespace) ?? false; - - public string? Map(Type type) => _inner.Map(type); - - public Type? Map(string storageType) => _inner.Map(storageType); - - public IEnumerable StorageTypes => _inner.StorageTypes; - - public IEnumerable Types => _inner.Types; - } - - public class MessageTypeMapper : IMessageTypeMapper { - private readonly IDictionary _storageTypeToType; - private readonly IDictionary _typeToStorageType; - - public IEnumerable StorageTypes => _storageTypeToType.Keys; - public IEnumerable Types => _typeToStorageType.Keys; - - public static IMessageTypeMapper ScopedFromType(Type type) { - if (type.Namespace == null) { - throw new ArgumentNullException(nameof(type.Namespace)); - } - - return new ReflectionMessageTypeMapper(type.Assembly, type.Namespace); - } - - public static IMessageTypeMapper Create(params MessageTypeMapper[] messageTypeMappers) - => new CompositeMessageTypeMapper(messageTypeMappers.Concat(new[] {TransactoMessageTypeMapper.Instance}) - .ToArray()); - - public MessageTypeMapper(IEnumerable types) { - _typeToStorageType = types.ToDictionary(type => type, type => type.Name); - _storageTypeToType = _typeToStorageType.ToDictionary(pair => pair.Value, pair => pair.Key); - } - - public string? Map(Type type) => _typeToStorageType.TryGetValue(type, out var storageType) ? storageType : null; - - public Type? Map(string storageType) => _storageTypeToType.TryGetValue(storageType, out var type) ? type : null; - } -} diff --git a/src/Transacto/Infrastructure/ChartOfAccountsEventStoreRepository.cs b/src/Transacto/Infrastructure/ChartOfAccountsEventStoreRepository.cs index 0beb1d5..85e6504 100644 --- a/src/Transacto/Infrastructure/ChartOfAccountsEventStoreRepository.cs +++ b/src/Transacto/Infrastructure/ChartOfAccountsEventStoreRepository.cs @@ -4,6 +4,7 @@ using EventStore.Client; using Transacto.Domain; using Transacto.Framework; +using Transacto.Framework.CommandHandling; namespace Transacto.Infrastructure { public class ChartOfAccountsEventStoreRepository : IChartOfAccountsRepository { @@ -12,16 +13,16 @@ public class ChartOfAccountsEventStoreRepository : IChartOfAccountsRepository { public ChartOfAccountsEventStoreRepository(EventStoreClient eventStore, IMessageTypeMapper messageTypeMapper, UnitOfWork unitOfWork) { _inner = new EventStoreRepository(eventStore, unitOfWork, - ChartOfAccounts.Factory, _ => "chartOfAccounts", messageTypeMapper); + ChartOfAccounts.Factory, messageTypeMapper); } public ValueTask> GetOptional(CancellationToken cancellationToken = default) - => _inner.GetById(string.Empty, cancellationToken); + => _inner.GetById(ChartOfAccounts.Identifier, cancellationToken); public async ValueTask Get(CancellationToken cancellationToken = default) { var optionalChartOfAccounts = await GetOptional(cancellationToken); if (!optionalChartOfAccounts.HasValue) { - throw new InvalidOperationException(); + throw new ChartOfAccountsNotFoundException(); } return optionalChartOfAccounts.Value; diff --git a/src/Transacto/Infrastructure/EventStoreRepository.cs b/src/Transacto/Infrastructure/EventStoreRepository.cs index 418f2df..f6ba96f 100644 --- a/src/Transacto/Infrastructure/EventStoreRepository.cs +++ b/src/Transacto/Infrastructure/EventStoreRepository.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using EventStore.Client; using Transacto.Framework; +using Transacto.Framework.CommandHandling; namespace Transacto.Infrastructure { public class EventStoreRepository where TAggregateRoot : AggregateRoot { @@ -15,7 +16,6 @@ public class EventStoreRepository where TAggregateRoot : Aggrega private readonly EventStoreClient _eventStore; private readonly UnitOfWork _unitOfWork; private readonly Func _factory; - private readonly Func _getStreamName; private readonly IMessageTypeMapper _messageTypeMapper; private readonly JsonSerializerOptions _serializerOptions; @@ -23,27 +23,27 @@ public EventStoreRepository( EventStoreClient eventStore, UnitOfWork unitOfWork, Func factory, - Func getStreamName, IMessageTypeMapper messageTypeMapper, JsonSerializerOptions? serializerOptions = null) { _eventStore = eventStore; _unitOfWork = unitOfWork; _factory = factory; - _getStreamName = getStreamName; _messageTypeMapper = messageTypeMapper; _serializerOptions = serializerOptions ?? DefaultOptions; } public async ValueTask> GetById(string identifier, CancellationToken cancellationToken = default) { - var streamName = _getStreamName(identifier); + var streamName = identifier; if (_unitOfWork.TryGet(streamName, out var a) && a is TAggregateRoot aggregate) { return new Optional(aggregate); } try { await using var events = _eventStore.ReadStreamAsync(Direction.Forwards, - streamName, StreamPosition.Start, int.MaxValue, cancellationToken: cancellationToken); + streamName, StreamPosition.Start, + configureOperationOptions: options => options.TimeoutAfter = TimeSpan.FromMinutes(20), + cancellationToken: cancellationToken); aggregate = _factory(); @@ -61,6 +61,6 @@ public async ValueTask> GetById(string identifier, } public void Add(TAggregateRoot aggregateRoot) => - _unitOfWork.Attach(_getStreamName(aggregateRoot.Id), aggregateRoot); + _unitOfWork.Attach(aggregateRoot.Id, aggregateRoot); } } diff --git a/src/Transacto/Infrastructure/GeneralLedgerEntryEventStoreRepository.cs b/src/Transacto/Infrastructure/GeneralLedgerEntryEventStoreRepository.cs index 1682d28..711d3c6 100644 --- a/src/Transacto/Infrastructure/GeneralLedgerEntryEventStoreRepository.cs +++ b/src/Transacto/Infrastructure/GeneralLedgerEntryEventStoreRepository.cs @@ -1,9 +1,9 @@ -using System; using System.Threading; using System.Threading.Tasks; using EventStore.Client; using Transacto.Domain; using Transacto.Framework; +using Transacto.Framework.CommandHandling; namespace Transacto.Infrastructure { public class GeneralLedgerEntryEventStoreRepository : IGeneralLedgerEntryRepository { @@ -12,15 +12,15 @@ public class GeneralLedgerEntryEventStoreRepository : IGeneralLedgerEntryReposit public GeneralLedgerEntryEventStoreRepository(EventStoreClient eventStore, IMessageTypeMapper messageTypeMapper, UnitOfWork unitOfWork) { _inner = new EventStoreRepository(eventStore, unitOfWork, - GeneralLedgerEntry.Factory, - identifier => $"generalLedgerEntry-{identifier.ToString()}", messageTypeMapper); + GeneralLedgerEntry.Factory, messageTypeMapper); } public async ValueTask Get(GeneralLedgerEntryIdentifier identifier, CancellationToken cancellationToken = default) { - var optionalGeneralLedgerEntry = await _inner.GetById(identifier.ToString(), cancellationToken); + var optionalGeneralLedgerEntry = await _inner.GetById(GeneralLedgerEntry.FormatStreamIdentifier(identifier), + cancellationToken); if (!optionalGeneralLedgerEntry.HasValue) { - throw new InvalidOperationException(); + throw new GeneralLedgerEntryNotFoundException(identifier); } return optionalGeneralLedgerEntry.Value; diff --git a/src/Transacto/Infrastructure/GeneralLedgerEventStoreRepository.cs b/src/Transacto/Infrastructure/GeneralLedgerEventStoreRepository.cs index d6110d6..7bc0551 100644 --- a/src/Transacto/Infrastructure/GeneralLedgerEventStoreRepository.cs +++ b/src/Transacto/Infrastructure/GeneralLedgerEventStoreRepository.cs @@ -1,12 +1,11 @@ -using System; using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using EventStore.Client; using Transacto.Domain; using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Messages; namespace Transacto.Infrastructure { @@ -21,8 +20,7 @@ public GeneralLedgerEventStoreRepository(EventStoreClient eventStore, IMessageTy _eventStore = eventStore; _messageTypeMapper = messageTypeMapper; _unitOfWork = unitOfWork; - _inner = new EventStoreRepository(eventStore, unitOfWork, GeneralLedger.Factory, - identifier => identifier, messageTypeMapper); + _inner = new EventStoreRepository(eventStore, unitOfWork, GeneralLedger.Factory, messageTypeMapper); } public async ValueTask Get(CancellationToken cancellationToken = default) { @@ -44,6 +42,7 @@ public async ValueTask Get(CancellationToken cancellationToken = streamPosition = resolvedEvent.OriginalEvent.EventNumber; lastEventRead = true; } + var @event = JsonSerializer.Deserialize(resolvedEvent.OriginalEvent.Data.Span, _messageTypeMapper.Map(resolvedEvent.OriginalEvent.EventType), TransactoSerializerOptions.Events); diff --git a/src/Transacto/Infrastructure/SqlStreamStoreDocumentRepository.cs b/src/Transacto/Infrastructure/SqlStreamStoreDocumentRepository.cs index 87b222a..9469040 100644 --- a/src/Transacto/Infrastructure/SqlStreamStoreDocumentRepository.cs +++ b/src/Transacto/Infrastructure/SqlStreamStoreDocumentRepository.cs @@ -8,13 +8,13 @@ using Transacto.Framework; namespace Transacto.Infrastructure { - public class SqlStreamStoreBusinessTransactionRepository + public class StreamStoreBusinessTransactionRepository where TBusinessTransaction : IBusinessTransaction { private readonly IStreamStore _streamStore; private readonly Func _getStreamName; private readonly JsonSerializerOptions _serializerOptions; - public SqlStreamStoreBusinessTransactionRepository( + public StreamStoreBusinessTransactionRepository( IStreamStore streamStore, Func getStreamName, JsonSerializerOptions serializerOptions) { diff --git a/src/Transacto/Messages/AccountDefined.cs b/src/Transacto/Messages/AccountDefined.cs index 23bef0c..9842ddc 100644 --- a/src/Transacto/Messages/AccountDefined.cs +++ b/src/Transacto/Messages/AccountDefined.cs @@ -2,5 +2,7 @@ namespace Transacto.Messages { public class AccountDefined { public string AccountName { get; set; } = null!; public int AccountNumber { get; set; } + + public override string ToString() => $"Account {AccountNumber} - {AccountName} was defined."; } } diff --git a/src/Transacto/Messages/BeginClosingAccountingPeriod.cs b/src/Transacto/Messages/BeginClosingAccountingPeriod.cs index ca06fd5..ddee682 100644 --- a/src/Transacto/Messages/BeginClosingAccountingPeriod.cs +++ b/src/Transacto/Messages/BeginClosingAccountingPeriod.cs @@ -2,7 +2,6 @@ namespace Transacto.Messages { public class BeginClosingAccountingPeriod { - public string Period { get; set; } = null!; public Guid[] GeneralLedgerEntryIds { get; set; } = Array.Empty(); public DateTimeOffset ClosingOn { get; set; } public int RetainedEarningsAccountNumber { get; set; } diff --git a/src/Transacto/Messages/GeneralLedgerOpened.cs b/src/Transacto/Messages/GeneralLedgerOpened.cs index 5e0555e..ae364ee 100644 --- a/src/Transacto/Messages/GeneralLedgerOpened.cs +++ b/src/Transacto/Messages/GeneralLedgerOpened.cs @@ -3,5 +3,6 @@ namespace Transacto.Messages { public class GeneralLedgerOpened { public DateTimeOffset OpenedOn { get; set; } + public override string ToString() => $"The general ledger was opened on {OpenedOn:O}."; } } diff --git a/src/Transacto/Messages/PostGeneralLedgerEntry.cs b/src/Transacto/Messages/PostGeneralLedgerEntry.cs index 3388ef4..7aa67ec 100644 --- a/src/Transacto/Messages/PostGeneralLedgerEntry.cs +++ b/src/Transacto/Messages/PostGeneralLedgerEntry.cs @@ -2,10 +2,13 @@ using Transacto.Domain; namespace Transacto.Messages { - public class PostGeneralLedgerEntry { - public Guid GeneralLedgerEntryId { get; set; } - public string Period { get; set; } = null!; - public DateTimeOffset CreatedOn { get; set; } - public IBusinessTransaction? BusinessTransaction { get; set; } - } + public class PostGeneralLedgerEntry { + public Guid GeneralLedgerEntryId { get; set; } + public string Period { get; set; } = null!; + public DateTimeOffset CreatedOn { get; set; } + public IBusinessTransaction? BusinessTransaction { get; set; } + + public override string ToString() => + $"Posting general ledger entry {BusinessTransaction?.ReferenceNumber} in period {Period} on {CreatedOn:O}."; + } } diff --git a/src/Transacto/Modules/ChartOfAccountsModule.cs b/src/Transacto/Modules/ChartOfAccountsModule.cs index 0e7a54e..d862cd3 100644 --- a/src/Transacto/Modules/ChartOfAccountsModule.cs +++ b/src/Transacto/Modules/ChartOfAccountsModule.cs @@ -1,7 +1,7 @@ -using System.Text.Json; using EventStore.Client; using Transacto.Application; using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Infrastructure; using Transacto.Messages; using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; @@ -13,45 +13,53 @@ public ChartOfAccountsModule(EventStoreClient eventStore, IMessageTypeMapper mes Build() .Log() .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { + .Handle(async (_, ct) => { var (unitOfWork, command) = _; var handlers = new ChartOfAccountsHandlers( new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); + await handlers.Handle(command, ct); + + return Position.Start; }); Build() .Log() .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { + .Handle(async (_, ct) => { var (unitOfWork, command) = _; var handlers = new ChartOfAccountsHandlers( new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); + await handlers.Handle(command, ct); + + return Position.Start; }); Build() .Log() .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { + .Handle(async (_, ct) => { var (unitOfWork, command) = _; var handlers = new ChartOfAccountsHandlers( new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); + await handlers.Handle(command, ct); + + return Position.Start; }); Build() .Log() .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { + .Handle(async (_, ct) => { var (unitOfWork, command) = _; var handlers = new ChartOfAccountsHandlers( new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); + await handlers.Handle(command, ct); + + return Position.Start; }); } } diff --git a/src/Transacto/Modules/GeneralLedgerEntryModule.cs b/src/Transacto/Modules/GeneralLedgerEntryModule.cs index 0065a9b..84b9ebd 100644 --- a/src/Transacto/Modules/GeneralLedgerEntryModule.cs +++ b/src/Transacto/Modules/GeneralLedgerEntryModule.cs @@ -1,6 +1,7 @@ using EventStore.Client; using Transacto.Application; using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Infrastructure; using Transacto.Messages; using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; @@ -12,14 +13,16 @@ public GeneralLedgerEntryModule(EventStoreClient eventStore, IMessageTypeMapper Build() .Log() .UnitOfWork(eventStore, messageTypeMapper, eventSerializerOptions) - .Handle((_, ct) => { + .Handle(async (_, ct) => { var (unitOfWork, command) = _; var handlers = new GeneralLedgerEntryHandlers( new GeneralLedgerEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), new GeneralLedgerEntryEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); + await handlers.Handle(command, ct); + + return Position.Start; }); } } diff --git a/src/Transacto/Modules/GeneralLedgerModule.cs b/src/Transacto/Modules/GeneralLedgerModule.cs index f1196f3..d6d7065 100644 --- a/src/Transacto/Modules/GeneralLedgerModule.cs +++ b/src/Transacto/Modules/GeneralLedgerModule.cs @@ -2,36 +2,56 @@ using EventStore.Client; using Transacto.Application; using Transacto.Framework; +using Transacto.Framework.CommandHandling; using Transacto.Infrastructure; using Transacto.Messages; -using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; namespace Transacto.Modules { - public class GeneralLedgerModule : CommandHandlerModule { - public GeneralLedgerModule(EventStoreClient eventStore, - IMessageTypeMapper messageTypeMapper, JsonSerializerOptions serializerOptions) { - Build() - .Log() - .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { - var (unitOfWork, command) = _; - var handlers = - new GeneralLedgerHandlers( - new GeneralLedgerEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); + public class GeneralLedgerModule : CommandHandlerModule { + public GeneralLedgerModule(EventStoreClient eventStore, + IMessageTypeMapper messageTypeMapper, JsonSerializerOptions serializerOptions) { + Build() + .Log() + .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) + .Handle(async (_, ct) => { + var (unitOfWork, command) = _; + var handlers = + new GeneralLedgerHandlers( + new GeneralLedgerEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), + new GeneralLedgerEntryEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), + new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); - }); - Build() - .Log() - .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) - .Handle((_, ct) => { - var (unitOfWork, command) = _; - var handlers = - new GeneralLedgerHandlers( - new GeneralLedgerEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); + await handlers.Handle(command, ct); + return Position.Start; + }); + Build() + .Log() + .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) + .Handle(async (_, ct) => { + var (unitOfWork, command) = _; + var handlers = + new GeneralLedgerHandlers( + new GeneralLedgerEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), + new GeneralLedgerEntryEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), + new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); - return handlers.Handle(command, ct); - }); - } - } + await handlers.Handle(command, ct); + return Position.Start; + }); + Build() + .Log() + .UnitOfWork(eventStore, messageTypeMapper, serializerOptions) + .Handle(async (_, ct) => { + var (unitOfWork, command) = _; + var handlers = + new GeneralLedgerHandlers( + new GeneralLedgerEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), + new GeneralLedgerEntryEventStoreRepository(eventStore, messageTypeMapper, unitOfWork), + new ChartOfAccountsEventStoreRepository(eventStore, messageTypeMapper, unitOfWork)); + + await handlers.Handle(command, ct); + return Position.Start; + }); + } + } } diff --git a/src/Transacto/Transacto.csproj b/src/Transacto/Transacto.csproj index fe28691..7cc9cda 100644 --- a/src/Transacto/Transacto.csproj +++ b/src/Transacto/Transacto.csproj @@ -6,11 +6,12 @@ enable true 8.0 + $(RestoreSources);https://api.nuget.org/v3/index.json;https://nuget.pkg.github.com/EventStore/index.json - - + + diff --git a/test/Transacto.Tests/Application/ChartOfAccountsTestRepository.cs b/test/Transacto.Tests/Application/ChartOfAccountsTestRepository.cs index 3423b1c..36f9b47 100644 --- a/test/Transacto.Tests/Application/ChartOfAccountsTestRepository.cs +++ b/test/Transacto.Tests/Application/ChartOfAccountsTestRepository.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Transacto.Domain; @@ -8,25 +7,14 @@ namespace Transacto.Application { internal class ChartOfAccountsTestRepository : IChartOfAccountsRepository { - private readonly IFactRecorder _factRecorder; + private readonly FactRecorderRepository _inner; public ChartOfAccountsTestRepository(IFactRecorder factRecorder) { - _factRecorder = factRecorder; + _inner = new FactRecorderRepository(factRecorder, ChartOfAccounts.Factory); } - public async ValueTask> GetOptional(CancellationToken cancellationToken = default) { - var facts = await _factRecorder.GetFacts().Where(x => x.Identifier == string.Empty) - .ToArrayAsync(cancellationToken); - - if (facts.Length == 0) { - return Optional.Empty; - } - - var chartOfAccounts = ChartOfAccounts.Factory(); - await chartOfAccounts.LoadFromHistory(facts.Select(x => x.Event).ToAsyncEnumerable()); - _factRecorder.Record(string.Empty, chartOfAccounts); - return chartOfAccounts; - } + public ValueTask> GetOptional(CancellationToken cancellationToken = default) + => _inner.GetOptional(ChartOfAccounts.Identifier, cancellationToken); public async ValueTask Get(CancellationToken cancellationToken = default) { var optional = await GetOptional(cancellationToken); @@ -37,7 +25,6 @@ public async ValueTask Get(CancellationToken cancellationToken return optional.Value; } - public void Add(ChartOfAccounts chartOfAccounts) => - _factRecorder.Record(string.Empty, chartOfAccounts.GetChanges()); + public void Add(ChartOfAccounts chartOfAccounts) => _inner.Add(chartOfAccounts); } } diff --git a/test/Transacto.Tests/Application/ChartOfAccountsTests.cs b/test/Transacto.Tests/Application/ChartOfAccountsTests.cs index bdc5c29..9ecc227 100644 --- a/test/Transacto.Tests/Application/ChartOfAccountsTests.cs +++ b/test/Transacto.Tests/Application/ChartOfAccountsTests.cs @@ -23,7 +23,7 @@ public Task defining_an_account(AccountName accountName, AccountNumber accountNu AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) - .Then(string.Empty, new AccountDefined { + .Then("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -33,7 +33,7 @@ public Task defining_an_account(AccountName accountName, AccountNumber accountNu public Task defining_a_second_account(AccountName accountName, AccountNumber accountNumber, AccountName secondAccountName, AccountNumber secondAccountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -41,7 +41,7 @@ public Task defining_a_second_account(AccountName accountName, AccountNumber acc AccountName = secondAccountName.ToString(), AccountNumber = secondAccountNumber.ToInt32() }) - .Then(string.Empty, new AccountDefined { + .Then("chartOfAccounts", new AccountDefined { AccountName = secondAccountName.ToString(), AccountNumber = secondAccountNumber.ToInt32() }) @@ -50,7 +50,7 @@ public Task defining_a_second_account(AccountName accountName, AccountNumber acc [Theory, AutoTransactoData] public Task defining_the_same_account_throws(AccountName accountName, AccountNumber accountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -58,14 +58,14 @@ public Task defining_the_same_account_throws(AccountName accountName, AccountNum AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) - .Throws(new InvalidOperationException()) + .Throws(new AccountExistsException(accountNumber)) .Assert(_handler, _facts); [Theory, AutoTransactoData] public Task renaming_an_account(AccountName accountName, AccountNumber accountNumber, AccountName secondAccountName) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -73,7 +73,7 @@ public Task renaming_an_account(AccountName accountName, AccountNumber accountNu NewAccountName = secondAccountName.ToString(), AccountNumber = accountNumber.ToInt32() }) - .Then(string.Empty, new AccountRenamed { + .Then("chartOfAccounts", new AccountRenamed { NewAccountName = secondAccountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -82,14 +82,14 @@ public Task renaming_an_account(AccountName accountName, AccountNumber accountNu [Theory, AutoTransactoData] public Task deactivating_an_account(AccountName accountName, AccountNumber accountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) .When(new DeactivateAccount { AccountNumber = accountNumber.ToInt32() }) - .Then(string.Empty, new AccountDeactivated { + .Then("chartOfAccounts", new AccountDeactivated { AccountNumber = accountNumber.ToInt32() }) .Assert(_handler, _facts); @@ -97,7 +97,7 @@ public Task deactivating_an_account(AccountName accountName, AccountNumber accou [Theory, AutoTransactoData] public Task reactivating_a_deactivated_account(AccountName accountName, AccountNumber accountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }, new AccountDeactivated { @@ -106,7 +106,7 @@ public Task reactivating_a_deactivated_account(AccountName accountName, AccountN .When(new ReactivateAccount { AccountNumber = accountNumber.ToInt32() }) - .Then(string.Empty, new AccountReactivated { + .Then("chartOfAccounts", new AccountReactivated { AccountNumber = accountNumber.ToInt32() }) .Assert(_handler, _facts); @@ -114,7 +114,7 @@ public Task reactivating_a_deactivated_account(AccountName accountName, AccountN [Theory, AutoTransactoData] public Task reactivating_an_active_account(AccountName accountName, AccountNumber accountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -127,7 +127,7 @@ public Task reactivating_an_active_account(AccountName accountName, AccountNumbe [Theory, AutoTransactoData] public Task deactivating_a_deactivated_account(AccountName accountName, AccountNumber accountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }, new AccountDeactivated { @@ -156,7 +156,7 @@ public Task renaming_an_account_when_it_was_not_defined_throws(AccountName accou AccountNumber accountNumber, AccountName secondAccountName, AccountNumber secondAccountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) @@ -164,7 +164,7 @@ public Task renaming_an_account_when_it_was_not_defined_throws(AccountName accou NewAccountName = secondAccountName.ToString(), AccountNumber = secondAccountNumber.ToInt32() }) - .Throws(new InvalidOperationException()) + .Throws(new AccountNotFoundException(secondAccountNumber)) .Assert(_handler, _facts); [Theory, AutoTransactoData] @@ -181,14 +181,14 @@ public Task deactivating_an_account_when_no_account_defined_throws(AccountNumber public Task deactivating_an_account_when_it_was_not_defined_throws(AccountName accountName, AccountNumber accountNumber, AccountNumber secondAccountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) .When(new DeactivateAccount { AccountNumber = secondAccountNumber.ToInt32() }) - .Throws(new InvalidOperationException()) + .Throws(new AccountNotFoundException(secondAccountNumber)) .Assert(_handler, _facts); [Theory, AutoTransactoData] @@ -205,14 +205,14 @@ public Task reactivating_an_account_when_no_account_defined_throws(AccountNumber public Task reactivating_an_account_when_it_was_not_defined_throws(AccountName accountName, AccountNumber accountNumber, AccountNumber secondAccountNumber) => new Scenario() - .Given(string.Empty, new AccountDefined { + .Given("chartOfAccounts", new AccountDefined { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }) .When(new ReactivateAccount { AccountNumber = secondAccountNumber.ToInt32() }) - .Throws(new InvalidOperationException()) + .Throws(new AccountNotFoundException(secondAccountNumber)) .Assert(_handler, _facts); } } diff --git a/test/Transacto.Tests/Application/FactRecorderRepository.cs b/test/Transacto.Tests/Application/FactRecorderRepository.cs new file mode 100644 index 0000000..b3fd994 --- /dev/null +++ b/test/Transacto.Tests/Application/FactRecorderRepository.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Transacto.Framework; +using Transacto.Testing; + +namespace Transacto.Application { + internal class FactRecorderRepository where T : AggregateRoot { + private readonly IFactRecorder _facts; + private readonly Func _factory; + + public FactRecorderRepository(IFactRecorder facts, Func factory) { + _facts = facts; + _factory = factory; + } + + public async ValueTask> GetOptional(string identifier, + CancellationToken cancellationToken = default) { + var facts = await _facts.GetFacts().Where(x => x.Identifier == identifier) + .ToArrayAsync(cancellationToken); + + if (facts.Length == 0) { + return Optional.Empty; + } + + var aggregateRoot = _factory(); + aggregateRoot.LoadFromHistory(facts.Select(x => x.Event)); + _facts.Attach(aggregateRoot.Id, aggregateRoot); + return aggregateRoot; + } + + public void Add(T aggregateRoot) => _facts.Record(aggregateRoot.Id, aggregateRoot.GetChanges()); + } +} diff --git a/test/Transacto.Tests/Application/GeneralLedgerEntryTestRepository.cs b/test/Transacto.Tests/Application/GeneralLedgerEntryTestRepository.cs new file mode 100644 index 0000000..bd90642 --- /dev/null +++ b/test/Transacto.Tests/Application/GeneralLedgerEntryTestRepository.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Transacto.Domain; +using Transacto.Testing; + +namespace Transacto.Application { + internal class GeneralLedgerEntryTestRepository : IGeneralLedgerEntryRepository { + private readonly FactRecorderRepository _inner; + + public GeneralLedgerEntryTestRepository(IFactRecorder facts) { + _inner = new FactRecorderRepository(facts, GeneralLedgerEntry.Factory); + } + + public async ValueTask Get(GeneralLedgerEntryIdentifier identifier, + CancellationToken cancellationToken = default) { + var optional = await _inner.GetOptional(GeneralLedgerEntry.FormatStreamIdentifier(identifier), + cancellationToken); + return optional.HasValue ? optional.Value : throw new InvalidOperationException(); + } + + public void Add(GeneralLedgerEntry generalLedgerEntry) => _inner.Add(generalLedgerEntry); + } +} diff --git a/test/Transacto.Tests/Application/GeneralLedgerTestRepository.cs b/test/Transacto.Tests/Application/GeneralLedgerTestRepository.cs index fa6ee72..826c372 100644 --- a/test/Transacto.Tests/Application/GeneralLedgerTestRepository.cs +++ b/test/Transacto.Tests/Application/GeneralLedgerTestRepository.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Transacto.Domain; @@ -7,28 +6,21 @@ namespace Transacto.Application { internal class GeneralLedgerTestRepository : IGeneralLedgerRepository { - private readonly IFactRecorder _factRecorder; + private readonly FactRecorderRepository _inner; public GeneralLedgerTestRepository(IFactRecorder factRecorder) { - _factRecorder = factRecorder; + _inner = new FactRecorderRepository(factRecorder, GeneralLedger.Factory); } public async ValueTask Get(CancellationToken cancellationToken = default) { - var facts = await _factRecorder.GetFacts().Where(x => x.Identifier == GeneralLedger.Identifier) - .ToArrayAsync(cancellationToken); - - if (facts.Length == 0) { + var optional = await _inner.GetOptional(GeneralLedger.Identifier, cancellationToken); + if (!optional.HasValue) { throw new InvalidOperationException(); } - var generalLedger = GeneralLedger.Factory(); - await generalLedger.LoadFromHistory(facts.Select(x => x.Event).ToAsyncEnumerable()); - _factRecorder.Record(generalLedger.Id, generalLedger); - return generalLedger; - + return optional.Value; } - public void Add(GeneralLedger generalLedger) => - _factRecorder.Record(generalLedger.Id, generalLedger.GetChanges()); + public void Add(GeneralLedger generalLedger) => _inner.Add(generalLedger); } } diff --git a/test/Transacto.Tests/Application/GeneralLedgerTests.cs b/test/Transacto.Tests/Application/GeneralLedgerTests.cs index d1205ff..d8d98e6 100644 --- a/test/Transacto.Tests/Application/GeneralLedgerTests.cs +++ b/test/Transacto.Tests/Application/GeneralLedgerTests.cs @@ -1,18 +1,88 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; using Transacto.Domain; using Transacto.Messages; using Transacto.Testing; using Xunit; +using Xunit.Abstractions; namespace Transacto.Application { + public class GeneralLedgerEntryTests { + private readonly GeneralLedgerEntryHandlers _handler; + private readonly IFactRecorder _facts; + private readonly TestSpecificationTextWriter _writer; + + public GeneralLedgerEntryTests(ITestOutputHelper output) { + _writer = new TestSpecificationTextWriter(new TestOutputHelperTextWriter(output)); + _facts = new FactRecorder(); + _handler = new GeneralLedgerEntryHandlers(new GeneralLedgerTestRepository(_facts), + new GeneralLedgerEntryTestRepository(_facts), + new ChartOfAccountsTestRepository(_facts)); + } + + [Theory, AutoTransactoData] + public Task entry_not_in_balance_throws(GeneralLedgerEntryIdentifier generalLedgerEntryIdentifier, + int sequenceNumber, DateTimeOffset openedOn, AccountName accountName, AccountNumber accountNumber) { + var scenario = new Scenario() + .Given("generalLedger", + new GeneralLedgerOpened { + OpenedOn = openedOn + }) + .Given("chartOfAccounts", + new AccountDefined { + AccountName = accountName.ToString(), + AccountNumber = accountNumber.ToInt32() + }) + .When(new PostGeneralLedgerEntry { + Period = Period.Open(openedOn).ToString(), + BusinessTransaction = new BadTransaction { + Account = accountNumber, + ReferenceNumber = sequenceNumber + }, + CreatedOn = openedOn, + GeneralLedgerEntryId = generalLedgerEntryIdentifier.ToGuid() + }) + + .Throws(new GeneralLedgerEntryNotInBalanceException(generalLedgerEntryIdentifier)); + _writer.Write(scenario.Build()); + return scenario.Assert(_handler, _facts); + } + + private class BadTransaction : IBusinessTransaction { + GeneralLedgerEntryNumber IBusinessTransaction.ReferenceNumber => + new GeneralLedgerEntryNumber("BAD", ReferenceNumber); + + public int ReferenceNumber { get; set; } + + public AccountNumber Account { get; set; } + + public void Apply(GeneralLedgerEntry generalLedgerEntry, ChartOfAccounts chartOfAccounts) { + generalLedgerEntry.ApplyCredit(new Credit(Account, new Money(1m)), chartOfAccounts); + } + + public IEnumerable GetAdditionalChanges() { + yield break; + } + + public int? Version { get; set; } + } + } + public class GeneralLedgerTests { private readonly GeneralLedgerHandlers _handler; private readonly IFactRecorder _facts; + private readonly AccountNumber _retainedEarnings; public GeneralLedgerTests() { _facts = new FactRecorder(); - _handler = new GeneralLedgerHandlers(new GeneralLedgerTestRepository(_facts)); + _handler = new GeneralLedgerHandlers( + new GeneralLedgerTestRepository(_facts), + new GeneralLedgerEntryTestRepository(_facts), + new ChartOfAccountsTestRepository(_facts)); + _retainedEarnings = new AccountNumber(new Random().Next(3000, 3999)); } [Theory, AutoTransactoData] @@ -22,42 +92,167 @@ public Task opening_the_period(DateTimeOffset openedOn) => .When(new OpenGeneralLedger { OpenedOn = openedOn }) - .Then(GeneralLedger.Identifier, new GeneralLedgerOpened { + .Then("generalLedger", new GeneralLedgerOpened { OpenedOn = openedOn }) .Assert(_handler, _facts); [Theory, AutoTransactoData] public Task closing_an_open_period(Period period, - GeneralLedgerEntryIdentifier[] generalLedgerEntryIdentifiers) => + GeneralLedgerEntryIdentifier[] generalLedgerEntryIdentifiers, + GeneralLedgerEntryIdentifier closingGeneralLedgerEntryIdentifier) => new Scenario() - .Given(GeneralLedger.Identifier, new GeneralLedgerOpened { + .Given("generalLedger", new GeneralLedgerOpened { OpenedOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)) }) .When(new BeginClosingAccountingPeriod { - Period = period.ToString(), GeneralLedgerEntryIds = Array.ConvertAll(generalLedgerEntryIdentifiers, x => x.ToGuid()), - ClosingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)) + ClosingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)), + RetainedEarningsAccountNumber = _retainedEarnings.ToInt32(), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() }) - .Then(GeneralLedger.Identifier, new AccountingPeriodClosing { + .Then("generalLedger", new AccountingPeriodClosing { Period = period.ToString(), GeneralLedgerEntryIds = Array.ConvertAll(generalLedgerEntryIdentifiers, x => x.ToGuid()), - ClosingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)) + ClosingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)), + RetainedEarningsAccountNumber = _retainedEarnings.ToInt32(), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() + }) + .Assert(_handler, _facts); + + [Theory, AutoTransactoData] + public Task closing_a_closed_period(Period period, + GeneralLedgerEntryIdentifier closingGeneralLedgerEntryIdentifier) => + new Scenario() + .Given("generalLedger", new GeneralLedgerOpened { + OpenedOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 1)) + }, + new AccountingPeriodClosing { + Period = period.ToString(), + ClosingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)), + RetainedEarningsAccountNumber = _retainedEarnings.ToInt32(), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() + }) + .When(new BeginClosingAccountingPeriod { + ClosingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)), + RetainedEarningsAccountNumber = _retainedEarnings.ToInt32(), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() }) + .Throws(new PeriodClosingInProcessException(period)) .Assert(_handler, _facts); [Theory, AutoTransactoData] - public Task closing_a_closed_period(Period period) => + public Task closing_the_period_before_the_period_has_started(DateTimeOffset openedOn, + GeneralLedgerEntryIdentifier closingGeneralLedgerEntryIdentifier) => new Scenario() - .Given(GeneralLedger.Identifier, new GeneralLedgerOpened { - OpenedOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 1)) - }, new BeginClosingAccountingPeriod { - Period = period.ToString() + .Given("generalLedger", new GeneralLedgerOpened { + OpenedOn = openedOn }) .When(new BeginClosingAccountingPeriod { - Period = period.ToString() + ClosingOn = openedOn.AddMonths(-1), + RetainedEarningsAccountNumber = _retainedEarnings.ToInt32(), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() }) - .ThenNone() + .Throws(new ClosingDateBeforePeriodException(Period.Open(openedOn), openedOn.AddMonths(-1))) + .Assert(_handler, _facts); + + [Theory, AutoTransactoData] + public Task period_closing_started(DateTimeOffset openedOn, + GeneralLedgerEntryIdentifier[] generalLedgerEntryIdentifiers, + GeneralLedgerEntryIdentifier closingGeneralLedgerEntryIdentifier, + Money amount) { + var period = Period.Open(openedOn); + var cashAccountNumber = new AccountNumber(new Random().Next(1000, 1999)); + var incomeAccountNumber = new AccountNumber(new Random().Next(4000, 4999)); + + var closingOn = new DateTimeOffset(new DateTime(period.Year, period.Month, 2)); + + var accountingPeriodClosing = new AccountingPeriodClosing { + Period = period.ToString(), + ClosingOn = closingOn, + RetainedEarningsAccountNumber = _retainedEarnings.ToInt32(), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid(), + GeneralLedgerEntryIds = + Array.ConvertAll(generalLedgerEntryIdentifiers, identifier => identifier.ToGuid()) + }; + var generalLedgerEntryFacts = generalLedgerEntryIdentifiers.SelectMany( + (identifier, index) => Array.ConvertAll(new object[] { + new GeneralLedgerEntryCreated { + Number = $"sale-{index}", + Period = period.ToString(), + CreatedOn = openedOn, + GeneralLedgerEntryId = identifier.ToGuid() + }, + new DebitApplied { + Amount = amount.ToDecimal(), + AccountNumber = cashAccountNumber.ToInt32(), + GeneralLedgerEntryId = identifier.ToGuid() + }, + new CreditApplied { + Amount = amount.ToDecimal(), + AccountNumber = incomeAccountNumber.ToInt32(), + GeneralLedgerEntryId = identifier.ToGuid() + }, + new GeneralLedgerEntryPosted { + Period = period.ToString(), + GeneralLedgerEntryId = identifier.ToGuid() + }, + }, e => new Fact($"generalLedgerEntry-{identifier}", e))) + .ToArray(); + return new Scenario() + .Given("chartOfAccounts", + new AccountDefined { + AccountName = "Cash on Hand", + AccountNumber = cashAccountNumber.ToInt32() + }, + new AccountDefined { + AccountName = "Income", + AccountNumber = incomeAccountNumber.ToInt32() + }, + new AccountDefined { + AccountName = "Retained Earnings", + AccountNumber = _retainedEarnings.ToInt32() + }) + .Given("generalLedger", + new GeneralLedgerOpened { + OpenedOn = openedOn + }, + accountingPeriodClosing) + .Given(generalLedgerEntryFacts) + .When(accountingPeriodClosing) + .Then("generalLedger", + new GeneralLedgerEntryCreated { + CreatedOn = closingOn, + GeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid(), + Number = $"jec-{period}", + Period = period.ToString() + }, + new DebitApplied { + Amount = amount.ToDecimal() * generalLedgerEntryIdentifiers.Length, + AccountNumber = incomeAccountNumber.ToInt32(), + GeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() + }, + new CreditApplied { + Amount = amount.ToDecimal() * generalLedgerEntryIdentifiers.Length, + AccountNumber = _retainedEarnings.ToInt32(), + GeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() + }, + new GeneralLedgerEntryPosted { + Period = period.ToString(), + GeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid() + }, + new AccountingPeriodClosed { + Period = period.ToString(), + GeneralLedgerEntryIds = Array.ConvertAll(generalLedgerEntryIdentifiers, + identifier => identifier.ToGuid()), + ClosingGeneralLedgerEntryId = closingGeneralLedgerEntryIdentifier.ToGuid(), + Balance = new Dictionary { + [cashAccountNumber.ToInt32()] = amount.ToDecimal() * generalLedgerEntryIdentifiers.Length, + [incomeAccountNumber.ToInt32()] = Money.Zero.ToDecimal(), + [_retainedEarnings.ToInt32()] = -(amount.ToDecimal() * generalLedgerEntryIdentifiers.Length) + } + }) .Assert(_handler, _facts); + } } } diff --git a/test/Transacto.Tests/Domain/AccountNameTests.cs b/test/Transacto.Tests/Domain/AccountNameTests.cs new file mode 100644 index 0000000..847ed23 --- /dev/null +++ b/test/Transacto.Tests/Domain/AccountNameTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace Transacto.Domain { + public class AccountNameTests { + [Theory, AutoTransactoData] + public void Equality(AccountName sut) { + var copy = new AccountName(sut.ToString()); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(AccountName sut) { + var copy = new AccountName(sut.ToString()); + + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(AccountName sut, AccountName other) { + Assert.True(sut != other); + } + + public static IEnumerable InvalidAccountNameCases() { + yield return new object[]{string.Empty}; + yield return new object[]{new string('a', AccountName.MaxLength + 1)}; + } + + [Theory, MemberData(nameof(InvalidAccountNameCases))] + public void InvalidAccountNameThrows(string value) { + var ex = Assert.Throws(() => new AccountName(value)); + Assert.Equal("value", ex.ParamName); + } + + [Theory, AutoTransactoData] + public void ToStringReturnsExpectedResult(string expected) { + var sut = new AccountName(expected); + var actual = sut.ToString(); + Assert.Equal(expected, actual); + } + } +} diff --git a/test/Transacto.Tests/Domain/AccountNumberTests.cs b/test/Transacto.Tests/Domain/AccountNumberTests.cs new file mode 100644 index 0000000..70555a1 --- /dev/null +++ b/test/Transacto.Tests/Domain/AccountNumberTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace Transacto.Domain { + public class AccountNumberTests { + [Theory, AutoTransactoData] + public void Equality(AccountNumber sut) { + var copy = new AccountNumber(sut.ToInt32()); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(AccountNumber sut) { + var copy = new AccountNumber(sut.ToInt32()); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(AccountNumber sut, AccountNumber other) { + Assert.False(sut == other); + } + + public static IEnumerable InvalidAccountNumberCases() { + yield return new object[] {-1}; + yield return new object[] {999}; + yield return new object[] {9000}; + yield return new object[] {int.MaxValue}; + } + + [Theory, MemberData(nameof(InvalidAccountNumberCases))] + public void InvalidAccountNumberThrows(int value) { + var ex = Assert.Throws(() => new AccountNumber(value)); + Assert.Equal("value", ex.ParamName); + } + + [Theory, AutoTransactoData] + public void ToInt32ReturnsExpectedResult(int value) { + var expected = Math.Max(1000, value % 8999); + var sut = new AccountNumber(expected); + Assert.Equal(expected, sut.ToInt32()); + } + + [Theory, AutoTransactoData] + public void ToStringReturnsExpectedResult(int value) { + var expected = Math.Max(1000, value % 8999); + var sut = new AccountNumber(expected); + Assert.Equal(expected.ToString(), sut.ToString()); + } + } +} diff --git a/test/Transacto.Tests/Domain/AccountTypeTests.cs b/test/Transacto.Tests/Domain/AccountTypeTests.cs new file mode 100644 index 0000000..aa26b19 --- /dev/null +++ b/test/Transacto.Tests/Domain/AccountTypeTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Transacto.Domain { + public class AccountTypeTests { + [Theory, AutoTransactoData] + public void Equality(AccountNumber accountNumber) { + Assert.Equal(AccountType.OfAccountNumber(accountNumber), AccountType.OfAccountNumber(accountNumber)); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(AccountNumber accountNumber) { + Assert.True(AccountType.OfAccountNumber(accountNumber) == AccountType.OfAccountNumber(accountNumber)); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(AccountNumber accountNumber) { + Assert.False(AccountType.OfAccountNumber(accountNumber) != AccountType.OfAccountNumber(accountNumber)); + } + + public static IEnumerable AppearsOnBalanceSheetCases() { + yield return new object[] {AccountType.Asset, true}; + yield return new object[] {AccountType.Liability, true}; + yield return new object[] {AccountType.Equity, true}; + yield return new object[] {AccountType.Income, false}; + yield return new object[] {AccountType.CostOfGoodsSold, false}; + yield return new object[] {AccountType.Expenses, false}; + yield return new object[] {AccountType.OtherIncome, false}; + yield return new object[] {AccountType.OtherExpenses, false}; + } + + [Theory, MemberData(nameof(AppearsOnBalanceSheetCases))] + public void AppearsOnBalanceSheet(AccountType sut, bool appearsOnBalanceSheet) { + Assert.Equal(appearsOnBalanceSheet, sut.AppearsOnBalanceSheet); + } + + public static IEnumerable AppearsOnProfitAndLossCases() { + yield return new object[] {AccountType.Asset, false}; + yield return new object[] {AccountType.Liability, false}; + yield return new object[] {AccountType.Equity, false}; + yield return new object[] {AccountType.Income, true}; + yield return new object[] {AccountType.CostOfGoodsSold, true}; + yield return new object[] {AccountType.Expenses, true}; + yield return new object[] {AccountType.OtherIncome, true}; + yield return new object[] {AccountType.OtherExpenses, true}; + } + + [Theory, MemberData(nameof(AppearsOnProfitAndLossCases))] + public void AppearsOnProfitAndLoss(AccountType sut, bool appearsOnProfitAndLoss) { + Assert.Equal(appearsOnProfitAndLoss, sut.AppearsOnProfitAndLoss); + } + + [Theory, AutoTransactoData] + public void ToStringReturnsExpectedResult(AccountType accountType) { + var expected = accountType.GetType().Name; + Assert.Equal(expected, accountType.Name); + } + + [Theory, AutoTransactoData] + public void MustBe(AccountType accountType) { + accountType.MustBe(accountType); + } + + [Theory, AutoTransactoData] + public void MustBeDoesNotMatchThrows(Random random) { + var index = random.Next(0, AccountType.All.Count); + var sut = AccountType.All[index]; + var other = AccountType.All[(index + 1) % AccountType.All.Count]; + var ex = Assert.Throws(() => sut.MustBe(other)); + Assert.Equal(other, ex.Expected); + Assert.Equal(sut, ex.Actual); + Assert.Equal($"Expected an account type of '{ex.Expected.Name}', received '{ex.Actual.Name}'.", ex.Message); + } + } +} diff --git a/test/Transacto.Tests/Domain/CreditTests.cs b/test/Transacto.Tests/Domain/CreditTests.cs new file mode 100644 index 0000000..5601df6 --- /dev/null +++ b/test/Transacto.Tests/Domain/CreditTests.cs @@ -0,0 +1,83 @@ +using System; +using Xunit; + +namespace Transacto.Domain { + public class CreditTests { + [Theory, AutoTransactoData] + public void Equality(AccountNumber accountNumber, Money amount) { + var sut = new Credit(accountNumber, amount); + var copy = new Credit(accountNumber, amount); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(AccountNumber accountNumber, Money amount) { + var sut = new Credit(accountNumber, amount); + var copy = new Credit(accountNumber, amount); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(Credit left, Credit right) { + Assert.False(left == right); + } + + [Theory, AutoTransactoData] + public void MoneyLessThanZeroThrows(AccountNumber accountNumber, decimal value) { + var amount = new Money(-Math.Abs(value)); + + Assert.Throws(() => new Credit(accountNumber, amount)); + } + + [Theory, AutoTransactoData] + public void ZeroMoney(AccountNumber accountNumber) { + var sut = new Credit(accountNumber); + Assert.Equal(new Credit(accountNumber, Money.Zero), sut); + } + + [Theory, AutoTransactoData] + public void AppearsOnBalanceSheet(AccountNumber accountNumber, Money amount) { + var accountType = AccountType.OfAccountNumber(accountNumber); + var sut = new Credit(accountNumber, amount); + Assert.Equal(accountType.AppearsOnBalanceSheet, sut.AppearsOnBalanceSheet); + } + + [Theory, AutoTransactoData] + public void AppearsOnProfitAndLoss(AccountNumber accountNumber, Money amount) { + var accountType = AccountType.OfAccountNumber(accountNumber); + var sut = new Credit(accountNumber, amount); + Assert.Equal(accountType.AppearsOnProfitAndLoss, sut.AppearsOnProfitAndLoss); + } + + [Theory, AutoTransactoData] + public void MoneyAdditionOperator(Credit sut, Money amount) { + var result = sut + amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount + amount, result.Amount); + } + + [Theory, AutoTransactoData] + public void MoneySubtractionOperator(Credit sut) { + var amount = new Money(sut.Amount.ToDecimal() / 2); + var result = sut - amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount - amount, result.Amount); + } + + + [Theory, AutoTransactoData] + public void DecimalAdditionOperator(Credit sut, decimal amount) { + var result = sut + amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount + amount, result.Amount); + } + + [Theory, AutoTransactoData] + public void DecimalSubtractionOperator(Credit sut) { + var amount = sut.Amount.ToDecimal() / 2; + var result = sut - amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount - amount, result.Amount); + } + } +} diff --git a/test/Transacto.Tests/Domain/DebitTests.cs b/test/Transacto.Tests/Domain/DebitTests.cs new file mode 100644 index 0000000..341aa31 --- /dev/null +++ b/test/Transacto.Tests/Domain/DebitTests.cs @@ -0,0 +1,83 @@ +using System; +using Xunit; + +namespace Transacto.Domain { + public class DebitTests { + [Theory, AutoTransactoData] + public void Equality(AccountNumber accountNumber, Money amount) { + var sut = new Debit(accountNumber, amount); + var copy = new Debit(accountNumber, amount); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(AccountNumber accountNumber, Money amount) { + var sut = new Debit(accountNumber, amount); + var copy = new Debit(accountNumber, amount); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(Debit left, Debit right) { + Assert.False(left == right); + } + + [Theory, AutoTransactoData] + public void MoneyLessThanZeroThrows(AccountNumber accountNumber, decimal value) { + var amount = new Money(-Math.Abs(value)); + + Assert.Throws(() => new Debit(accountNumber, amount)); + } + + [Theory, AutoTransactoData] + public void ZeroMoney(AccountNumber accountNumber) { + var sut = new Debit(accountNumber); + Assert.Equal(new Debit(accountNumber, Money.Zero), sut); + } + + [Theory, AutoTransactoData] + public void AppearsOnBalanceSheet(AccountNumber accountNumber, Money amount) { + var accountType = AccountType.OfAccountNumber(accountNumber); + var sut = new Debit(accountNumber, amount); + Assert.Equal(accountType.AppearsOnBalanceSheet, sut.AppearsOnBalanceSheet); + } + + [Theory, AutoTransactoData] + public void AppearsOnProfitAndLoss(AccountNumber accountNumber, Money amount) { + var accountType = AccountType.OfAccountNumber(accountNumber); + var sut = new Debit(accountNumber, amount); + Assert.Equal(accountType.AppearsOnProfitAndLoss, sut.AppearsOnProfitAndLoss); + } + + [Theory, AutoTransactoData] + public void MoneyAdditionOperator(Debit sut, Money amount) { + var result = sut + amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount + amount, result.Amount); + } + + [Theory, AutoTransactoData] + public void MoneySubtractionOperator(Debit sut) { + var amount = new Money(sut.Amount.ToDecimal() / 2); + var result = sut - amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount - amount, result.Amount); + } + + + [Theory, AutoTransactoData] + public void DecimalAdditionOperator(Debit sut, decimal amount) { + var result = sut + amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount + amount, result.Amount); + } + + [Theory, AutoTransactoData] + public void DecimalSubtractionOperator(Debit sut) { + var amount = sut.Amount.ToDecimal() / 2; + var result = sut - amount; + Assert.Equal(sut.AccountNumber, result.AccountNumber); + Assert.Equal(sut.Amount - amount, result.Amount); + } + } +} diff --git a/test/Transacto.Tests/Domain/GeneralLedgerEntryIdentifierTests.cs b/test/Transacto.Tests/Domain/GeneralLedgerEntryIdentifierTests.cs new file mode 100644 index 0000000..a8e41fd --- /dev/null +++ b/test/Transacto.Tests/Domain/GeneralLedgerEntryIdentifierTests.cs @@ -0,0 +1,42 @@ +using System; +using Xunit; + +namespace Transacto.Domain { + public class GeneralLedgerEntryIdentifierTests { + [Theory, AutoTransactoData] + public void Equality(Guid value) { + var sut = new GeneralLedgerEntryIdentifier(value); + var copy = new GeneralLedgerEntryIdentifier(value); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(Guid value) { + var sut = new GeneralLedgerEntryIdentifier(value); + var copy = new GeneralLedgerEntryIdentifier(value); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(GeneralLedgerEntryIdentifier left, GeneralLedgerEntryIdentifier right) { + Assert.False(left == right); + } + + [Fact] + public void EmptyValueThrows() { + Assert.Throws(() => new GeneralLedgerEntryIdentifier(Guid.Empty)); + } + + [Theory, AutoTransactoData] + public void ToGuidReturnsExpectedResult(Guid value) { + var sut = new GeneralLedgerEntryIdentifier(value); + Assert.Equal(value, sut.ToGuid()); + } + + [Theory, AutoTransactoData] + public void ToStringReturnsExpectedResult(Guid value) { + var sut = new GeneralLedgerEntryIdentifier(value); + Assert.Equal(value.ToString("n"), sut.ToString()); + } + } +} diff --git a/test/Transacto.Tests/Domain/GeneralLedgerEntryNumberTests.cs b/test/Transacto.Tests/Domain/GeneralLedgerEntryNumberTests.cs new file mode 100644 index 0000000..95256fc --- /dev/null +++ b/test/Transacto.Tests/Domain/GeneralLedgerEntryNumberTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using AutoFixture; +using Xunit; + +namespace Transacto.Domain { + public class GeneralLedgerEntryNumberTests { + [Theory, AutoTransactoData] + public void Equality(GeneralLedgerEntryNumber sut) { + var copy = new GeneralLedgerEntryNumber(sut.Prefix, sut.SequenceNumber); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(GeneralLedgerEntryNumber sut) { + var copy = new GeneralLedgerEntryNumber(sut.Prefix, sut.SequenceNumber); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(GeneralLedgerEntryNumber left, GeneralLedgerEntryNumber right) { + Assert.False(left == right); + } + + public static IEnumerable InvalidPrefixCases() { + var fixture = new ScenarioFixture(); + yield return new object[] {" ", fixture.Create()}; + yield return new object[] {string.Empty, fixture.Create()}; + yield return new object[] {" a", fixture.Create()}; + yield return new object[] {"a ", fixture.Create()}; + yield return new object[] {"a ", fixture.Create()}; + yield return new object[] + {new string('a', GeneralLedgerEntryNumber.MaxPrefixLength + 1), fixture.Create()}; + } + + [Theory, MemberData(nameof(InvalidPrefixCases))] + public void InvalidPrefix(string prefix, int sequenceNumber) { + var ex = Assert.Throws(() => new GeneralLedgerEntryNumber(prefix, sequenceNumber)); + Assert.Equal("prefix", ex.ParamName); + } + + [Theory, AutoTransactoData] + public void SequenceNumberLessThanZeroThrows(Random random, int sequenceNumber) { + var ex = Assert.Throws(() => new GeneralLedgerEntryNumber( + new string('a', random.Next(1, GeneralLedgerEntryNumber.MaxPrefixLength)), + -Math.Abs(sequenceNumber))); + Assert.Equal("sequenceNumber", ex.ParamName); + } + + [Theory, AutoTransactoData] + public void SequenceNumberZeroThrows(Random random) { + var ex = Assert.Throws(() => new GeneralLedgerEntryNumber( + new string('a', random.Next(1, GeneralLedgerEntryNumber.MaxPrefixLength)), 0)); + Assert.Equal("sequenceNumber", ex.ParamName); + } + + [Theory, AutoTransactoData] + public void ParseValidValueReturnsExpectedResult(GeneralLedgerEntryNumber number) { + var sut = GeneralLedgerEntryNumber.Parse(number.ToString()); + Assert.Equal(number, sut); + } + + [Theory, AutoTransactoData] + public void TryParseValidValueReturnsExpectedResult(GeneralLedgerEntryNumber number) { + Assert.True(GeneralLedgerEntryNumber.TryParse(number.ToString(), out var sut)); + Assert.Equal(number, sut); + } + + public static IEnumerable ParseInvalidValueTestCases() { + yield return new object[] {string.Empty}; + yield return new object[] {"a"}; + yield return new object[] {"a-"}; + yield return new object[] {"a--1"}; + yield return new object[] {" "}; + } + + [Theory, MemberData(nameof(ParseInvalidValueTestCases))] + public void ParseInvalidValueReturnsExpectedResult(string value) { + Assert.Throws(() => GeneralLedgerEntryNumber.Parse(value)); + } + + [Theory, MemberData(nameof(ParseInvalidValueTestCases))] + public void TryParseInvalidValueReturnsExpectedResult(string value) { + Assert.False(GeneralLedgerEntryNumber.TryParse(value, out var sut)); + Assert.Equal(default, sut); + } + } +} diff --git a/test/Transacto.Tests/Domain/MoneyTests.cs b/test/Transacto.Tests/Domain/MoneyTests.cs new file mode 100644 index 0000000..2ff5e1a --- /dev/null +++ b/test/Transacto.Tests/Domain/MoneyTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using Xunit; + +namespace Transacto.Domain { + public class MoneyTests { + [Theory, AutoTransactoData] + public void Equality(Money sut) { + var copy = new Money(sut.ToDecimal()); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(Money sut) { + var copy = new Money(sut.ToDecimal()); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(Money left, Money right) { + Assert.False(left == right); + } + + [Fact] + public void Zero() { + Assert.Equal(new Money(0m), Money.Zero); + } + + public static IEnumerable ComparisonCases() { + yield return new object[] {new Money(0m), new Money(0m), 0}; + yield return new object[] {new Money(-1m), new Money(0m), -1}; + yield return new object[] {new Money(1m), new Money(0m), 1}; + } + + [Theory, MemberData(nameof(ComparisonCases))] + public void ComparisonReturnsExpectedResult(Money left, Money right, int expected) { + Assert.Equal(left.CompareTo(right), expected); + } + + public static IEnumerable GreaterThanCases() { + yield return new object[] {1m, 1m, false}; + yield return new object[] {1m, 0m, true}; + yield return new object[] {1m, 2m, false}; + } + + [Theory, MemberData(nameof(GreaterThanCases))] + public void GreaterThanReturnsExpectedResult(decimal left, decimal right, bool gt) { + Assert.Equal(new Money(left) > new Money(right), gt); + } + + public static IEnumerable GreaterThanOrEqualCases() { + yield return new object[] {1m, 1m, true}; + yield return new object[] {1m, 0m, true}; + yield return new object[] {1m, 2m, false}; + } + + [Theory, MemberData(nameof(GreaterThanOrEqualCases))] + public void GreaterThanOrEqualThanReturnsExpectedResult(decimal left, decimal right, bool gte) { + Assert.Equal(new Money(left) >= new Money(right), gte); + } + + public static IEnumerable LessThanCases() { + yield return new object[] {1m, 1m, false}; + yield return new object[] {1m, 0m, false}; + yield return new object[] {1m, 2m, true}; + } + + [Theory, MemberData(nameof(LessThanCases))] + public void LessThanReturnsExpectedResult(decimal left, decimal right, bool le) { + Assert.Equal(new Money(left) < new Money(right), le); + } + + public static IEnumerable LessThanOrEqualCases() { + yield return new object[] {1m, 1m, true}; + yield return new object[] {1m, 0m, false}; + yield return new object[] {1m, 2m, true}; + } + + [Theory, MemberData(nameof(LessThanOrEqualCases))] + public void LessThanOrEqualThanReturnsExpectedResult(decimal left, decimal right, bool lte) { + Assert.Equal(new Money(left) <= new Money(right), lte); + } + + [Theory, AutoTransactoData] + public void MoneyAdditionOperator(decimal left, decimal right) { + Assert.Equal(new Money(left + right), new Money(left) + new Money(right)); + } + + [Theory, AutoTransactoData] + public void DecimalAdditionOperator(decimal left, decimal right) { + Assert.Equal(new Money(left + right), new Money(left) + right); + } + + [Theory, AutoTransactoData] + public void MoneySubtractionOperator(decimal left, decimal right) { + Assert.Equal(new Money(left - right), new Money(left) - new Money(right)); + } + + [Theory, AutoTransactoData] + public void DecimalSubtractionOperator(decimal left, decimal right) { + Assert.Equal(new Money(left - right), new Money(left) - right); + } + + [Theory, AutoTransactoData] + public void NegationOperator(decimal value) { + Assert.Equal(new Money(-value), -new Money(value)); + } + + [Theory, AutoTransactoData] + public void ToDecimalReturnsExpectedResult(decimal value) { + Assert.Equal(value, new Money(value).ToDecimal()); + } + } +} diff --git a/test/Transacto.Tests/Domain/PeriodTests.cs b/test/Transacto.Tests/Domain/PeriodTests.cs new file mode 100644 index 0000000..1a37322 --- /dev/null +++ b/test/Transacto.Tests/Domain/PeriodTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using AutoFixture; +using Xunit; + +namespace Transacto.Domain { + public class PeriodTests { + [Theory, AutoTransactoData] + public void Equality(Period sut) { + var copy = Period.Parse(sut.ToString()); + Assert.Equal(sut, copy); + } + + [Theory, AutoTransactoData] + public void EqualityOperator(Period sut) { + var copy = Period.Parse(sut.ToString()); + Assert.True(sut == copy); + } + + [Theory, AutoTransactoData] + public void InequalityOperator(Period left, Period right) { + Assert.False(left == right); + } + + [Theory, AutoTransactoData] + public void NextReturnsExpectedResult(Period period) { + var sut = period.Next(); + Assert.True(sut > period); + Assert.True(sut < sut.Next()); + } + + [Theory, AutoTransactoData] + public void DateNotInPeriodThrows(DateTimeOffset value) { + var period = Period.Open(value); + var ex = Assert.Throws(() => period.MustNotBeAfter(value.AddMonths(-1))); + Assert.Equal(value.AddMonths(-1), ex.Date); + Assert.Equal(period, ex.Period); + } + + public static IEnumerable ContainsCases() { + var fixture = new ScenarioFixture(); + var period = fixture.Create(); + + var daysInMonth = CultureInfo.InvariantCulture.Calendar.GetDaysInMonth(period.Year, period.Month); + foreach (var day in Enumerable.Range(1, daysInMonth)) { + yield return new object[] + {period, new DateTimeOffset(new DateTime(period.Year, period.Month, day), TimeSpan.Zero), true}; + } + + yield return new object[] { + period, + new DateTimeOffset(new DateTime(period.Year, period.Month, daysInMonth).AddDays(1), TimeSpan.Zero), + false + }; + + yield return new object[] + {period, new DateTimeOffset(new DateTime(period.Year, period.Month, 1).AddDays(-1), TimeSpan.Zero), false}; + + yield return new object[] + {period, new DateTimeOffset(new DateTime(period.Year, period.Month, 1), TimeSpan.FromHours(-1)), true}; + + yield return new object[] + {period, new DateTimeOffset(new DateTime(period.Year, period.Month, 1), TimeSpan.FromHours(1)), true}; + + } + + [Theory, MemberData(nameof(ContainsCases))] + public void ContainsReturnsExpectedResult(Period period, DateTimeOffset value, bool expected) { + Assert.Equal(expected, period.Contains(value)); + } + + public static IEnumerable MonthOutOfRangeCases() { + yield return new object[] {0}; + yield return new object[] {13}; + } + + [Theory, MemberData(nameof(MonthOutOfRangeCases))] + public void MonthOutOfRangeThrows(int month) { + var ex = Assert.Throws(() => Period.Parse($"2020{month:D2}")); + Assert.Equal("month", ex.ParamName); + } + + public static IEnumerable InvalidValueCases() { + yield return new object[] {string.Empty}; + yield return new object[] {"a"}; + yield return new object[] {"0"}; + yield return new object[] {"aaaaaa"}; + yield return new object[] {"0110000"}; + } + + [Theory, MemberData(nameof(InvalidValueCases))] + public void ParseInvalidValueReturnsExpectedResult(string value) { + Assert.Throws(() => Period.Parse(value)); + } + + [Theory, AutoTransactoData] + public void TryParseValidValueReturnsExpectedResult(Period period) { + Assert.True(Period.TryParse(period.ToString(), out var sut)); + Assert.Equal(period, sut); + } + + [Theory, MemberData(nameof(InvalidValueCases))] + public void TryParseInvalidValueReturnsExpectedResult(string value) { + Assert.False(Period.TryParse(value, out var sut)); + Assert.Equal(default, sut); + } + + [Theory, AutoTransactoData] + public void ToStringReturnsExpectedResult(Period sut) { + var actual = sut.ToString(); + Assert.Equal($"{sut.Year:D4}{sut.Month:D2}", actual); + } + } +} diff --git a/test/Transacto.Tests/FixtureExtensions.cs b/test/Transacto.Tests/FixtureExtensions.cs index 51c697f..171dad9 100644 --- a/test/Transacto.Tests/FixtureExtensions.cs +++ b/test/Transacto.Tests/FixtureExtensions.cs @@ -12,6 +12,10 @@ public static void CustomizeAccountNumber(this IFixture fixture) => fixture.Customize(composer => composer.FromFactory(r => new AccountNumber(r.Next(1000, 8999)))); + public static void CustomizeAccountType(this IFixture fixture) => + fixture.Customize(composer => + composer.FromFactory(r => AccountType.All[r.Next(0, AccountType.All.Count)])); + public static void CustomizePeriodIdentifier(this IFixture fixture) => fixture.Customize(composer => composer.FromFactory(Period.Open)); @@ -19,12 +23,18 @@ public static void CustomizeMoney(this IFixture fixture) => fixture.Customize(composer => composer.FromFactory(r => new Money(Math.Abs(Convert.ToDecimal(r.Next(1, 10000) / 100))))); - public static void CustomizeCredits(this IFixture fixture) => + public static void CustomizeCredit(this IFixture fixture) => fixture.Customize(composer => composer.FromFactory((n, m) => new Credit(n, m))); - public static void CustomizeDebits(this IFixture fixture) => + public static void CustomizeDebit(this IFixture fixture) => fixture.Customize(composer => composer.FromFactory((n, m) => new Debit(n, m))); + + public static void CustomizeGeneralLedgerEntryNumber(this IFixture fixture) => + fixture.Customize(composer => + composer.FromFactory((r, i) => + new GeneralLedgerEntryNumber( + new string('a', r.Next(1, GeneralLedgerEntryNumber.MaxPrefixLength)), i))); } } diff --git a/test/Transacto.Tests/Integration/BalanceSheetIntegrationTests.cs b/test/Transacto.Tests/Integration/BalanceSheetIntegrationTests.cs index bb219d9..9e88103 100644 --- a/test/Transacto.Tests/Integration/BalanceSheetIntegrationTests.cs +++ b/test/Transacto.Tests/Integration/BalanceSheetIntegrationTests.cs @@ -27,6 +27,7 @@ public async Task when_an_entry_is_posted( GeneralLedgerEntryId = generalLedgerEntryIdentifier.ToGuid(), CreatedOn = createdOn, BusinessTransaction = new JournalEntry { + ReferenceNumber = 1, Credits = Array.ConvertAll(credits, credit => new JournalEntry.Item { Amount = credit.Amount.ToDecimal(), AccountNumber = credit.AccountNumber.Value diff --git a/test/Transacto.Tests/Integration/BusinessTransaction.cs b/test/Transacto.Tests/Integration/BusinessTransaction.cs index e248f7c..2731b91 100644 --- a/test/Transacto.Tests/Integration/BusinessTransaction.cs +++ b/test/Transacto.Tests/Integration/BusinessTransaction.cs @@ -10,7 +10,7 @@ internal class BusinessTransaction : IBusinessTransaction { [DataMember(Name = "referenceNumber")] public int ReferenceNumber { get; set; } GeneralLedgerEntryNumber IBusinessTransaction.ReferenceNumber => - new GeneralLedgerEntryNumber($"t-{ReferenceNumber}"); + new GeneralLedgerEntryNumber("t", ReferenceNumber); public void Apply(GeneralLedgerEntry entry, ChartOfAccounts chartOfAccounts) { entry.ApplyDebit(new Debit(new AccountNumber(1000), new Money(5m)), chartOfAccounts); diff --git a/test/Transacto.Tests/Integration/BusinessTransactionIntegrationTests.cs b/test/Transacto.Tests/Integration/BusinessTransactionIntegrationTests.cs index addbb82..df7dce3 100644 --- a/test/Transacto.Tests/Integration/BusinessTransactionIntegrationTests.cs +++ b/test/Transacto.Tests/Integration/BusinessTransactionIntegrationTests.cs @@ -14,39 +14,13 @@ using Xunit; namespace Transacto.Integration { - public class BusinessTransactionIntegrationTests : IDisposable { - private readonly TestServer _testServer; - private readonly HttpClient _httpClient; - - public BusinessTransactionIntegrationTests() { - _testServer = new TestServer(new WebHostBuilder() - .Configure(app => app.UseTransacto().Map("/transactions", inner => inner.UseRouting().UseEndpoints( - e => e.MapBusinessTransaction(string.Empty)))) - .ConfigureServices(s => s - .AddEventStoreClient(settings => { - settings.OperationOptions.ThrowOnAppendFailure = true; - settings.CreateHttpMessageHandler = () => new SocketsHttpHandler { - SslOptions = { - RemoteCertificateValidationCallback = delegate { - return true; - } - } - }; - }) - .AddSingleton(new HttpClientSqlStreamStore(new HttpClientSqlStreamStoreSettings { - BaseAddress = new UriBuilder {Port = 5002}.Uri - })) - .AddTransacto() - .AddStreamStoreProjection())); - _httpClient = _testServer.CreateClient(); - } - + public class BusinessTransactionIntegrationTests : IntegrationTests { [Fact] public async Task Somewthing() { var now = DateTimeOffset.UtcNow; var period = Period.Open(now); var transactionId = Guid.NewGuid(); - await _httpClient.SendCommand("/transactions", new PostGeneralLedgerEntry { + await HttpClient.SendCommand("/transactions", new PostGeneralLedgerEntry { BusinessTransaction = new BusinessTransaction { TransactionId = transactionId, ReferenceNumber = 1, @@ -59,18 +33,14 @@ public async Task Somewthing() { await Task.Delay(TimeSpan.FromMinutes(5)); } - public void Dispose() { - _httpClient?.Dispose(); - _testServer?.Dispose(); - } - private class BusinessTransactionEntry : FeedEntry { public Guid TransactonId { get; set; } public int ReferenceNumber { get; set; } } private class BusinessTransactionFeed : StreamStoreFeedProjection { - public BusinessTransactionFeed(IMessageTypeMapper messageTypeMapper) : base("businessTransactions", messageTypeMapper) { + public BusinessTransactionFeed(IMessageTypeMapper messageTypeMapper) : base("businessTransactions", + messageTypeMapper) { When((e, _) => new BusinessTransactionEntry { TransactonId = e.TransactionId, ReferenceNumber = e.ReferenceNumber diff --git a/test/Transacto.Tests/Integration/ChartOfAccountsIntegrationTests.cs b/test/Transacto.Tests/Integration/ChartOfAccountsIntegrationTests.cs index 9342bd7..30e40df 100644 --- a/test/Transacto.Tests/Integration/ChartOfAccountsIntegrationTests.cs +++ b/test/Transacto.Tests/Integration/ChartOfAccountsIntegrationTests.cs @@ -2,58 +2,33 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; +using EventStore.Client; using Transacto.Domain; -using Transacto.Framework; using Transacto.Infrastructure; using Transacto.Messages; using Xunit; namespace Transacto.Integration { - public class ChartOfAccountsIntegrationTests : IDisposable { - private readonly TestServer _testServer; - private readonly HttpClient _httpClient; - - public ChartOfAccountsIntegrationTests() { - _testServer = new TestServer(new WebHostBuilder() - .Configure(app => app.UseTransacto()) - .ConfigureServices(s => s - .AddEventStoreClient(settings => { - settings.OperationOptions.ThrowOnAppendFailure = true; - settings.CreateHttpMessageHandler = () => new SocketsHttpHandler { - SslOptions = { - RemoteCertificateValidationCallback = delegate { - return true; - } - } - }; - }) - .AddTransacto())); - _httpClient = _testServer.CreateClient(); - } - + public class ChartOfAccountsIntegrationTests : IntegrationTests { [Fact] public async Task Somewthing() { var accounts = GetChartOfAccounts(); + Position position = Position.Start; + foreach (var (accountNumber, accountName) in accounts.OrderBy(_ => Guid.NewGuid())) { - await _httpClient.SendCommand("/chart-of-accounts", new DefineAccount { + position = await HttpClient.SendCommand("/chart-of-accounts", new DefineAccount { AccountName = accountName.ToString(), AccountNumber = accountNumber.ToInt32() }, TransactoSerializerOptions.BusinessTransactions()); } - await Task.Delay(500); + using var response = await HttpClient.ConditionalGetAsync("/chart-of-accounts", position); - using var response = await _httpClient.GetAsync("/chart-of-accounts"); - - var chartOfAccounts = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var body = await response.Content.ReadAsStreamAsync(); + var chartOfAccounts = await JsonDocument.ParseAsync(body); using var resultEnumerator = chartOfAccounts.RootElement.EnumerateObject(); using var expectEnumerator = accounts.OrderBy(x => x.Item1.ToInt32()).GetEnumerator(); @@ -79,10 +54,5 @@ public async Task Somewthing() { yield return (new AccountNumber(4000), new AccountName("Sales Income")); yield return (new AccountNumber(5000), new AccountName("Cost of Goods Sold")); } - - public void Dispose() { - _httpClient?.Dispose(); - _testServer?.Dispose(); - } } } diff --git a/test/Transacto.Tests/Integration/HttpClientExtensions.cs b/test/Transacto.Tests/Integration/HttpClientExtensions.cs index cd55eb1..5f32fd9 100644 --- a/test/Transacto.Tests/Integration/HttpClientExtensions.cs +++ b/test/Transacto.Tests/Integration/HttpClientExtensions.cs @@ -1,13 +1,19 @@ +using System; using System.Net.Http; +using System.Net.Http.Headers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using EventStore.Client; +using Polly; namespace Transacto.Integration { internal static class HttpClientExtensions { - public static async Task SendCommand(this HttpClient client, string requestUri, object command, + public static async Task SendCommand(this HttpClient client, string requestUri, object command, JsonSerializerOptions options, CancellationToken cancellationToken = default) { + var separator = new[] {'/'}; + using var response = await client.PostAsync(requestUri, new MultipartFormDataContent { {new StringContent(command.GetType().Name), nameof(command)}, { new ReadOnlyMemoryContent( @@ -15,6 +21,38 @@ public static async Task SendCommand(this HttpClient client, string requestUri, "data", "data" } }, cancellationToken); + + var value = await response.Content.ReadAsStringAsync(); + + var parts = value?.Split(separator, 2) ?? Array.Empty(); + + if (parts.Length != 2 || !ulong.TryParse(parts[0], out var p) || !ulong.TryParse(parts[1], out var c)) { + return Position.Start; + } + + return new Position(p, c); } + + public static Task ConditionalGetAsync(this HttpClient client, string requestUri, + Position position, CancellationToken cancellationToken = default) => + Policy.Handle() + .WaitAndRetryAsync(5, count => TimeSpan.FromMilliseconds(count * 2 * 100)) + .ExecuteAsync(async ct => { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri) { + Headers = { + IfMatch = { + new EntityTagHeaderValue($@"""{position.CommitPosition}/{position.PreparePosition}""") + } + } + }; + var response = await client.SendAsync(request, ct); + try { + response.EnsureSuccessStatusCode(); + return response; + } catch (HttpRequestException) { + response.Dispose(); + throw; + } + }, cancellationToken); } } diff --git a/test/Transacto.Tests/Integration/IntegrationTests.cs b/test/Transacto.Tests/Integration/IntegrationTests.cs index 7d1628c..7e026ef 100644 --- a/test/Transacto.Tests/Integration/IntegrationTests.cs +++ b/test/Transacto.Tests/Integration/IntegrationTests.cs @@ -2,56 +2,59 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; +using Ductus.FluentDocker.Builders; +using Ductus.FluentDocker.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Retry; using SqlStreamStore; using Transacto.Domain; -using Transacto.Framework; using Transacto.Infrastructure; using Transacto.Messages; +using Xunit; namespace Transacto.Integration { - public abstract class IntegrationTests : IDisposable { - private readonly TestServer _testServer; - protected HttpClient HttpClient { get; } + [Collection(nameof(IntegrationTests))] + public abstract class IntegrationTests : IDisposable, IAsyncLifetime { + private readonly IContainerService _eventStore; + private readonly IContainerService _streamStore; + + private TestServer _testServer; + protected HttpClient HttpClient { get; private set; } static IntegrationTests() { Inflector.Inflector.SetDefaultCultureFunc = () => new CultureInfo("en-US"); } protected IntegrationTests() { - _testServer = new TestServer(new WebHostBuilder() - .ConfigureServices(s => s - .AddEventStoreClient(settings => { - settings.OperationOptions.ThrowOnAppendFailure = true; - settings.CreateHttpMessageHandler = () => new SocketsHttpHandler { - SslOptions = { - RemoteCertificateValidationCallback = delegate { - return true; - } - } - }; - }) - .AddSingleton(new HttpClientSqlStreamStore(new HttpClientSqlStreamStoreSettings { - CreateHttpClient = () => new HttpClient(new SocketsHttpHandler { - SslOptions = { - RemoteCertificateValidationCallback = delegate { - return true; - } - } - }, true) - })) - .AddTransacto()) - .Configure(app => app.UseTransacto())); - HttpClient = _testServer.CreateClient(); + _eventStore = new Builder() + .UseContainer() + .WithName("transacto-es-test") + .UseImage("eventstore/eventstore:20.6.0-buster-slim") + .ReuseIfExists() + .ExposePort(2113, 2113) + .WithEnvironment("EVENTSTORE_DEV=true") + .Build(); + _streamStore = new Builder() + .UseContainer() + .WithName("transacto-sss-test") + .UseImage("sqlstreamstore/server:1.2.0-beta.5-alpine3.9") + .ReuseIfExists() + .ExposePort(5000, 80) + .Build(); } public void Dispose() { - _testServer?.Dispose(); HttpClient?.Dispose(); + _testServer?.Dispose(); + _streamStore?.Dispose(); + _eventStore?.Dispose(); } protected async IAsyncEnumerable<(AccountNumber accountNumber, AccountName accountName)> @@ -80,5 +83,69 @@ public void Dispose() { yield return (new AccountNumber(4000), new AccountName("Sales Income")); yield return (new AccountNumber(5000), new AccountName("Cost of Goods Sold")); } + + public async Task InitializeAsync() { + _eventStore.Start(); + _streamStore.Start(); + + using var client = new HttpClient(new SocketsHttpHandler { + SslOptions = { + RemoteCertificateValidationCallback = delegate { + return true; + } + } + }, true); + + await Retry.ExecuteAsync(async () => { + using var response = await client.GetAsync("https://localhost:2113/"); + if (response.StatusCode >= HttpStatusCode.BadRequest) { + throw new Exception(); + } + }); + await Retry.ExecuteAsync(async () => { + return; + using var response = + await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://localhost:5000/") { + Headers = {Accept = {new MediaTypeWithQualityHeaderValue("application/hal+json")}} + }); + if (response.StatusCode >= HttpStatusCode.BadRequest) { + throw new Exception(); + } + }); + + _testServer = new TestServer(new WebHostBuilder() + .ConfigureServices(s => s + .AddEventStoreClient(settings => { + settings.OperationOptions.ThrowOnAppendFailure = true; + settings.CreateHttpMessageHandler = () => new SocketsHttpHandler { + SslOptions = { + RemoteCertificateValidationCallback = delegate { + return true; + } + } + }; + }) + .AddSingleton(new HttpClientSqlStreamStore(new HttpClientSqlStreamStoreSettings { + CreateHttpClient = () => new HttpClient(new SocketsHttpHandler { + SslOptions = { + RemoteCertificateValidationCallback = delegate { + return true; + } + } + }, true) + })) + .AddTransacto()) + .Configure(app => app.UseTransacto())); + HttpClient = _testServer.CreateClient(); + } + + private static AsyncRetryPolicy Retry => Policy + .Handle() + .WaitAndRetryAsync(100, i => TimeSpan.FromMilliseconds(Math.Pow(i, 2))); + + public Task DisposeAsync() { + Dispose(); + return Task.CompletedTask; + } } } diff --git a/test/Transacto.Tests/ScenarioFixture.cs b/test/Transacto.Tests/ScenarioFixture.cs index c385e27..25b3db2 100644 --- a/test/Transacto.Tests/ScenarioFixture.cs +++ b/test/Transacto.Tests/ScenarioFixture.cs @@ -9,11 +9,15 @@ public ScenarioFixture() { this.CustomizeAccountNumber(); + this.CustomizeAccountType(); + + this.CustomizeGeneralLedgerEntryNumber(); + this.CustomizeMoney(); - this.CustomizeCredits(); + this.CustomizeCredit(); - this.CustomizeDebits(); + this.CustomizeDebit(); } } } diff --git a/test/Transacto.Tests/TestOutputHelperTextWriter.cs b/test/Transacto.Tests/TestOutputHelperTextWriter.cs new file mode 100644 index 0000000..71c568a --- /dev/null +++ b/test/Transacto.Tests/TestOutputHelperTextWriter.cs @@ -0,0 +1,17 @@ +using System.IO; +using System.Text; +using Xunit.Abstractions; + +#nullable enable +namespace Transacto { + internal class TestOutputHelperTextWriter : TextWriter { + private readonly ITestOutputHelper _output; + public override Encoding Encoding { get; } = Encoding.UTF8; + + public TestOutputHelperTextWriter(ITestOutputHelper output) { + _output = output; + } + + public override void Write(string? value) => _output.WriteLine(value?.Trim()); + } +} diff --git a/test/Transacto.Tests/Testing/FactRecorder.cs b/test/Transacto.Tests/Testing/FactRecorder.cs index e49a920..77d8ef9 100644 --- a/test/Transacto.Tests/Testing/FactRecorder.cs +++ b/test/Transacto.Tests/Testing/FactRecorder.cs @@ -16,7 +16,7 @@ public void Record(string identifier, IEnumerable events) => Record(events.Select(e => new Fact(identifier, e))); public void Record(IEnumerable facts) => _recordedFacts.AddRange(facts); - public void Record(string identifier, AggregateRoot aggregate) => _aggregates[identifier] = aggregate; + public void Attach(string identifier, AggregateRoot aggregate) => _aggregates[identifier] = aggregate; public IAsyncEnumerable GetFacts() => _recordedFacts.ToAsyncEnumerable() diff --git a/test/Transacto.Tests/Testing/IFactRecorder.cs b/test/Transacto.Tests/Testing/IFactRecorder.cs index 7ff43c0..7d9be3c 100644 --- a/test/Transacto.Tests/Testing/IFactRecorder.cs +++ b/test/Transacto.Tests/Testing/IFactRecorder.cs @@ -5,7 +5,7 @@ namespace Transacto.Testing { public interface IFactRecorder { void Record(string identifier, IEnumerable events); void Record(IEnumerable facts); - void Record(string identifier, AggregateRoot aggregate); + void Attach(string identifier, AggregateRoot aggregate); IAsyncEnumerable GetFacts(); } } diff --git a/test/Transacto.Tests/Transacto.Tests.csproj b/test/Transacto.Tests/Transacto.Tests.csproj index f92ca91..4852c3a 100644 --- a/test/Transacto.Tests/Transacto.Tests.csproj +++ b/test/Transacto.Tests/Transacto.Tests.csproj @@ -4,21 +4,23 @@ netcoreapp3.1 Transacto false + $(RestoreSources);https://api.nuget.org/v3/index.json;https://nuget.pkg.github.com/EventStore/index.json;https://nuget.pkg.github.com/thefringeninja/index.json;https://f.feedz.io/logicality/streamstore-ci/nuget/index.json - - - - - - - + + + + + + + + - - + +