Unit test improvements.
This commit is contained in:
+20
-37
@@ -2,39 +2,15 @@
|
|||||||
using FinancialApi.Application.Handlers;
|
using FinancialApi.Application.Handlers;
|
||||||
using FinancialApi.Application.Models;
|
using FinancialApi.Application.Models;
|
||||||
using FinancialApi.Domain.Entities;
|
using FinancialApi.Domain.Entities;
|
||||||
|
using FinancialApi.Domain.Exceptions;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using FluentAssertions.Execution;
|
using FluentAssertions.Execution;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
|
||||||
namespace FinancialApi.Application.UnitTests;
|
namespace FinancialApi.Application.UnitTests;
|
||||||
|
|
||||||
public class ApplyFeeHandlerTests
|
public class ApplyFeeHandlerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
|
||||||
public void GivenValidAccounts_BasicFee_ShouldValidateEverything()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var command = new ApplyFeeCommand(1, 100);
|
|
||||||
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
|
|
||||||
var intents = ApplyFeeHandler.Handle(command, pair);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
using var _ = new AssertionScope();
|
|
||||||
intents.CustomerWrite.Entity.Balance.Should()
|
|
||||||
.Be(account.Balance - command.Amount);
|
|
||||||
intents.RevenueWrite.Entity.Balance.Should()
|
|
||||||
.Be(revenueAccount.Balance + command.Amount);
|
|
||||||
intents.JournalWrite.Entity.Lines.Count.Should()
|
|
||||||
.BeGreaterThanOrEqualTo(2);
|
|
||||||
intents.Event.AccountId.Should()
|
|
||||||
.Be(account.Id);
|
|
||||||
intents.Event.Amount.Should()
|
|
||||||
.Be(command.Amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class LiabilityAccount
|
public class LiabilityAccount
|
||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -49,17 +25,18 @@ public class ApplyFeeHandlerTests
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
var timeProvider = new FakeTimeProvider();
|
||||||
var command = new ApplyFeeCommand(1, feeAmount);
|
var command = new ApplyFeeCommand(1, feeAmount);
|
||||||
var account = new Account(1, startingBalance, AccountType.Liability);
|
var account = new Account(1, startingBalance, AccountType.Liability);
|
||||||
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
||||||
var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow());
|
var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var intents = ApplyFeeHandler.Handle(command, pair);
|
var (customerWrite, revenueWrite, journalWrite, @event) = ApplyFeeHandler.Handle(command, feeContext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
using var _ = new AssertionScope();
|
using var _ = new AssertionScope();
|
||||||
intents.CustomerWrite.Entity.Balance.Should()
|
customerWrite.Entity.Balance.Should()
|
||||||
.Be(expectedBalance);
|
.Be(expectedBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +47,15 @@ public class ApplyFeeHandlerTests
|
|||||||
[InlineData(1000, 1999999)]
|
[InlineData(1000, 1999999)]
|
||||||
public void GivenStartingBalanceLessThanFee_ApplyFee_ShouldFail(decimal startingBalance, decimal feeAmount)
|
public void GivenStartingBalanceLessThanFee_ApplyFee_ShouldFail(decimal startingBalance, decimal feeAmount)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
|
var timeProvider = new FakeTimeProvider();
|
||||||
var command = new ApplyFeeCommand(1, feeAmount);
|
var command = new ApplyFeeCommand(1, feeAmount);
|
||||||
var account = new Account(1, startingBalance, AccountType.Liability);
|
var account = new Account(1, startingBalance, AccountType.Liability);
|
||||||
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
||||||
var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow());
|
var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => ApplyFeeHandler.Handle(command, pair);
|
var act = () => ApplyFeeHandler.Handle(command, feeContext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
using var _ = new AssertionScope();
|
using var _ = new AssertionScope();
|
||||||
@@ -98,17 +77,19 @@ public class ApplyFeeHandlerTests
|
|||||||
decimal expectedBalance
|
decimal expectedBalance
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
|
var timeProvider = new FakeTimeProvider();
|
||||||
var command = new ApplyFeeCommand(1, feeAmount);
|
var command = new ApplyFeeCommand(1, feeAmount);
|
||||||
var account = new Account(1, startingBalance, AccountType.Credit);
|
var account = new Account(1, startingBalance, AccountType.Credit);
|
||||||
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
||||||
var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow());
|
var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var intents = ApplyFeeHandler.Handle(command, pair);
|
var (customerWrite, revenueWrite, journalWrite, @event) = ApplyFeeHandler.Handle(command, feeContext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
using var _ = new AssertionScope();
|
using var _ = new AssertionScope();
|
||||||
intents.CustomerWrite.Entity.Balance.Should()
|
customerWrite.Entity.Balance.Should()
|
||||||
.Be(expectedBalance);
|
.Be(expectedBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,17 +104,19 @@ public class ApplyFeeHandlerTests
|
|||||||
decimal expectedBalance
|
decimal expectedBalance
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
|
var timeProvider = new FakeTimeProvider();
|
||||||
var command = new ApplyFeeCommand(1, feeAmount);
|
var command = new ApplyFeeCommand(1, feeAmount);
|
||||||
var account = new Account(1, startingBalance, AccountType.Credit);
|
var account = new Account(1, startingBalance, AccountType.Credit);
|
||||||
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
var revenueAccount = new Account(99999, 0, AccountType.Credit);
|
||||||
var pair = new FeeContext(account, revenueAccount, TimeProvider.System.GetUtcNow());
|
var feeContext = new FeeContext(account, revenueAccount, timeProvider.GetUtcNow());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var intents = ApplyFeeHandler.Handle(command, pair);
|
var (customerWrite, revenueWrite, journalWrite, @event) = ApplyFeeHandler.Handle(command, feeContext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
using var _ = new AssertionScope();
|
using var _ = new AssertionScope();
|
||||||
intents.CustomerWrite.Entity.Balance.Should()
|
customerWrite.Entity.Balance.Should()
|
||||||
.Be(expectedBalance);
|
.Be(expectedBalance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
using FinancialApi.Application.Commands;
|
||||||
|
using FinancialApi.Application.Events;
|
||||||
|
using FinancialApi.Application.Handlers;
|
||||||
|
using FinancialApi.Application.Models;
|
||||||
|
using FinancialApi.Domain.Entities;
|
||||||
|
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<Account> SourceWrite, IStorageAction<Account> DestWrite, IStorageAction<BalancedJournalEntry>
|
||||||
|
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.Liability);
|
||||||
|
var destAccount = new Account(2, destStartingBalance, AccountType.Liability);
|
||||||
|
var transferContext = new TransferContext(sourceAccount, destAccount, timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
return TransferFundsHandler.Handle(command, transferContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-5
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Time.Testing;
|
|||||||
|
|
||||||
namespace FinancialApi.Application.UnitTests;
|
namespace FinancialApi.Application.UnitTests;
|
||||||
|
|
||||||
public class ApplyTransferHandlerTests
|
public class TransferFundsHandlerTests
|
||||||
{
|
{
|
||||||
public class ApplyFeeHandlerTests
|
public class ApplyFeeHandlerTests
|
||||||
{
|
{
|
||||||
@@ -17,21 +17,22 @@ public class ApplyTransferHandlerTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var fakeTimeProvider = new FakeTimeProvider();
|
var fakeTimeProvider = new FakeTimeProvider();
|
||||||
|
|
||||||
var command = new TransferFundsCommand(1, 2, 100);
|
var command = new TransferFundsCommand(1, 2, 100);
|
||||||
var sourceAccount = new Account(1, 100, AccountType.Liability);
|
var sourceAccount = new Account(1, 100, AccountType.Liability);
|
||||||
var destAccount = new Account(1, 100, AccountType.Liability);
|
var destAccount = new Account(1, 100, AccountType.Liability);
|
||||||
var transferContext = new TransferContext(sourceAccount, destAccount, fakeTimeProvider.GetUtcNow());
|
var transferContext = new TransferContext(sourceAccount, destAccount, fakeTimeProvider.GetUtcNow());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var intents = TransferFundsHandler.Handle(command, transferContext);
|
var (sourceWrite, destWrite, journalWrite, @event) = TransferFundsHandler.Handle(command, transferContext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
using var _ = new AssertionScope();
|
using var _ = new AssertionScope();
|
||||||
intents.SourceWrite.Entity.Balance.Should()
|
sourceWrite.Entity.Balance.Should()
|
||||||
.Be(0);
|
.Be(0);
|
||||||
intents.DestWrite.Entity.Balance.Should()
|
destWrite.Entity.Balance.Should()
|
||||||
.Be(200);
|
.Be(200);
|
||||||
|
journalWrite.Entity.Lines.Should()
|
||||||
|
.HaveCountGreaterThanOrEqualTo(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ public static class ApplyFeeHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ( IStorageAction<Account> CustomerWrite, IStorageAction<Account> RevenueWrite,
|
public static ( IStorageAction<Account> CustomerWrite, IStorageAction<Account> RevenueWrite,
|
||||||
IStorageAction<JournalEntry> JournalWrite, FeeAppliedEvent Event ) Handle(
|
IStorageAction<BalancedJournalEntry> JournalWrite, FeeAppliedEvent Event ) Handle(
|
||||||
ApplyFeeCommand cmd,
|
ApplyFeeCommand cmd,
|
||||||
FeeContext context
|
FeeContext context
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -31,11 +31,11 @@ public static class ReverseJournalHandler
|
|||||||
throw new InvalidOperationException($"Journal Entry {cmd.OriginalJournalId} does not exist");
|
throw new InvalidOperationException($"Journal Entry {cmd.OriginalJournalId} does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ( IStorageAction<JournalEntry> JournalWrite, UnitOfWork<Account> AccountWrites, JournalReversedEvent
|
public static (IStorageAction<BalancedJournalEntry> JournalWrite, UnitOfWork<Account> AccountWrites, JournalReversedEvent
|
||||||
Event) Handle(ReverseJournalCommand cmd, JournalReversalContext context)
|
Event) Handle(ReverseJournalCommand cmd, JournalReversalContext context)
|
||||||
{
|
{
|
||||||
var reversalJournal = BalancedJournal.CreateReversal(
|
var reversalJournal = BalancedJournal.CreateReversal(
|
||||||
context.OriginalJournalEntry,
|
context.OriginalBalancedJournalEntry,
|
||||||
$"Reversal: {cmd.Reason}",
|
$"Reversal: {cmd.Reason}",
|
||||||
context.TimeStamp
|
context.TimeStamp
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ public static class TransferFundsHandler
|
|||||||
return new TransferContext(source, dest, timeProvider.GetUtcNow());
|
return new TransferContext(source, dest, timeProvider.GetUtcNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ( IStorageAction<Account> SourceWrite, IStorageAction<Account> DestWrite, IStorageAction<JournalEntry>
|
public static (IStorageAction<Account> SourceWrite, IStorageAction<Account> DestWrite, IStorageAction<BalancedJournalEntry>
|
||||||
JournalWrite, FundsTransferredEvent Event ) Handle(TransferFundsCommand cmd, TransferContext context)
|
JournalWrite, FundsTransferredEvent Event) Handle(TransferFundsCommand cmd, TransferContext context)
|
||||||
{
|
{
|
||||||
var updatedSource = context.Source.ApplyPosting(cmd.Amount, EntryType.Debit);
|
var updatedSource = context.Source.ApplyPosting(cmd.Amount, EntryType.Debit);
|
||||||
var updatedDest = context.Destination.ApplyPosting(cmd.Amount, EntryType.Credit);
|
var updatedDest = context.Destination.ApplyPosting(cmd.Amount, EntryType.Credit);
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ namespace FinancialApi.Application.Interfaces;
|
|||||||
|
|
||||||
public interface IReversalQuery
|
public interface IReversalQuery
|
||||||
{
|
{
|
||||||
Task<(JournalEntry?, List<Account>?)> GetReversalDataAsync(Guid journalEntryId);
|
Task<(BalancedJournalEntry?, List<Account>?)> GetReversalDataAsync(Guid journalEntryId);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ using FinancialApi.Domain.Entities;
|
|||||||
namespace FinancialApi.Application.Models;
|
namespace FinancialApi.Application.Models;
|
||||||
|
|
||||||
public record JournalReversalContext(
|
public record JournalReversalContext(
|
||||||
JournalEntry OriginalJournalEntry,
|
BalancedJournalEntry OriginalBalancedJournalEntry,
|
||||||
IReadOnlyList<Account> AffectedAccounts,
|
IReadOnlyList<Account> AffectedAccounts,
|
||||||
DateTimeOffset TimeStamp
|
DateTimeOffset TimeStamp
|
||||||
);
|
);
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
using FinancialApi.Domain.Entities;
|
using FinancialApi.Domain.Entities;
|
||||||
|
using FinancialApi.Domain.Exceptions;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
|
||||||
namespace FinancialApi.Application.UnitTests;
|
namespace FinancialApi.Domain.Tests;
|
||||||
|
|
||||||
public class AccountTests
|
public class AccountTests
|
||||||
{
|
{
|
||||||
@@ -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<JournalLine> 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<JournalLine> 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<GaapViolationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GivenEmptyJournal_ShouldFail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var timeProvider = new FakeTimeProvider();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => BalancedJournal.Create(Guid.NewGuid(), "Transfer", timeProvider.GetUtcNow(), []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
act.Should()
|
||||||
|
.Throw<GaapViolationException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
using FinancialApi.Domain.Entities;
|
using FinancialApi.Domain.Entities;
|
||||||
|
using FinancialApi.Domain.Exceptions;
|
||||||
|
|
||||||
namespace FinancialApi.Domain.Aggregates;
|
namespace FinancialApi.Domain.Aggregates;
|
||||||
|
|
||||||
public record BalancedJournal
|
public record BalancedJournal
|
||||||
{
|
{
|
||||||
public JournalEntry Entry { get; }
|
public BalancedJournalEntry Entry { get; }
|
||||||
|
|
||||||
private BalancedJournal(JournalEntry entry)
|
private BalancedJournal(BalancedJournalEntry entry)
|
||||||
{
|
{
|
||||||
Entry = entry;
|
Entry = entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static BalancedJournal Create(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines)
|
public static BalancedJournal Create(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> 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)
|
var debits = lines.Where(l => l.Type == EntryType.Debit)
|
||||||
.Sum(l => l.Amount);
|
.Sum(l => l.Amount);
|
||||||
var credits = lines.Where(l => l.Type == EntryType.Credit)
|
var credits = lines.Where(l => l.Type == EntryType.Credit)
|
||||||
.Sum(l => l.Amount);
|
.Sum(l => l.Amount);
|
||||||
|
|
||||||
if (debits != credits)
|
return debits != credits
|
||||||
{
|
? throw new GaapViolationException($"Total Debits ({debits}) must equal Total Credits ({credits})!")
|
||||||
throw new InvalidOperationException(
|
: new BalancedJournal(new BalancedJournalEntry(id, description, createdAt, lines));
|
||||||
$"GAAP Violation: Total Debits ({debits}) must equal Total Credits ({credits})!"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new BalancedJournal(new JournalEntry(id, description, createdAt, lines));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static BalancedJournal CreateReversal(JournalEntry original, string reason, DateTimeOffset now)
|
public static BalancedJournal CreateReversal(BalancedJournalEntry original, string reason, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
var reversedLines = original.Lines.Select(l =>
|
var reversedLines = original.Lines.Select(l =>
|
||||||
l with { Type = l.Type == EntryType.Debit ? EntryType.Credit : EntryType.Debit }
|
l with { Type = l.Type == EntryType.Debit ? EntryType.Credit : EntryType.Debit }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace FinancialApi.Domain.Entities;
|
using FinancialApi.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace FinancialApi.Domain.Entities;
|
||||||
|
|
||||||
public enum AccountType
|
public enum AccountType
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-4
@@ -1,15 +1,13 @@
|
|||||||
namespace FinancialApi.Domain.Entities;
|
namespace FinancialApi.Domain.Entities;
|
||||||
|
|
||||||
public record JournalEntry
|
public record BalancedJournalEntry
|
||||||
{
|
{
|
||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
public string Description { get; init; }
|
public string Description { get; init; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
public List<JournalLine> Lines { get; init; } = [];
|
public List<JournalLine> Lines { get; init; } = [];
|
||||||
|
|
||||||
internal JournalEntry() { }
|
internal BalancedJournalEntry(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines)
|
||||||
|
|
||||||
internal JournalEntry(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines)
|
|
||||||
{
|
{
|
||||||
Lines = lines;
|
Lines = lines;
|
||||||
Id = id;
|
Id = id;
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
namespace FinancialApi.Domain.Entities;
|
|
||||||
|
|
||||||
public class InsufficientFundsException(string message) : Exception(message);
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
namespace FinancialApi.Domain.Entities;
|
|
||||||
|
|
||||||
public class InvalidStartingBalanceException(string message) : Exception(message);
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
namespace FinancialApi.Domain.Exceptions;
|
||||||
|
|
||||||
|
public class GaapViolationException(string message) : Exception(message);
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
namespace FinancialApi.Domain.Exceptions;
|
||||||
|
|
||||||
|
public class InsufficientFundsException(string message) : GaapViolationException(message);
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
namespace FinancialApi.Domain.Exceptions;
|
||||||
|
|
||||||
|
public class InvalidStartingBalanceException(string message) : GaapViolationException(message);
|
||||||
@@ -6,7 +6,7 @@ namespace FinancialApi.Infrastructure;
|
|||||||
public class AccountDbContext(DbContextOptions<AccountDbContext> options) : DbContext(options)
|
public class AccountDbContext(DbContextOptions<AccountDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<Account> Accounts => Set<Account>();
|
public DbSet<Account> Accounts => Set<Account>();
|
||||||
public DbSet<JournalEntry> JournalEntries => Set<JournalEntry>();
|
public DbSet<BalancedJournalEntry> JournalEntries => Set<BalancedJournalEntry>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -20,7 +20,7 @@ public class AccountDbContext(DbContextOptions<AccountDbContext> options) : DbCo
|
|||||||
new Account(99999, 0.00m, AccountType.Revenue)
|
new Account(99999, 0.00m, AccountType.Revenue)
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<JournalEntry>(builder =>
|
modelBuilder.Entity<BalancedJournalEntry>(builder =>
|
||||||
{
|
{
|
||||||
builder.HasKey(x => x.Id);
|
builder.HasKey(x => x.Id);
|
||||||
builder.OwnsMany(
|
builder.OwnsMany(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace FinancialApi.Infrastructure;
|
|||||||
|
|
||||||
public class ReversalQuery(AccountDbContext db) : IReversalQuery
|
public class ReversalQuery(AccountDbContext db) : IReversalQuery
|
||||||
{
|
{
|
||||||
public async Task<(JournalEntry?, List<Account>?)> GetReversalDataAsync(Guid journalEntryId)
|
public async Task<(BalancedJournalEntry?, List<Account>?)> GetReversalDataAsync(Guid journalEntryId)
|
||||||
{
|
{
|
||||||
var entry = await db.JournalEntries.Include(x => x.Lines)
|
var entry = await db.JournalEntries.Include(x => x.Lines)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Domain", "Fina
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Application.UnitTests", "FinancialApi.Application.UnitTests\FinancialApi.Application.UnitTests.csproj", "{93DBA3AA-3C03-431C-A41C-DDADFAC964D5}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Application.UnitTests", "FinancialApi.Application.UnitTests\FinancialApi.Application.UnitTests.csproj", "{93DBA3AA-3C03-431C-A41C-DDADFAC964D5}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Domain.Tests", "FinancialApi.Domain.Tests\FinancialApi.Domain.Tests.csproj", "{C15E3FE0-6E3D-4FFC-892E-D469536ADC13}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -42,5 +44,9 @@ Global
|
|||||||
{93DBA3AA-3C03-431C-A41C-DDADFAC964D5}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{93DBA3AA-3C03-431C-A41C-DDADFAC964D5}.Release|Any CPU.Build.0 = 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
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfiguredTaskAwaitable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe929cb9673c947ad9269facdac90006adb3400_003F14_003Fd939fe0a_003FConfiguredTaskAwaitable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfiguredTaskAwaitable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe929cb9673c947ad9269facdac90006adb3400_003F14_003Fd939fe0a_003FConfiguredTaskAwaitable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConstructorBindingFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fda476e261fcf49b394c1074b25b4cdce2b2960_003Fd4_003Fb1aa95c1_003FConstructorBindingFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConstructorBindingFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fda476e261fcf49b394c1074b25b4cdce2b2960_003Fd4_003Fb1aa95c1_003FConstructorBindingFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fda476e261fcf49b394c1074b25b4cdce2b2960_003F37_003F1b1010ed_003FDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALateBoundTestFramework_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fcffa676d9bfc6c7a1c9d0d25ead15b3b0c9e12e3187bf397eaa52260e5fd91_003FLateBoundTestFramework_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALateBoundTestFramework_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fcffa676d9bfc6c7a1c9d0d25ead15b3b0c9e12e3187bf397eaa52260e5fd91_003FLateBoundTestFramework_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMessageContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb263759e5fefb34bcd589e63aeba791cef6f591ecbc93d1a7e62befa9e8bc6_003FMessageContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMessageContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb263759e5fefb34bcd589e63aeba791cef6f591ecbc93d1a7e62befa9e8bc6_003FMessageContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARelationalLoggerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb797502679b4aa8376708777b4db8d11e31a798d7e6bead76c15bdfa30834892_003FRelationalLoggerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARelationalLoggerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb797502679b4aa8376708777b4db8d11e31a798d7e6bead76c15bdfa30834892_003FRelationalLoggerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe929cb9673c947ad9269facdac90006adb3400_003Ff0_003F6b0e8a92_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWolverineRuntime_002EHostService_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F97bae8f1ffdd86c14edef353d7a85d3d3773b56c68e3a62e8815d1afaacbe797_003FWolverineRuntime_002EHostService_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWolverineRuntime_002EHostService_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F97bae8f1ffdd86c14edef353d7a85d3d3773b56c68e3a62e8815d1afaacbe797_003FWolverineRuntime_002EHostService_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=ea7499a6_002Ddeba_002D4139_002Dac76_002D505e3f7f2642/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=ea7499a6_002Ddeba_002D4139_002Dac76_002D505e3f7f2642/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||||
<Solution />
|
<Solution />
|
||||||
|
|||||||
Reference in New Issue
Block a user