diff --git a/wolverine/a-frame-architecture/.editorconfig b/wolverine/a-frame-architecture/.editorconfig new file mode 100644 index 0000000..4706336 --- /dev/null +++ b/wolverine/a-frame-architecture/.editorconfig @@ -0,0 +1,146 @@ +[*] +charset = utf-8-bom +end_of_line = crlf +trim_trailing_whitespace = false +insert_final_newline = false +indent_style = space +indent_size = 4 + +# Microsoft .NET properties +csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_naming_rule.interfaces_rule.import_to_resharper = True +dotnet_naming_rule.interfaces_rule.resharper_description = Interfaces +dotnet_naming_rule.interfaces_rule.resharper_guid = a7a3339e-4e89-4319-9735-a9dc4cb74cc7 +dotnet_naming_rule.interfaces_rule.severity = warning +dotnet_naming_rule.interfaces_rule.style = i_upper_camel_case_style +dotnet_naming_rule.interfaces_rule.symbols = interfaces_symbols +dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True +dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field +dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef +dotnet_naming_rule.unity_serialized_field_rule.severity = warning +dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style +dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols +dotnet_naming_rule.unity_serialized_field_rule_1.import_to_resharper = True +dotnet_naming_rule.unity_serialized_field_rule_1.resharper_description = Unity serialized field +dotnet_naming_rule.unity_serialized_field_rule_1.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef +dotnet_naming_rule.unity_serialized_field_rule_1.severity = warning +dotnet_naming_rule.unity_serialized_field_rule_1.style = lower_camel_case_style +dotnet_naming_rule.unity_serialized_field_rule_1.symbols = unity_serialized_field_symbols_1 +dotnet_naming_rule.unity_serialized_field_rule_2.import_to_resharper = True +dotnet_naming_rule.unity_serialized_field_rule_2.resharper_description = Unity serialized field +dotnet_naming_rule.unity_serialized_field_rule_2.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef +dotnet_naming_rule.unity_serialized_field_rule_2.severity = warning +dotnet_naming_rule.unity_serialized_field_rule_2.style = lower_camel_case_style +dotnet_naming_rule.unity_serialized_field_rule_2.symbols = unity_serialized_field_symbols_2 +dotnet_naming_style.i_upper_camel_case_style.capitalization = pascal_case +dotnet_naming_style.i_upper_camel_case_style.required_prefix = I +dotnet_naming_style.lower_camel_case_style.capitalization = camel_case +dotnet_naming_symbols.interfaces_symbols.applicable_accessibilities = * +dotnet_naming_symbols.interfaces_symbols.applicable_kinds = interface +dotnet_naming_symbols.interfaces_symbols.resharper_applicable_kinds = interface +dotnet_naming_symbols.interfaces_symbols.resharper_required_modifiers = any +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance +dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_accessibilities = * +dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_applicable_kinds = unity_serialised_field +dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_required_modifiers = instance +dotnet_naming_symbols.unity_serialized_field_symbols_2.applicable_accessibilities = * +dotnet_naming_symbols.unity_serialized_field_symbols_2.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols_2.resharper_applicable_kinds = unity_serialised_field +dotnet_naming_symbols.unity_serialized_field_symbols_2.resharper_required_modifiers = instance +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# ReSharper properties +resharper_align_linq_query = true +resharper_autodetect_indent_settings = true +resharper_braces_for_for = required +resharper_braces_for_foreach = required +resharper_braces_for_ifelse = required +resharper_braces_for_while = required +resharper_cpp_insert_final_newline = true +resharper_csharp_empty_block_style = together_same_line +resharper_csharp_int_align_comments = true +resharper_csharp_wrap_after_declaration_lpar = true +resharper_csharp_wrap_after_invocation_lpar = true +resharper_csharp_wrap_arguments_style = chop_if_long +resharper_csharp_wrap_before_declaration_rpar = true +resharper_csharp_wrap_before_invocation_rpar = true +resharper_csharp_wrap_parameters_style = chop_if_long +resharper_formatter_off_tag = @formatter:off +resharper_formatter_on_tag = @formatter:on +resharper_formatter_tags_enabled = true +resharper_keep_existing_expr_member_arrangement = false +resharper_keep_existing_initializer_arrangement = false +resharper_keep_existing_linebreaks = false +resharper_place_field_attribute_on_same_line = if_owner_is_single_line +resharper_place_method_attribute_on_same_line = if_owner_is_single_line +resharper_show_autodetect_configure_formatting_tip = false +resharper_use_indent_from_vs = false +resharper_wrap_before_primary_constructor_declaration_rpar = true +resharper_wrap_chained_method_calls = chop_always + +# ReSharper inspection severities +resharper_arrange_redundant_parentheses_highlighting = hint +resharper_arrange_this_qualifier_highlighting = hint +resharper_arrange_type_member_modifiers_highlighting = hint +resharper_arrange_type_modifiers_highlighting = hint +resharper_built_in_type_reference_style_for_member_access_highlighting = hint +resharper_built_in_type_reference_style_highlighting = hint +resharper_enforce_do_while_statement_braces_highlighting = warning +resharper_enforce_fixed_statement_braces_highlighting = warning +resharper_enforce_foreach_statement_braces_highlighting = warning +resharper_enforce_for_statement_braces_highlighting = warning +resharper_enforce_if_statement_braces_highlighting = warning +resharper_enforce_lock_statement_braces_highlighting = warning +resharper_enforce_using_statement_braces_highlighting = warning +resharper_enforce_while_statement_braces_highlighting = warning +resharper_mvc_action_not_resolved_highlighting = warning +resharper_mvc_area_not_resolved_highlighting = warning +resharper_mvc_controller_not_resolved_highlighting = warning +resharper_mvc_masterpage_not_resolved_highlighting = warning +resharper_mvc_partial_view_not_resolved_highlighting = warning +resharper_mvc_template_not_resolved_highlighting = warning +resharper_mvc_view_component_not_resolved_highlighting = warning +resharper_mvc_view_component_view_not_resolved_highlighting = warning +resharper_mvc_view_not_resolved_highlighting = warning +resharper_razor_assembly_not_resolved_highlighting = warning +resharper_redundant_base_qualifier_highlighting = warning +resharper_suggest_var_or_type_built_in_types_highlighting = hint +resharper_suggest_var_or_type_elsewhere_highlighting = hint +resharper_suggest_var_or_type_simple_types_highlighting = hint +resharper_web_config_module_not_resolved_highlighting = warning +resharper_web_config_type_not_resolved_highlighting = warning +resharper_web_config_wrong_module_highlighting = warning + +[{*.yaml,*.yml}] +indent_style = space +indent_size = 2 + +[{tsconfig.app.json,tsconfig.base.json,tsconfig.e2e.json,tsconfig.editor.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,tsconfig.test.json}] +indent_style = space +indent_size = 2 + +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] +indent_style = space +indent_size = 2 + +[*.{appxmanifest,asax,ascx,aspx,axaml,blockshader,c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,cxxm,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,h++,hh,hlsl,hlsli,hlslinc,hp,hpp,hxx,icc,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,mxx,nuspec,paml,pssl,pssli,razor,resw,resx,shader,shaderFoundry,skin,tcc,tpp,urtshader,usf,ush,uxml,vb,xaml,xamlx,xoml,xsd}] +indent_style = space +indent_size = 4 +tab_width = 4 diff --git a/wolverine/a-frame-architecture/.gitignore b/wolverine/a-frame-architecture/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/wolverine/a-frame-architecture/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/.gitignore b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/.gitignore new file mode 100644 index 0000000..6d7db21 --- /dev/null +++ b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/projectSettingsUpdater.xml +/modules.xml +/.idea.a-frame-architecture.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/encodings.xml b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/indexLayout.xml b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/mise.xml b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/mise.xml new file mode 100644 index 0000000..a5fff2e --- /dev/null +++ b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/mise.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/vcs.xml b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/wolverine/a-frame-architecture/.idea/.idea.a-frame-architecture/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyFeeHandlerTests.cs b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyFeeHandlerTests.cs new file mode 100644 index 0000000..8739c27 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyFeeHandlerTests.cs @@ -0,0 +1,129 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Handlers; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Exceptions; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.Time.Testing; + +namespace FinancialApi.Application.UnitTests; + +public class ApplyFeeHandlerTests +{ + public class SpendAccount + { + [Theory] + [InlineData(100.0, 20.0, 80.0)] + [InlineData(199.78, 10.0, 189.78)] + [InlineData(200000, 0.09, 199999.91)] + [InlineData(1000, 1000, 0)] + public void GivenStartingBalanceGreaterThanFee_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Spend); + var revenueAccount = new Account(99999, 0, AccountType.Revenue); + var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow()); + + // Act + var (customerWrite, revenueWrite, journalWrite, @event) = ApplyFeeHandler.Handle(command, feeContext); + + // Assert + using var _ = new AssertionScope(); + customerWrite.Entity.Balance.Should() + .Be(expectedBalance); + } + + [Theory] + [InlineData(100.0, 110.0)] + [InlineData(199.78, 200.0)] + [InlineData(200000, 200000.01)] + [InlineData(1000, 1999999)] + public void GivenStartingBalanceLessThanFee_ApplyFee_ShouldFail(decimal startingBalance, decimal feeAmount) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Spend); + var revenueAccount = new Account(99999, 0, AccountType.Revenue); + var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow()); + + // Act + var act = () => ApplyFeeHandler.Handle(command, feeContext); + + // Assert + using var _ = new AssertionScope(); + act.Should() + .Throw(); + } + } + + public class BorrowAccount + { + [Theory] + [InlineData(100.0, 20.0, 80.0)] + [InlineData(199.78, 10.0, 189.78)] + [InlineData(200000, 0.09, 199999.91)] + [InlineData(1000, 1000, 0)] + public void GivenStartingBalanceGreaterThanFee_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Borrow); + var revenueAccount = new Account(99999, 0, AccountType.Revenue); + var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow()); + + // Act + var (customerWrite, revenueWrite, journalWrite, @event) = ApplyFeeHandler.Handle(command, feeContext); + + // Assert + using var _ = new AssertionScope(); + customerWrite.Entity.Balance.Should() + .Be(expectedBalance); + } + + [Theory] + [InlineData(100.0, 110.0, -10.0)] + [InlineData(199.78, 200.0, -0.22)] + [InlineData(200000, 200000.01, -0.01)] + [InlineData(1000, 1999999, -1998999)] + public void GivenStartingBalanceLessThanFee_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Borrow); + var revenueAccount = new Account(99999, 0, AccountType.Revenue); + var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow()); + + // Act + var (customerWrite, revenueWrite, journalWrite, @event) = ApplyFeeHandler.Handle(command, feeContext); + + // Assert + using var _ = new AssertionScope(); + customerWrite.Entity.Balance.Should() + .Be(expectedBalance); + revenueWrite.Entity.Balance.Should() + .Be(feeAmount); + journalWrite.Should() + .NotBeNull(); + @event.Should() + .NotBeNull(); + } + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/FinancialApi.Application.UnitTests.csproj b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/FinancialApi.Application.UnitTests.csproj new file mode 100644 index 0000000..59ef367 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/FinancialApi.Application.UnitTests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\.nuget\packages\wolverinefx\6.18.0\lib\net10.0\Wolverine.dll + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ReverseJournalHandlerTests.cs b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ReverseJournalHandlerTests.cs new file mode 100644 index 0000000..c03449d --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ReverseJournalHandlerTests.cs @@ -0,0 +1,67 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Handlers; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Events; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.Time.Testing; +using Wolverine.Persistence; + +namespace FinancialApi.Application.UnitTests; + +public class ReverseJournalHandlerTests +{ + [Fact] + public void GivenValidJournalEntry_Reversal_ShouldResetAccountBalances() + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var expectedSourceAccountBalance = 100.0m; + var expectedDestAccountBalance = 100.0m; + var (initialSourceAccountWrite, initialDestAccountWrite, initialJournalWrite, initialEvent) = + CreateInitialTransferTransaction( + expectedSourceAccountBalance, + expectedDestAccountBalance, + 10, + timeProvider + ); + var command = new ReverseJournalCommand(initialJournalWrite.Entity.Id, "Invalid Transaction"); + var context = new JournalReversalContext( + initialJournalWrite.Entity, + [initialSourceAccountWrite.Entity, initialDestAccountWrite.Entity], + timeProvider.GetUtcNow() + ); + + // Act + var (journalWrite, accountWrites, @event) = ReverseJournalHandler.Handle(command, context); + + // Assert + using var _ = new AssertionScope(); + accountWrites[0] + .Entity.Balance.Should() + .Be(expectedSourceAccountBalance); + accountWrites[1] + .Entity.Balance.Should() + .Be(expectedDestAccountBalance); + journalWrite.Entity.Lines.Should() + .HaveCount(initialJournalWrite.Entity.Lines.Count); + } + + private (IStorageAction SourceWrite, IStorageAction DestWrite, IStorageAction + JournalWrite, FundsTransferredEvent Event) CreateInitialTransferTransaction( + decimal sourceStartingBalance, + decimal destStartingBalance, + decimal transferAmount, + TimeProvider timeProvider + ) + { + var command = new TransferFundsCommand(1, 2, transferAmount); + var sourceAccount = new Account(1, sourceStartingBalance, AccountType.Spend); + var destAccount = new Account(2, destStartingBalance, AccountType.Spend); + var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow()); + + // Act + return TransferFundsHandler.Handle(command, transferContext); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/TransferFundsHandlerTests.cs b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/TransferFundsHandlerTests.cs new file mode 100644 index 0000000..781d83a --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/TransferFundsHandlerTests.cs @@ -0,0 +1,154 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Handlers; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Exceptions; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.Time.Testing; + +namespace FinancialApi.Application.UnitTests; + +public class TransferFundsHandlerTests +{ + public class LiabilityAccount + { + [Theory] + [InlineData(100.0, 20.0, 80.0)] + [InlineData(199.78, 10.0, 189.78)] + [InlineData(200000, 0.09, 199999.91)] + [InlineData(1000, 1000, 0)] + public void GivenStartingBalanceGreaterThanTransfer_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new TransferFundsCommand(1, 2, feeAmount); + var sourceAccount = new Account(1, startingBalance, AccountType.Spend); + var destAccount = new Account(2, 0, AccountType.Spend); + var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow()); + + // Act + var (customerWrite, revenueWrite, journalWrite, @event) = + TransferFundsHandler.Handle(command, transferContext); + + // Assert + using var _ = new AssertionScope(); + customerWrite.Entity.Balance.Should() + .Be(expectedBalance); + } + + [Theory] + [InlineData(100.0, 110.0)] + [InlineData(199.78, 200.0)] + [InlineData(200000, 200000.01)] + [InlineData(1000, 1999999)] + public void GivenStartingBalanceLessThanTransfer_ApplyFee_ShouldFail(decimal startingBalance, decimal feeAmount) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new TransferFundsCommand(1, 2, feeAmount); + var sourceAccount = new Account(1, startingBalance, AccountType.Spend); + var destAccount = new Account(2, 0, AccountType.Spend); + var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow()); + + // Act + var act = () => TransferFundsHandler.Handle(command, transferContext); + + // Assert + using var _ = new AssertionScope(); + act.Should() + .Throw(); + } + } + + public class CreditAccount + { + [Theory] + [InlineData(100.0, 20.0, 80.0)] + [InlineData(199.78, 10.0, 189.78)] + [InlineData(200000, 0.09, 199999.91)] + [InlineData(1000, 1000, 0)] + public void GivenStartingBalanceGreaterThanFee_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new TransferFundsCommand(1, 2, feeAmount); + var sourceAccount = new Account(1, startingBalance, AccountType.Borrow); + var destAccount = new Account(2, 0, AccountType.Borrow); + var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow()); + + // Act + var (customerWrite, revenueWrite, journalWrite, @event) = + TransferFundsHandler.Handle(command, transferContext); + + // Assert + using var _ = new AssertionScope(); + customerWrite.Entity.Balance.Should() + .Be(expectedBalance); + } + + [Theory] + [InlineData(100.0, 110.0, -10.0)] + [InlineData(199.78, 200.0, -0.22)] + [InlineData(200000, 200000.01, -0.01)] + [InlineData(1000, 1999999, -1998999)] + public void GivenStartingBalanceLessThanFee_ApplyFee_ShouldSucceed( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new TransferFundsCommand(1, 2, feeAmount); + var sourceAccount = new Account(1, startingBalance, AccountType.Borrow); + var destAccount = new Account(2, 0, AccountType.Borrow); + var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow()); + + // Act + var act = () => TransferFundsHandler.Handle(command, transferContext); + + // Assert + using var _ = new AssertionScope(); + act.Should() + .NotThrow(); + } + + + [Theory] + [InlineData(100.0, 110.0, -10.0)] + [InlineData(199.78, 200.0, -0.22)] + [InlineData(200000, 200000.01, -0.01)] + [InlineData(1000, 1999999, -1998999)] + public void GivenStartingBalanceLessThanFee_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + var command = new TransferFundsCommand(1, 2, feeAmount); + var sourceAccount = new Account(1, startingBalance, AccountType.Borrow); + var destAccount = new Account(2, 0, AccountType.Borrow); + var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow()); + + // Act + var (customerWrite, revenueWrite, journalWrite, @event) = + TransferFundsHandler.Handle(command, transferContext); + + // Assert + using var _ = new AssertionScope(); + customerWrite.Entity.Balance.Should() + .Be(expectedBalance); + } + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Commands/ApplyFeeCommand.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/ApplyFeeCommand.cs new file mode 100644 index 0000000..92fd480 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/ApplyFeeCommand.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Application.Commands; + +public record ApplyFeeCommand(int AccountId, decimal Amount); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Commands/ReverseJournalCommand.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/ReverseJournalCommand.cs new file mode 100644 index 0000000..66e954b --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/ReverseJournalCommand.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Application.Commands; + +public record ReverseJournalCommand(Guid OriginalJournalId, string Reason); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Commands/TransferFundsCommand.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/TransferFundsCommand.cs new file mode 100644 index 0000000..6f39544 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/TransferFundsCommand.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Application.Commands; + +public record TransferFundsCommand(int SourceAccountId, int DestinationAccountId, decimal Amount); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj b/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj new file mode 100644 index 0000000..53a91b8 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + ..\..\..\..\..\..\.nuget\packages\microsoft.extensions.logging.abstractions\10.0.9\lib\net10.0\Microsoft.Extensions.Logging.Abstractions.dll + + + ..\..\..\..\..\..\.nuget\packages\wolverinefx\6.18.0\lib\net10.0\Wolverine.dll + + + + diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ApplyFeeHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ApplyFeeHandler.cs new file mode 100644 index 0000000..c31a65a --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ApplyFeeHandler.cs @@ -0,0 +1,51 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Interfaces; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Aggregates; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Events; +using Wolverine.Persistence; + +namespace FinancialApi.Application.Handlers; + +public static class ApplyFeeHandler +{ + public static async Task LoadAsync(ApplyFeeCommand cmd, IAccountQuery query, TimeProvider timeProvider) + { + var source = await query.FindByIdAsync(cmd.AccountId); + var dest = await query.FindByIdAsync(99999); + if (source == null || dest == null) + { + throw new InvalidOperationException("Cannot process transfer. Account(s) not found."); + } + + return new FeeContext(source, dest, timeProvider.GetUtcNow()); + } + + public static ( IStorageAction CustomerWrite, IStorageAction RevenueWrite, + IStorageAction JournalWrite, FeeAppliedEvent Event ) Handle( + ApplyFeeCommand cmd, + FeeContext context + ) + { + var updatedSource = context.Source.ApplyPosting(cmd.Amount, EntryType.Debit); + var updatedRevenue = context.Destination.ApplyPosting(cmd.Amount, EntryType.Credit); + + var lines = new List + { + new(updatedSource.Id, cmd.Amount, EntryType.Debit), new(updatedRevenue.Id, cmd.Amount, EntryType.Credit) + }; + + var journalEntry = BalancedJournal.Create( + Guid.NewGuid(), + $"Service Fee Applied: {cmd.Amount} to Account {cmd.AccountId}", + context.TimeStamp, + lines + ); + + var @event = new FeeAppliedEvent(cmd.AccountId, cmd.Amount); + + return (Storage.Update(updatedSource), Storage.Update(updatedRevenue), Storage.Insert(journalEntry.Entry), + @event); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/FinancialEventHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/FinancialEventHandler.cs new file mode 100644 index 0000000..ede771f --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/FinancialEventHandler.cs @@ -0,0 +1,30 @@ +using FinancialApi.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace FinancialApi.Application.Handlers; + +public static class FinancialEventHandler +{ + public static void Handle(FeeAppliedEvent @event, IEventTracker store, ILogger logger) + { + var message = $"FeeApplied: ${@event.Amount} moved from Account {@event.AccountId} to Account 99999."; + logger.LogInformation("BACKGROUND EVENT FIRED: {Message}", message); + store.Add(message); + } + + public static void Handle(FundsTransferredEvent @event, IEventTracker store, ILogger logger) + { + var message = + $"FundsTransferred: ${@event.Amount} moved from Account {@event.SourceAccountId} to Account {@event.DestinationAccountId}."; + logger.LogInformation("BACKGROUND EVENT FIRED: {Message}", message); + store.Add(message); + } + + public static void Handle(JournalReversedEvent @event, IEventTracker store, ILogger logger) + { + var message = + $"JournalReversed: Original Entry {@event.OriginalJournalId} was reversed by Entry {@event.ReversalJournalId}."; + logger.LogInformation("BACKGROUND EVENT FIRED: {Message}", message); + store.Add(message); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ReverseJournalHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ReverseJournalHandler.cs new file mode 100644 index 0000000..df8260e --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ReverseJournalHandler.cs @@ -0,0 +1,59 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Interfaces; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Aggregates; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Events; +using Wolverine.Persistence; + +namespace FinancialApi.Application.Handlers; + +public static class ReverseJournalHandler +{ + public static async Task LoadAsync( + ReverseJournalCommand cmd, + IReversalQuery query, + TimeProvider timeProvider + ) + { + var (journalEnty, accounts) = await query.GetReversalDataAsync(cmd.OriginalJournalId); + if (journalEnty == null) + { + throw new InvalidOperationException("Journal not found"); + } + + if (accounts == null) + { + throw new InvalidOperationException("Journal accounts not found"); + } + + return new JournalReversalContext(journalEnty, accounts, timeProvider.GetUtcNow()) ?? + throw new InvalidOperationException($"Journal Entry {cmd.OriginalJournalId} does not exist"); + } + + public static (IStorageAction JournalWrite, UnitOfWork AccountWrites, JournalReversedEvent + Event) Handle(ReverseJournalCommand cmd, JournalReversalContext context) + { + var reversalJournal = BalancedJournal.CreateReversal( + context.OriginalBalancedJournalEntry, + $"Reversal: {cmd.Reason}", + context.TimeStamp + ); + var accountState = context.AffectedAccounts.ToDictionary(a => a.Id); + + reversalJournal.Entry.Lines.ForEach(l => + { + var currentAccount = accountState[l.AccountId]; + accountState[l.AccountId] = currentAccount.ApplyPosting(l.Amount, l.Type); + } + ); + var accountUow = new UnitOfWork(); + foreach (var account in accountState.Values) + { + accountUow.Update(account); + } + + return (Storage.Insert(reversalJournal.Entry), accountUow, + new JournalReversedEvent(cmd.OriginalJournalId, reversalJournal.Entry.Id)); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs new file mode 100644 index 0000000..4fc117f --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs @@ -0,0 +1,53 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Interfaces; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Aggregates; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Events; +using Wolverine.Persistence; + +namespace FinancialApi.Application.Handlers; + +public static class TransferFundsHandler +{ + public static async Task LoadAsync( + TransferFundsCommand cmd, + IAccountQuery query, + TimeProvider timeProvider + ) + { + var source = await query.FindByIdAsync(cmd.SourceAccountId); + var dest = await query.FindByIdAsync(cmd.DestinationAccountId); + + if (source == null || dest == null) + { + return null; + } + + return new TransferContext(source, dest, timeProvider.GetUtcNow()); + } + + public static (IStorageAction SourceWrite, IStorageAction DestWrite, IStorageAction + JournalWrite, FundsTransferredEvent Event) Handle(TransferFundsCommand cmd, TransferContext context) + { + var updatedSource = context.Source.ApplyPosting(cmd.Amount, EntryType.Debit); + var updatedDest = context.Destination.ApplyPosting(cmd.Amount, EntryType.Credit); + + var lines = new List + { + new(context.Source.Id, cmd.Amount, EntryType.Debit), + new(context.Destination.Id, cmd.Amount, EntryType.Credit) + }; + + var journalEntry = BalancedJournal.Create( + Guid.NewGuid(), + $"Transfer: {cmd.Amount} from {cmd.SourceAccountId} to {cmd.DestinationAccountId}", + context.TimeStamp, + lines + ); + + var @event = new FundsTransferredEvent(cmd.SourceAccountId, cmd.DestinationAccountId, cmd.Amount); + + return (Storage.Update(updatedSource), Storage.Update(updatedDest), Storage.Insert(journalEntry.Entry), @event); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Interfaces/IAccountQuery.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Interfaces/IAccountQuery.cs new file mode 100644 index 0000000..b074d23 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Interfaces/IAccountQuery.cs @@ -0,0 +1,8 @@ +using FinancialApi.Domain.Entities; + +namespace FinancialApi.Application.Interfaces; + +public interface IAccountQuery +{ + Task FindByIdAsync(int id); +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Interfaces/IReversalQuery.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Interfaces/IReversalQuery.cs new file mode 100644 index 0000000..d663373 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Interfaces/IReversalQuery.cs @@ -0,0 +1,8 @@ +using FinancialApi.Domain.Entities; + +namespace FinancialApi.Application.Interfaces; + +public interface IReversalQuery +{ + Task<(BalancedJournalEntry?, List?)> GetReversalDataAsync(Guid journalEntryId); +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Models/FeeContext.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Models/FeeContext.cs new file mode 100644 index 0000000..bfc40f5 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Models/FeeContext.cs @@ -0,0 +1,5 @@ +using FinancialApi.Domain.Entities; + +namespace FinancialApi.Application.Models; + +public record FeeContext(Account Source, Account Destination, DateTimeOffset TimeStamp); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Models/JournalReversalContext.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Models/JournalReversalContext.cs new file mode 100644 index 0000000..9a9ed61 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Models/JournalReversalContext.cs @@ -0,0 +1,9 @@ +using FinancialApi.Domain.Entities; + +namespace FinancialApi.Application.Models; + +public record JournalReversalContext( + BalancedJournalEntry OriginalBalancedJournalEntry, + IReadOnlyList AffectedAccounts, + DateTimeOffset TimeStamp +); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Models/TransferContext.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Models/TransferContext.cs new file mode 100644 index 0000000..c517ed4 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Models/TransferContext.cs @@ -0,0 +1,5 @@ +using FinancialApi.Domain.Entities; + +namespace FinancialApi.Application.Models; + +public record TransferContext(Account Source, Account Destination, DateTimeOffset TimeStamp); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/AccountTests.cs b/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/AccountTests.cs new file mode 100644 index 0000000..c669abd --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/AccountTests.cs @@ -0,0 +1,55 @@ +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Exceptions; +using FluentAssertions; + +namespace FinancialApi.Domain.Tests; + +public class AccountTests +{ + [Theory] + [InlineData(AccountType.Borrow)] + public void CreateBorrowAccountType_ShouldAllowNegativeBalance(AccountType accountType) + { + var act = () => new Account(1, -100, accountType); + + act() + .Balance.Should() + .BeNegative(); + } + + [Theory] + [InlineData(AccountType.Revenue)] + [InlineData(AccountType.Spend)] + public void CreateDebitAccountType_ShouldNotAllowNegativeBalance(AccountType accountType) + { + var act = () => new Account(1, -100, accountType); + + act.Should() + .Throw(); + } + + [Theory] + [InlineData(AccountType.Borrow)] + public void GivenCreditAccountType_ShouldAllowNegativeBalance(AccountType accountType) + { + var account = new Account(1, 0, accountType); + + var updatedAccount = account.ApplyPosting(100, EntryType.Debit); + + updatedAccount.Balance.Should() + .BeNegative(); + } + + [Theory] + [InlineData(AccountType.Revenue)] + [InlineData(AccountType.Spend)] + public void GivenDebitAccountType_ShouldNotAllowNegativeBalance(AccountType accountType) + { + var account = new Account(1, 0, accountType); + + var act = () => account.ApplyPosting(100, EntryType.Debit); + + act.Should() + .Throw(); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/BalancedJournalEntryTests.cs b/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/BalancedJournalEntryTests.cs new file mode 100644 index 0000000..8ae24f9 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/BalancedJournalEntryTests.cs @@ -0,0 +1,54 @@ +using FinancialApi.Domain.Aggregates; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Exceptions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; + +namespace FinancialApi.Domain.Tests; + +public class BalancedJournalEntryTests +{ + [Fact] + public void GivenBalanceJournal_ShouldSucceed() + { + // Arrange + var timeProvider = new FakeTimeProvider(); + List lines = [new(1, 100, EntryType.Debit), new(1, 100, EntryType.Credit)]; + + // Act + var act = () => BalancedJournal.Create(Guid.NewGuid(), "Transfer", timeProvider.GetUtcNow(), lines); + + // Assert + act.Should() + .NotThrow(); + } + + [Fact] + public void GivenUnbalancedJournal_ShouldFail() + { + // Arrange + var timeProvider = new FakeTimeProvider(); + List lines = [new(1, 10, EntryType.Debit), new(1, 100, EntryType.Credit)]; + + // Act + var act = () => BalancedJournal.Create(Guid.NewGuid(), "Transfer", timeProvider.GetUtcNow(), lines); + + // Assert + act.Should() + .Throw(); + } + + [Fact] + public void GivenEmptyJournal_ShouldFail() + { + // Arrange + var timeProvider = new FakeTimeProvider(); + + // Act + var act = () => BalancedJournal.Create(Guid.NewGuid(), "Transfer", timeProvider.GetUtcNow(), []); + + // Assert + act.Should() + .Throw(); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/FinancialApi.Domain.Tests.csproj b/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/FinancialApi.Domain.Tests.csproj new file mode 100644 index 0000000..d2a332c --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain.Tests/FinancialApi.Domain.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\.nuget\packages\microsoft.extensions.timeprovider.testing\10.6.0\lib\net10.0\Microsoft.Extensions.TimeProvider.Testing.dll + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs new file mode 100644 index 0000000..8bc5af1 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs @@ -0,0 +1,46 @@ +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Exceptions; + +namespace FinancialApi.Domain.Aggregates; + +public record BalancedJournal +{ + public BalancedJournalEntry Entry { get; } + + private BalancedJournal(BalancedJournalEntry entry) + { + Entry = entry; + } + + public static BalancedJournal Create(Guid id, string description, DateTimeOffset createdAt, List lines) + { + if (lines.Count(l => l.Type == EntryType.Debit) == 0) + { + throw new GaapViolationException($"GAAP Violation: A journal entry needs at least one debit line."); + } + + if (lines.Count(l => l.Type == EntryType.Credit) == 0) + { + throw new GaapViolationException($"A journal entry needs at least one credit line."); + } + + var debits = lines.Where(l => l.Type == EntryType.Debit) + .Sum(l => l.Amount); + var credits = lines.Where(l => l.Type == EntryType.Credit) + .Sum(l => l.Amount); + + return debits != credits + ? throw new GaapViolationException($"Total Debits ({debits}) must equal Total Credits ({credits})!") + : new BalancedJournal(new BalancedJournalEntry(id, description, createdAt, lines)); + } + + public static BalancedJournal CreateReversal(BalancedJournalEntry original, string reason, DateTimeOffset now) + { + var reversedLines = original.Lines.Select(l => + l with { Type = l.Type == EntryType.Debit ? EntryType.Credit : EntryType.Debit } + ) + .ToList(); + + return Create(Guid.NewGuid(), reason, now, reversedLines); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs new file mode 100644 index 0000000..29d5c8f --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs @@ -0,0 +1,53 @@ +using FinancialApi.Domain.Exceptions; + +namespace FinancialApi.Domain.Entities; + +public enum AccountType +{ + Spend, + Borrow, + Revenue +} + +public record Account +{ + public Account(int Id, decimal Balance, AccountType Type) + { + this.Id = Id; + this.Balance = Balance; + this.Type = Type; + + if (Balance < 0 && Type is AccountType.Spend or AccountType.Revenue) + { + throw new InvalidStartingBalanceException("Insufficient funds."); + } + } + + public Account ApplyPosting(decimal amount, EntryType entryType) + { + if (amount <= 0) + { + throw new ArgumentException("Amount must be positive."); + } + + var newBalance = entryType == EntryType.Credit ? Balance + amount : Balance - amount; + + if (newBalance < 0 && Type is AccountType.Spend or AccountType.Revenue) + { + throw new InsufficientFundsException("Insufficient funds."); + } + + return this with { Balance = newBalance }; + } + + public int Id { get; init; } + public decimal Balance { get; init; } + public AccountType Type { get; init; } + + public void Deconstruct(out int Id, out decimal Balance, out AccountType Type) + { + Id = this.Id; + Balance = this.Balance; + Type = this.Type; + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/BalancedJournalEntry.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/BalancedJournalEntry.cs new file mode 100644 index 0000000..7e5cd8e --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/BalancedJournalEntry.cs @@ -0,0 +1,19 @@ +namespace FinancialApi.Domain.Entities; + +public record BalancedJournalEntry +{ + public Guid Id { get; init; } + public string Description { get; init; } = null!; + public DateTimeOffset CreatedAt { get; init; } + public List Lines { get; init; } = []; + + internal BalancedJournalEntry() { } + + internal BalancedJournalEntry(Guid id, string description, DateTimeOffset createdAt, List lines) + { + Lines = lines; + Id = id; + Description = description; + CreatedAt = createdAt; + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/EntryType.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/EntryType.cs new file mode 100644 index 0000000..491289e --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/EntryType.cs @@ -0,0 +1,7 @@ +namespace FinancialApi.Domain.Entities; + +public enum EntryType +{ + Debit, + Credit +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/JournalLine.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/JournalLine.cs new file mode 100644 index 0000000..03d4e3b --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/JournalLine.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Entities; + +public record JournalLine(int AccountId, decimal Amount, EntryType Type); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Events/FeeAppliedEvent.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/FeeAppliedEvent.cs new file mode 100644 index 0000000..5fdaa23 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/FeeAppliedEvent.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Events; + +public record FeeAppliedEvent(int AccountId, decimal Amount); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Events/FundsTransferredEvent.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/FundsTransferredEvent.cs new file mode 100644 index 0000000..8e28286 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/FundsTransferredEvent.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Events; + +public record FundsTransferredEvent(int SourceAccountId, int DestinationAccountId, decimal Amount); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Events/IEventTracker.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/IEventTracker.cs new file mode 100644 index 0000000..264d02f --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/IEventTracker.cs @@ -0,0 +1,6 @@ +namespace FinancialApi.Domain.Events; + +public interface IEventTracker +{ + void Add(string eventMessage); +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Events/JournalReversedEvent.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/JournalReversedEvent.cs new file mode 100644 index 0000000..cf33e38 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Events/JournalReversedEvent.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Events; + +public record JournalReversedEvent(Guid OriginalJournalId, Guid ReversalJournalId); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/GaapViolationException.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/GaapViolationException.cs new file mode 100644 index 0000000..9e44719 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/GaapViolationException.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Exceptions; + +public class GaapViolationException(string message) : Exception(message); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/InsufficientFundsException.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/InsufficientFundsException.cs new file mode 100644 index 0000000..f8500be --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/InsufficientFundsException.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Exceptions; + +public class InsufficientFundsException(string message) : GaapViolationException(message); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/InvalidStartingBalanceException.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/InvalidStartingBalanceException.cs new file mode 100644 index 0000000..34dabb9 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Exceptions/InvalidStartingBalanceException.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Domain.Exceptions; + +public class InvalidStartingBalanceException(string message) : GaapViolationException(message); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj b/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialResult.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialResult.cs new file mode 100644 index 0000000..1e27886 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialResult.cs @@ -0,0 +1,221 @@ +namespace FinancialApi.Domain; + +public interface IFinancialError +{ + int ErrorCode { get; } + string Message { get; } + Exception? Exception { get; } +} + +public sealed class FinancialError : IFinancialError +{ + public FinancialError(int errorCode, string message, Exception? exception = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(errorCode); + + ErrorCode = errorCode; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Exception = exception; + } + + public int ErrorCode { get; } + + public string Message { get; } + + public Exception? Exception { get; } + + public override string ToString() => $"Error: {ErrorCode} - {Message}{(Exception == null ? "" : $"\n{Exception}")}"; +} + +public sealed class FinancialResult +{ + private readonly T? _resultValue; + private readonly FinancialError? _resultError; + + public FinancialResult(T resultValue) + { + IsError = false; + _resultValue = resultValue; + _resultError = null; + } + + public FinancialResult(FinancialError resultError) + { + IsError = true; + _resultValue = default; + _resultError = resultError; + } + + public bool IsError { get; } + + /// + /// Error indicator + /// + public bool IsSuccess => !IsError; + + /// + /// Result Values + /// + public T? Value => _resultValue; + + public FinancialError? Error => _resultError; + + public static implicit operator FinancialResult(T resultValue) => new(resultValue); + + public static implicit operator FinancialResult(FinancialError resultError) => new(resultError); + + public static FinancialResult Success(T successValue) + { + return new FinancialResult(successValue); + } + + public static FinancialResult Failure(FinancialError failureValue) + { + return new FinancialResult(failureValue); + } + + public TResult Match( + Func success, + Func failure, + Func nullValue + ) + { + return IsError switch + { + false when _resultValue == null => nullValue(), + true => failure(_resultError!), + _ => success(_resultValue) + }; + } + + public TResult Match(Func success, Func failure) + { + return IsError switch + { + true => failure(_resultError!), + _ => success(_resultValue!) + }; + } + + public TResult? Match(Func success) + { + return IsError switch + { + true => default, + _ => success(_resultValue!) + }; + } + + public void Match(Action success, Action failure, Action nullValue) + { + switch (IsError) + { + case false when _resultValue == null: + nullValue.Invoke(); + return; + case true: + failure.Invoke(_resultError!); + return; + default: + success(_resultValue); + break; + } + } + + public void Match(Action success, Action failure) + { + switch (IsError) + { + case true: + failure.Invoke(_resultError!); + return; + default: + success(_resultValue!); + break; + } + } + + public void Match(Action success) + { + if (IsError) + { + return; + } + + success(_resultValue!); + } + + public async Task MatchAsync( + Func> success, + Func> failure, + Func> nullValue + ) + { + return IsError switch + { + false when _resultValue == null => await nullValue(), + true => await failure(_resultError!), + _ => await success(_resultValue) + }; + } + + public async Task MatchAsync( + Func> success, + Func> failure + ) + { + return IsError switch + { + true => await failure(_resultError!), + _ => await success(_resultValue!) + }; + } + + public async Task MatchAsync(Func> success) + { + return IsError switch + { + true => default, + _ => await success(_resultValue!) + }; + } + + public async Task MatchAsync(Func success, Func failure, Func nullValue) + { + switch (IsError) + { + case false when _resultValue == null: + await nullValue.Invoke(); + return; + case true: + await failure.Invoke(_resultError!); + return; + default: + await success(_resultValue!); + break; + } + } + + public async Task MatchAsync(Func success, Func failure) + { + switch (IsError) + { + case true: + await failure.Invoke(_resultError!); + return; + default: + await success(_resultValue!); + break; + } + } + + public async Task MatchAsync(Func success) + { + if (IsError) + { + return; + } + + await success(_resultValue!); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/ApplyFeeIntegrationTests.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/ApplyFeeIntegrationTests.cs new file mode 100644 index 0000000..8f89e2e --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/ApplyFeeIntegrationTests.cs @@ -0,0 +1,35 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Interfaces; +using FinancialApi.Domain.Entities; +using FinancialApi.Domain.Events; +using FluentAssertions; +using Wolverine; +using Wolverine.Tracking; + +namespace FinancialApi.Infrastructure.Tests; + +internal class FakeAccountQuery : IAccountQuery +{ + public Task FindByIdAsync(int id) => Task.FromResult(new Account(id, 100.00m, AccountType.Spend))!; +} + +public class ApplyFeeIntegrationTests(WolverineTestFixture fixture) : IClassFixture +{ + [Fact] + public async Task GivenValidCommand_WhenApplyingFee_ShouldPublishFeeAppliedEvent() + { + // Arrange + var command = new ApplyFeeCommand(1, 100); + + // Act + var session = await fixture.Host.InvokeMessageAndWaitAsync(command); + + // Assert + session.Sent.AllMessages() + .ShouldHaveMessageOfType(); + session.Sent.MessagesOf() + .Count() + .Should() + .Be(1); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/FinancialApi.Infrastructure.Tests.csproj b/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/FinancialApi.Infrastructure.Tests.csproj new file mode 100644 index 0000000..4a800f9 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/FinancialApi.Infrastructure.Tests.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\.nuget\packages\microsoft.extensions.hosting\10.0.0\lib\net10.0\Microsoft.Extensions.Hosting.dll + + + + ..\..\..\..\..\..\.nuget\packages\microsoft.extensions.timeprovider.testing\10.6.0\lib\net10.0\Microsoft.Extensions.TimeProvider.Testing.dll + + + + + + + + + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/WolverineTestFixture.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/WolverineTestFixture.cs new file mode 100644 index 0000000..91e3b3e --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure.Tests/WolverineTestFixture.cs @@ -0,0 +1,34 @@ +using FinancialApi.Application.Handlers; +using FinancialApi.Application.Interfaces; +using FinancialApi.Domain.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using Wolverine; + +namespace FinancialApi.Infrastructure.Tests; + +public class WolverineTestFixture : IDisposable +{ + public IHost Host { get; } + + public WolverineTestFixture() + { + // We build and start the host ONCE here + Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .ConfigureServices((context, services) => + { + services.AddSingleton(_ => new FakeAccountQuery()); + services.AddSingleton(_ => new FakeTimeProvider()); + services.AddSingleton(_ => new DemoEventStore()); + } + ) + .UseWolverine(opts => { opts.Discovery.IncludeAssembly(typeof(ApplyFeeHandler).Assembly); }) + .Start(); + } + + public void Dispose() + { + Host.Dispose(); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs new file mode 100644 index 0000000..5ed211a --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs @@ -0,0 +1,42 @@ +using FinancialApi.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinancialApi.Infrastructure; + +public class AccountDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Accounts => Set(); + public DbSet JournalEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(x => x.Id); + + modelBuilder.Entity() + .HasData( + new Account(1, 10000000.00m, AccountType.Borrow), + new Account(2, 50000000.00m, AccountType.Spend), + new Account(99999, 0.00m, AccountType.Revenue) + ); + + modelBuilder.Entity(builder => + { + builder.HasKey(x => x.Id); + builder.OwnsMany( + x => x.Lines, + line => + { + line.WithOwner() + .HasForeignKey("JournalEntryId"); + line.Property("Id") + .ValueGeneratedOnAdd(); + line.HasKey("Id"); + line.Property(x => x.Type) + .HasConversion(); + } + ); + } + ); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs new file mode 100644 index 0000000..13901db --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs @@ -0,0 +1,14 @@ +using FinancialApi.Application.Interfaces; +using FinancialApi.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinancialApi.Infrastructure; + +public class AccountQuery(AccountDbContext db) : IAccountQuery +{ + public async Task FindByIdAsync(int id) + { + return await db.Accounts.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/DemoEventStore.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/DemoEventStore.cs new file mode 100644 index 0000000..c00b0be --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/DemoEventStore.cs @@ -0,0 +1,13 @@ +using FinancialApi.Domain.Events; + +namespace FinancialApi.Infrastructure; + +public class DemoEventStore : IEventTracker +{ + public List CapturedEvents { get; } = new(); + + public void Add(string eventMessage) + { + CapturedEvents.Add($"[{DateTimeOffset.UtcNow:HH:mm:ss}] {eventMessage}"); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj new file mode 100644 index 0000000..af19738 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260713152041_InitialCreate.Designer.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260713152041_InitialCreate.Designer.cs new file mode 100644 index 0000000..b101970 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260713152041_InitialCreate.Designer.cs @@ -0,0 +1,50 @@ +// +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20260713152041_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Application.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + Balance = 100.00m + }, + new + { + Id = 2, + Balance = 500.00m + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260713152041_InitialCreate.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260713152041_InitialCreate.cs new file mode 100644 index 0000000..b5cfec2 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260713152041_InitialCreate.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace FinancialApi.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Balance = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.Id); + }); + + migrationBuilder.InsertData( + table: "Accounts", + columns: new[] { "Id", "Balance" }, + values: new object[,] + { + { 1, 100.00m }, + { 2, 500.00m } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Accounts"); + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715093924_AddJournal.Designer.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715093924_AddJournal.Designer.cs new file mode 100644 index 0000000..b67f0c2 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715093924_AddJournal.Designer.cs @@ -0,0 +1,115 @@ +// +using System; +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20260715093924_AddJournal")] + partial class AddJournal + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Application.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + AccountType = "Credit", + Balance = 100.00m + }, + new + { + Id = 2, + AccountType = "Liability", + Balance = 500.00m + }, + new + { + Id = 99999, + AccountType = "Revenue", + Balance = 0.00m + }); + }); + + modelBuilder.Entity("FinancialApi.Application.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("FinancialApi.Application.JournalEntry", b => + { + b.OwnsMany("FinancialApi.Application.JournalLine", "Lines", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("AccountId") + .HasColumnType("INTEGER"); + + b1.Property("Amount") + .HasColumnType("TEXT"); + + b1.Property("JournalEntryId") + .HasColumnType("TEXT"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("JournalEntryId"); + + b1.ToTable("JournalLine"); + + b1.WithOwner() + .HasForeignKey("JournalEntryId"); + }); + + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715093924_AddJournal.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715093924_AddJournal.cs new file mode 100644 index 0000000..316b75c --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715093924_AddJournal.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + /// + public partial class AddJournal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccountType", + table: "Accounts", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "JournalEntries", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_JournalEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "JournalLine", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccountId = table.Column(type: "INTEGER", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", nullable: false), + JournalEntryId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_JournalLine", x => x.Id); + table.ForeignKey( + name: "FK_JournalLine_JournalEntries_JournalEntryId", + column: x => x.JournalEntryId, + principalTable: "JournalEntries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "AccountType", + value: "Credit"); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 2, + column: "AccountType", + value: "Liability"); + + migrationBuilder.InsertData( + table: "Accounts", + columns: new[] { "Id", "AccountType", "Balance" }, + values: new object[] { 99999, "Revenue", 0.00m }); + + migrationBuilder.CreateIndex( + name: "IX_JournalLine_JournalEntryId", + table: "JournalLine", + column: "JournalEntryId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JournalLine"); + + migrationBuilder.DropTable( + name: "JournalEntries"); + + migrationBuilder.DeleteData( + table: "Accounts", + keyColumn: "Id", + keyValue: 99999); + + migrationBuilder.DropColumn( + name: "AccountType", + table: "Accounts"); + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs new file mode 100644 index 0000000..00ec55f --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs @@ -0,0 +1,115 @@ +// +using System; +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20260715100428_UpdateAccountBalances")] + partial class UpdateAccountBalances + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Application.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + AccountType = "Credit", + Balance = 10000000.00m + }, + new + { + Id = 2, + AccountType = "Liability", + Balance = 50000000.00m + }, + new + { + Id = 99999, + AccountType = "Revenue", + Balance = 0.00m + }); + }); + + modelBuilder.Entity("FinancialApi.Application.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("FinancialApi.Application.JournalEntry", b => + { + b.OwnsMany("FinancialApi.Application.JournalLine", "Lines", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("AccountId") + .HasColumnType("INTEGER"); + + b1.Property("Amount") + .HasColumnType("TEXT"); + + b1.Property("JournalEntryId") + .HasColumnType("TEXT"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("JournalEntryId"); + + b1.ToTable("JournalLine"); + + b1.WithOwner() + .HasForeignKey("JournalEntryId"); + }); + + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.cs new file mode 100644 index 0000000..5cb1170 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + /// + public partial class UpdateAccountBalances : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "Balance", + value: 10000000.00m); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 2, + column: "Balance", + value: 50000000.00m); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "Balance", + value: 100.00m); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 2, + column: "Balance", + value: 500.00m); + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260729103021_AccountTypes.Designer.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260729103021_AccountTypes.Designer.cs new file mode 100644 index 0000000..76231fa --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260729103021_AccountTypes.Designer.cs @@ -0,0 +1,114 @@ +// +using System; +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20260729103021_AccountTypes")] + partial class AccountTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Domain.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + Balance = 10000000.00m, + Type = 2 + }, + new + { + Id = 2, + Balance = 50000000.00m, + Type = 0 + }, + new + { + Id = 99999, + Balance = 0.00m, + Type = 3 + }); + }); + + modelBuilder.Entity("FinancialApi.Domain.Entities.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("FinancialApi.Domain.Entities.JournalEntry", b => + { + b.OwnsMany("FinancialApi.Domain.Entities.JournalLine", "Lines", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("AccountId") + .HasColumnType("INTEGER"); + + b1.Property("Amount") + .HasColumnType("TEXT"); + + b1.Property("JournalEntryId") + .HasColumnType("TEXT"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("JournalEntryId"); + + b1.ToTable("JournalLine"); + + b1.WithOwner() + .HasForeignKey("JournalEntryId"); + }); + + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260729103021_AccountTypes.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260729103021_AccountTypes.cs new file mode 100644 index 0000000..2e06369 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260729103021_AccountTypes.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + /// + public partial class AccountTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AccountType", + table: "Accounts"); + + migrationBuilder.AddColumn( + name: "Type", + table: "Accounts", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "Type", + value: 2); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 2, + column: "Type", + value: 0); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 99999, + column: "Type", + value: 3); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "Accounts"); + + migrationBuilder.AddColumn( + name: "AccountType", + table: "Accounts", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "AccountType", + value: "Credit"); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 2, + column: "AccountType", + value: "Liability"); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 99999, + column: "AccountType", + value: "Revenue"); + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260731073046_UpdateAccountTypes.Designer.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260731073046_UpdateAccountTypes.Designer.cs new file mode 100644 index 0000000..8601062 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260731073046_UpdateAccountTypes.Designer.cs @@ -0,0 +1,114 @@ +// +using System; +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20260731073046_UpdateAccountTypes")] + partial class UpdateAccountTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Domain.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + Balance = 10000000.00m, + Type = 1 + }, + new + { + Id = 2, + Balance = 50000000.00m, + Type = 0 + }, + new + { + Id = 99999, + Balance = 0.00m, + Type = 2 + }); + }); + + modelBuilder.Entity("FinancialApi.Domain.Entities.BalancedJournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("FinancialApi.Domain.Entities.BalancedJournalEntry", b => + { + b.OwnsMany("FinancialApi.Domain.Entities.JournalLine", "Lines", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("AccountId") + .HasColumnType("INTEGER"); + + b1.Property("Amount") + .HasColumnType("TEXT"); + + b1.Property("JournalEntryId") + .HasColumnType("TEXT"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("JournalEntryId"); + + b1.ToTable("JournalLine"); + + b1.WithOwner() + .HasForeignKey("JournalEntryId"); + }); + + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260731073046_UpdateAccountTypes.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260731073046_UpdateAccountTypes.cs new file mode 100644 index 0000000..ce3ebb9 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260731073046_UpdateAccountTypes.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + /// + public partial class UpdateAccountTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "Type", + value: 1); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 99999, + column: "Type", + value: 2); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 1, + column: "Type", + value: 2); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 99999, + column: "Type", + value: 3); + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/AccountDbContextModelSnapshot.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/AccountDbContextModelSnapshot.cs new file mode 100644 index 0000000..51fd1ec --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/AccountDbContextModelSnapshot.cs @@ -0,0 +1,111 @@ +// +using System; +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + partial class AccountDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Domain.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + Balance = 10000000.00m, + Type = 1 + }, + new + { + Id = 2, + Balance = 50000000.00m, + Type = 0 + }, + new + { + Id = 99999, + Balance = 0.00m, + Type = 2 + }); + }); + + modelBuilder.Entity("FinancialApi.Domain.Entities.BalancedJournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("FinancialApi.Domain.Entities.BalancedJournalEntry", b => + { + b.OwnsMany("FinancialApi.Domain.Entities.JournalLine", "Lines", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("AccountId") + .HasColumnType("INTEGER"); + + b1.Property("Amount") + .HasColumnType("TEXT"); + + b1.Property("JournalEntryId") + .HasColumnType("TEXT"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("JournalEntryId"); + + b1.ToTable("JournalLine"); + + b1.WithOwner() + .HasForeignKey("JournalEntryId"); + }); + + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ReversalQuery.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ReversalQuery.cs new file mode 100644 index 0000000..2b0a51b --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ReversalQuery.cs @@ -0,0 +1,30 @@ +using FinancialApi.Application.Interfaces; +using FinancialApi.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinancialApi.Infrastructure; + +public class ReversalQuery(AccountDbContext db) : IReversalQuery +{ + public async Task<(BalancedJournalEntry?, List?)> GetReversalDataAsync(Guid journalEntryId) + { + var entry = await db.JournalEntries.Include(x => x.Lines) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == journalEntryId); + + if (entry == null) + { + return (null, null); + } + + var accountIds = entry.Lines.Select(l => l.AccountId) + .Distinct() + .ToList(); + + var accounts = await db.Accounts.AsNoTracking() + .Where(a => accountIds.Contains(a.Id)) + .ToListAsync(); + + return (entry, accounts); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/Extensions.cs b/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..7f05ac7 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/Extensions.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace FinancialApi.ServiceDefaults; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + } + ); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + } + ); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + } + ) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) && + !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + } + ); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry() + .UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks( + AlivenessEndpointPath, + new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") } + ); + } + + return app; + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj b/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj new file mode 100644 index 0000000..37c4266 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs b/wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs new file mode 100644 index 0000000..35cd90e --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs @@ -0,0 +1,33 @@ +using FinancialApi.Domain.Exceptions; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace FinancialApi; + +public class DomainExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + if (exception is not (InvalidOperationException or ArgumentException or GaapViolationException)) + { + return false; + } + + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Domain Validation Error", + Detail = exception.Message, + Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1" + }; + + httpContext.Response.StatusCode = problemDetails.Status.Value; + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj new file mode 100644 index 0000000..481994a --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http new file mode 100644 index 0000000..e6ba523 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http @@ -0,0 +1,38 @@ +@FinancialApi_HostAddress = https://localhost:7010 + +### Apply Fee to an Account + +POST {{FinancialApi_HostAddress}}/accounts/apply-fee +Accept: application/json +Content-Type: application/json + +{ + "accountId": 2, + "amount": 100 +} + +### Transfer Between Accounts + +POST {{FinancialApi_HostAddress}}/accounts/transfer +Content-Type: application/json + +{ + "sourceAccountId": 1, + "destinationAccountId": 2, + "amount": 1000.00 +} + +### Reverse a Journal Entry + +POST {{FinancialApi_HostAddress}}/journal/reverse +Content-Type: application/json + +{ + "originalJournalId": "E7FB25DB-406F-4063-A4B0-DE6BB8292437", + "reason": "Incorrect Account Fee" +} + +### Events + +GET {{FinancialApi_HostAddress}}/events +Content-Type: application/json \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi/Program.cs b/wolverine/a-frame-architecture/FinancialApi/Program.cs new file mode 100644 index 0000000..d08c147 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/Program.cs @@ -0,0 +1,103 @@ +using FinancialApi; +using FinancialApi.Application.Commands; +using FinancialApi.Application.Handlers; +using FinancialApi.Application.Interfaces; +using FinancialApi.Domain.Events; +using FinancialApi.Infrastructure; +using FinancialApi.ServiceDefaults; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.Sqlite; + + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + +var connectionString = "Data Source=demo.db"; + +builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +builder.AddServiceDefaults(); + +builder.Host.UseWolverine(opts => + { + opts.Discovery.IncludeAssembly(typeof(DemoEventStore).Assembly); + opts.Discovery.IncludeAssembly(typeof(ApplyFeeHandler).Assembly); + opts.PersistMessagesWithSqlite(connectionString); + opts.UseEntityFrameworkCoreTransactions(); + opts.Policies.AutoApplyTransactions(); + } +); +builder.Host.UseResourceSetupOnStartup(); + +var app = builder.Build(); + +app.UseExceptionHandler(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.MapPost( + "/accounts/apply-fee", + async (ApplyFeeCommand cmd, IMessageBus bus) => + { + await bus.InvokeAsync(cmd); + return Results.Accepted(); + } +); + +app.MapPost( + "/accounts/transfer", + async (TransferFundsCommand cmd, IMessageBus bus) => + { + await bus.InvokeAsync(cmd); + return Results.Accepted(); + } +); + +app.MapPost( + "/journal/reverse", + async (ReverseJournalCommand cmd, IMessageBus bus) => + { + await bus.InvokeAsync(cmd); + return Results.Accepted(); + } +); + +app.MapGet( + "/events", + (IEventTracker tracker) => + { + if (tracker is DemoEventStore store) + { + return Results.Ok(store.CapturedEvents); + } + + return Results.NotFound(); + } +); + +app.Run(); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi/Properties/launchSettings.json b/wolverine/a-frame-architecture/FinancialApi/Properties/launchSettings.json new file mode 100644 index 0000000..a91c327 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5115", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7010;http://localhost:5115", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi/appsettings.Development.json b/wolverine/a-frame-architecture/FinancialApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi/appsettings.json b/wolverine/a-frame-architecture/FinancialApi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/wolverine/a-frame-architecture/a-frame-architecture.sln b/wolverine/a-frame-architecture/a-frame-architecture.sln new file mode 100644 index 0000000..03833ff --- /dev/null +++ b/wolverine/a-frame-architecture/a-frame-architecture.sln @@ -0,0 +1,58 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.ServiceDefaults", "FinancialApi.ServiceDefaults\FinancialApi.ServiceDefaults.csproj", "{D9767D77-0B2D-4653-9B9C-A0E6A3850EA3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi", "FinancialApi\FinancialApi.csproj", "{9EFD8279-869B-485C-BA02-C983E96348A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Application", "FinancialApi.Application\FinancialApi.Application.csproj", "{B7EB1ECE-9F7F-440B-8818-28A127A99915}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Infrastructure", "FinancialApi.Infrastructure\FinancialApi.Infrastructure.csproj", "{6AB36AB5-05BD-41C8-9498-63EBDFAF8114}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Domain", "FinancialApi.Domain\FinancialApi.Domain.csproj", "{2DB493D2-1C96-4F7C-9AB6-2846A92860B9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Application.UnitTests", "FinancialApi.Application.UnitTests\FinancialApi.Application.UnitTests.csproj", "{93DBA3AA-3C03-431C-A41C-DDADFAC964D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Domain.Tests", "FinancialApi.Domain.Tests\FinancialApi.Domain.Tests.csproj", "{C15E3FE0-6E3D-4FFC-892E-D469536ADC13}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Infrastructure.Tests", "FinancialApi.Infrastructure.Tests\FinancialApi.Infrastructure.Tests.csproj", "{8C1BA932-16F2-42B3-A07C-C589C31044B3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D9767D77-0B2D-4653-9B9C-A0E6A3850EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9767D77-0B2D-4653-9B9C-A0E6A3850EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9767D77-0B2D-4653-9B9C-A0E6A3850EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9767D77-0B2D-4653-9B9C-A0E6A3850EA3}.Release|Any CPU.Build.0 = Release|Any CPU + {9EFD8279-869B-485C-BA02-C983E96348A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EFD8279-869B-485C-BA02-C983E96348A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EFD8279-869B-485C-BA02-C983E96348A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EFD8279-869B-485C-BA02-C983E96348A0}.Release|Any CPU.Build.0 = Release|Any CPU + {B7EB1ECE-9F7F-440B-8818-28A127A99915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7EB1ECE-9F7F-440B-8818-28A127A99915}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7EB1ECE-9F7F-440B-8818-28A127A99915}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7EB1ECE-9F7F-440B-8818-28A127A99915}.Release|Any CPU.Build.0 = Release|Any CPU + {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Release|Any CPU.Build.0 = Release|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Release|Any CPU.Build.0 = Release|Any CPU + {93DBA3AA-3C03-431C-A41C-DDADFAC964D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93DBA3AA-3C03-431C-A41C-DDADFAC964D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93DBA3AA-3C03-431C-A41C-DDADFAC964D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93DBA3AA-3C03-431C-A41C-DDADFAC964D5}.Release|Any CPU.Build.0 = Release|Any CPU + {C15E3FE0-6E3D-4FFC-892E-D469536ADC13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C15E3FE0-6E3D-4FFC-892E-D469536ADC13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C15E3FE0-6E3D-4FFC-892E-D469536ADC13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C15E3FE0-6E3D-4FFC-892E-D469536ADC13}.Release|Any CPU.Build.0 = Release|Any CPU + {8C1BA932-16F2-42B3-A07C-C589C31044B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C1BA932-16F2-42B3-A07C-C589C31044B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C1BA932-16F2-42B3-A07C-C589C31044B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C1BA932-16F2-42B3-A07C-C589C31044B3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/wolverine/a-frame-architecture/a-frame-architecture.sln.DotSettings.user b/wolverine/a-frame-architecture/a-frame-architecture.sln.DotSettings.user new file mode 100644 index 0000000..d52150c --- /dev/null +++ b/wolverine/a-frame-architecture/a-frame-architecture.sln.DotSettings.user @@ -0,0 +1,15 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + \ No newline at end of file diff --git a/wolverine/a-frame-architecture/mise.toml b/wolverine/a-frame-architecture/mise.toml new file mode 100644 index 0000000..a7a0331 --- /dev/null +++ b/wolverine/a-frame-architecture/mise.toml @@ -0,0 +1,20 @@ +[tools] +dotnet = "10" + +[tasks.restore] +description = "Restore the Project" +run = "dotnet restore" + +[tasks.build] +depends = ["restore"] +description = "Build the Project" +run = "dotnet build" + +[tasks.clean] +run = "fd -I \"bin|obj\" -td -X rm -r && dotnet clean" + +[tasks.migrate] +usage = ''' +arg "" help="Migration Name" +''' +run = "dotnet ef migrations add \"${usage_name?}\" --startup-project FinancialApi --project FinancialApi.Infrastructure" \ No newline at end of file