diff --git a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/AccountTests.cs b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/AccountTests.cs new file mode 100644 index 0000000..7c39c9a --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/AccountTests.cs @@ -0,0 +1,33 @@ +using FinancialApi.Domain.Entities; +using FluentAssertions; + +namespace FinancialApi.Application.UnitTests; + +public class AccountTests +{ + [Theory] + [InlineData(AccountType.Credit)] + 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.Debit)] + [InlineData(AccountType.Revenue)] + [InlineData(AccountType.Liability)] + 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.Application.UnitTests/ApplyFeeHandlerTests.cs b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyFeeHandlerTests.cs index f89a191..0aead9d 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyFeeHandlerTests.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyFeeHandlerTests.cs @@ -16,8 +16,8 @@ public class ApplyFeeHandlerTests { // Arrange var command = new ApplyFeeCommand(1, 100); - var account = new Account(1, 100, "Debit"); - var revenueAccount = new Account(99999, 0, "Credit"); + var account = new Account(1, 100, AccountType.Debit); + var revenueAccount = new Account(99999, 0, AccountType.Credit); var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow()); // Act @@ -36,4 +36,110 @@ public class ApplyFeeHandlerTests intents.Event.Amount.Should() .Be(command.Amount); } + + 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 GivenStartingBalanceGreaterThanFee_ApplyFee_ShouldBeExpectedBalance( + decimal startingBalance, + decimal feeAmount, + decimal expectedBalance + ) + { + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Liability); + var revenueAccount = new Account(99999, 0, AccountType.Credit); + var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow()); + + // Act + var intents = ApplyFeeHandler.Handle(command, pair); + + // Assert + using var _ = new AssertionScope(); + intents.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) + { + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Liability); + var revenueAccount = new Account(99999, 0, AccountType.Credit); + var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow()); + + // Act + var act = () => ApplyFeeHandler.Handle(command, pair); + + // Assert + using var _ = new AssertionScope(); + act.Should() + .Throw(); + account.Balance.Should() + .Be(startingBalance); + revenueAccount.Balance.Should() + .Be(0); + } + } + + 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 + ) + { + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Credit); + var revenueAccount = new Account(99999, 0, AccountType.Credit); + var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow()); + + // Act + var intents = ApplyFeeHandler.Handle(command, pair); + + // Assert + using var _ = new AssertionScope(); + intents.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 + ) + { + var command = new ApplyFeeCommand(1, feeAmount); + var account = new Account(1, startingBalance, AccountType.Credit); + var revenueAccount = new Account(99999, 0, AccountType.Credit); + var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow()); + + // Act + var intents = ApplyFeeHandler.Handle(command, pair); + + // Assert + using var _ = new AssertionScope(); + intents.CustomerWrite.Entity.Balance.Should() + .Be(expectedBalance); + } + } } \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyTransferHandlerTests.cs b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyTransferHandlerTests.cs new file mode 100644 index 0000000..4e1a960 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/ApplyTransferHandlerTests.cs @@ -0,0 +1,37 @@ +using FinancialApi.Application.Commands; +using FinancialApi.Application.Handlers; +using FinancialApi.Application.Models; +using FinancialApi.Domain.Entities; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.Time.Testing; + +namespace FinancialApi.Application.UnitTests; + +public class ApplyTransferHandlerTests +{ + public class ApplyFeeHandlerTests + { + [Fact] + public void GivenValidAccounts_BasicFee_ShouldValidateEverything() + { + // Arrange + var fakeTimeProvider = new FakeTimeProvider(); + + var command = new TransferFundsCommand(1, 2, 100); + var sourceAccount = new Account(1, 100, AccountType.Liability); + var destAccount = new Account(1, 100, AccountType.Liability); + var transferContext = new TransferContext(sourceAccount, destAccount, fakeTimeProvider.GetUtcNow()); + + // Act + var intents = TransferFundsHandler.Handle(command, transferContext); + + // Assert + using var _ = new AssertionScope(); + intents.SourceWrite.Entity.Balance.Should() + .Be(0); + intents.DestWrite.Entity.Balance.Should() + .Be(200); + } + } +} \ 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 index 6bd4a6c..59ef367 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/FinancialApi.Application.UnitTests.csproj +++ b/wolverine/a-frame-architecture/FinancialApi.Application.UnitTests/FinancialApi.Application.UnitTests.csproj @@ -9,9 +9,9 @@ - - - + + + @@ -22,13 +22,13 @@ - + - - ..\..\..\..\..\..\.nuget\packages\wolverinefx\6.18.0\lib\net10.0\Wolverine.dll - + + ..\..\..\..\..\..\.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/Commands/TransferFundsCommand.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/TransferFundsCommand.cs index 166a158..6f39544 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/Commands/TransferFundsCommand.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Commands/TransferFundsCommand.cs @@ -1,3 +1,3 @@ namespace FinancialApi.Application.Commands; -public abstract record TransferFundsCommand(int SourceAccountId, int DestinationAccountId, decimal Amount); \ No newline at end of file +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 index 94e1287..53a91b8 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj +++ b/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj @@ -7,16 +7,16 @@ - + - - ..\..\..\..\..\..\.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 - + + ..\..\..\..\..\..\.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 index 88447fd..b046064 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ApplyFeeHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/ApplyFeeHandler.cs @@ -31,13 +31,10 @@ public static class ApplyFeeHandler { var updatedSource = context.Source.ApplyPosting(cmd.Amount, EntryType.Debit); var updatedRevenue = context.Destination.ApplyPosting(cmd.Amount, EntryType.Credit); - var serviceFee = 0.5m; var lines = new List { - new(updatedSource.Id, cmd.Amount, EntryType.Debit), - new(updatedRevenue.Id, cmd.Amount - serviceFee, EntryType.Credit), - new(updatedRevenue.Id, serviceFee, EntryType.Credit) + new(updatedSource.Id, cmd.Amount, EntryType.Debit), new(updatedRevenue.Id, cmd.Amount, EntryType.Credit) }; var journalEntry = BalancedJournal.Create( diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs index 33ee246..d112af5 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Handlers/TransferFundsHandler.cs @@ -20,7 +20,10 @@ public static class TransferFundsHandler var source = await query.FindByIdAsync(cmd.SourceAccountId); var dest = await query.FindByIdAsync(cmd.DestinationAccountId); - if (source == null || dest == null) return null; + if (source == null || dest == null) + { + return null; + } return new TransferContext(source, dest, timeProvider.GetUtcNow()); } @@ -28,8 +31,8 @@ public static class TransferFundsHandler public static ( IStorageAction SourceWrite, IStorageAction DestWrite, IStorageAction JournalWrite, FundsTransferredEvent Event ) Handle(TransferFundsCommand cmd, TransferContext context) { - var updatedSource = context.Source.Debit(cmd.Amount); - var updatedDest = context.Destination.Credit(cmd.Amount); + var updatedSource = context.Source.ApplyPosting(cmd.Amount, EntryType.Debit); + var updatedDest = context.Destination.ApplyPosting(cmd.Amount, EntryType.Credit); var lines = new List { diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs index 944e5aa..de4ccb4 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Aggregates/BalancedJournal.cs @@ -6,7 +6,10 @@ public record BalancedJournal { public JournalEntry Entry { get; } - private BalancedJournal(JournalEntry entry) => Entry = entry; + private BalancedJournal(JournalEntry entry) + { + Entry = entry; + } public static BalancedJournal Create(Guid id, string description, DateTimeOffset createdAt, List lines) { @@ -22,7 +25,7 @@ public record BalancedJournal ); } - return new(new JournalEntry(id, description, createdAt, lines)); + return new BalancedJournal(new JournalEntry(id, description, createdAt, lines)); } public static BalancedJournal CreateReversal(JournalEntry original, string reason, DateTimeOffset now) diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs index b4531de..361efac 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Entities/Account.cs @@ -1,54 +1,32 @@ namespace FinancialApi.Domain.Entities; -public record Account(int Id, decimal Balance, string AccountType) +public enum AccountType +{ + Liability, + Debit, + Credit, + Revenue +} + +public record Account(int Id, decimal Balance, AccountType Type) { public Account ApplyPosting(decimal amount, EntryType entryType) { if (amount <= 0) { - throw new ArgumentException("Credit amount must be positive."); + throw new ArgumentException("Amount must be positive."); } - var newBalance = Balance; - if (AccountType == "Revenue") - { - newBalance = entryType == EntryType.Credit ? newBalance + amount : newBalance - amount; - } - else - { - newBalance = entryType == EntryType.Debit ? newBalance - amount : newBalance + amount; - } + var newBalance = entryType == EntryType.Credit ? Balance + amount : Balance - amount; + ; - if (newBalance < 0 && AccountType == "Liability") + if (newBalance < 0 && Type is AccountType.Liability or AccountType.Debit or AccountType.Revenue) { - throw new InvalidOperationException("Insufficient funds."); + throw new InsufficientFundsException("Insufficient funds."); } return this with { Balance = newBalance }; } +} - public Account Debit(decimal amount) - { - if (amount <= 0) - { - throw new ArgumentException("Debit amount must be positive."); - } - - if (Balance - amount < 0) - { - throw new InvalidOperationException("Insufficient funds."); - } - - return this with { Balance = Balance - amount }; - } - - public Account Credit(decimal amount) - { - if (amount <= 0) - { - throw new ArgumentException("Credit amount must be positive."); - } - - return this with { Balance = Balance + amount }; - } -} \ No newline at end of file +public class InsufficientFundsException(string insufficientFunds) : Exception(insufficientFunds); \ 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 index 06cfb42..45ad50a 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs @@ -16,9 +16,9 @@ public class AccountDbContext(DbContextOptions options) : DbCo modelBuilder.Entity() .HasData( - new Account(1, 10000000.00m, "Credit"), - new Account(2, 50000000.00m, "Liability"), - new Account(99999, 0.00m, "Revenue") + new Account(1, 10000000.00m, AccountType.Credit), + new Account(2, 50000000.00m, AccountType.Liability), + new Account(99999, 0.00m, AccountType.Revenue) ); modelBuilder.Entity(builder => diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj index ccd540f..af19738 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj @@ -7,16 +7,16 @@ - - + + - - - - - + + + + + diff --git a/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj b/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj index 72d16e2..37c4266 100644 --- a/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj +++ b/wolverine/a-frame-architecture/FinancialApi.ServiceDefaults/FinancialApi.ServiceDefaults.csproj @@ -8,15 +8,15 @@ - + - - - - - - - + + + + + + + diff --git a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj index b70990c..481994a 100644 --- a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj +++ b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.csproj @@ -7,16 +7,16 @@ - - - - - + + + + + - - + + diff --git a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http index 85ee0e2..3a5d97a 100644 --- a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http +++ b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http @@ -7,8 +7,8 @@ Accept: application/json Content-Type: application/json { - "accountId": 1, - "amount": 50.0 + "accountId": 1, + "amount": 50.0 } ### Transfer Between Accounts @@ -17,9 +17,9 @@ POST {{FinancialApi_HostAddress}}/accounts/transfer Content-Type: application/json { - "sourceAccountId": 1, - "destinationAccountId": 2, - "amount": 1000.00 + "sourceAccountId": 1, + "destinationAccountId": 2, + "amount": 1000.00 } ### Reverse a Journal Entry @@ -28,8 +28,8 @@ POST {{FinancialApi_HostAddress}}/journal/reverse Content-Type: application/json { - "originalJournalId": "FD2BFD8E-7C85-4CAD-A12B-CCBCD7C12B4A", - "reason": "Incorrect Account Fee" + "originalJournalId": "B65EB28C-7EC1-4B70-9069-06A61A4FE857", + "reason": "Incorrect Account Fee" } ### Events