Added a-frame-architecture usiung wolverine sample.
This commit is contained in:
@@ -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<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)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<JournalLine> Lines { get; init; } = [];
|
||||
|
||||
internal BalancedJournalEntry() { }
|
||||
|
||||
internal BalancedJournalEntry(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines)
|
||||
{
|
||||
Lines = lines;
|
||||
Id = id;
|
||||
Description = description;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace FinancialApi.Domain.Entities;
|
||||
|
||||
public enum EntryType
|
||||
{
|
||||
Debit,
|
||||
Credit
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FinancialApi.Domain.Entities;
|
||||
|
||||
public record JournalLine(int AccountId, decimal Amount, EntryType Type);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FinancialApi.Domain.Events;
|
||||
|
||||
public record FeeAppliedEvent(int AccountId, decimal Amount);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FinancialApi.Domain.Events;
|
||||
|
||||
public record FundsTransferredEvent(int SourceAccountId, int DestinationAccountId, decimal Amount);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace FinancialApi.Domain.Events;
|
||||
|
||||
public interface IEventTracker
|
||||
{
|
||||
void Add(string eventMessage);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FinancialApi.Domain.Events;
|
||||
|
||||
public record JournalReversedEvent(Guid OriginalJournalId, Guid ReversalJournalId);
|
||||
+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);
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<T>
|
||||
{
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Error indicator
|
||||
/// </summary>
|
||||
public bool IsSuccess => !IsError;
|
||||
|
||||
/// <summary>
|
||||
/// Result Values
|
||||
/// </summary>
|
||||
public T? Value => _resultValue;
|
||||
|
||||
public FinancialError? Error => _resultError;
|
||||
|
||||
public static implicit operator FinancialResult<T>(T resultValue) => new(resultValue);
|
||||
|
||||
public static implicit operator FinancialResult<T>(FinancialError resultError) => new(resultError);
|
||||
|
||||
public static FinancialResult<T> Success(T successValue)
|
||||
{
|
||||
return new FinancialResult<T>(successValue);
|
||||
}
|
||||
|
||||
public static FinancialResult<T> Failure(FinancialError failureValue)
|
||||
{
|
||||
return new FinancialResult<T>(failureValue);
|
||||
}
|
||||
|
||||
public TResult Match<TResult>(
|
||||
Func<T, TResult> success,
|
||||
Func<FinancialError, TResult> failure,
|
||||
Func<TResult> nullValue
|
||||
)
|
||||
{
|
||||
return IsError switch
|
||||
{
|
||||
false when _resultValue == null => nullValue(),
|
||||
true => failure(_resultError!),
|
||||
_ => success(_resultValue)
|
||||
};
|
||||
}
|
||||
|
||||
public TResult Match<TResult>(Func<T, TResult> success, Func<FinancialError, TResult> failure)
|
||||
{
|
||||
return IsError switch
|
||||
{
|
||||
true => failure(_resultError!),
|
||||
_ => success(_resultValue!)
|
||||
};
|
||||
}
|
||||
|
||||
public TResult? Match<TResult>(Func<T, TResult> success)
|
||||
{
|
||||
return IsError switch
|
||||
{
|
||||
true => default,
|
||||
_ => success(_resultValue!)
|
||||
};
|
||||
}
|
||||
|
||||
public void Match(Action<T> success, Action<FinancialError> 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<T> success, Action<FinancialError> failure)
|
||||
{
|
||||
switch (IsError)
|
||||
{
|
||||
case true:
|
||||
failure.Invoke(_resultError!);
|
||||
return;
|
||||
default:
|
||||
success(_resultValue!);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Match(Action<T> success)
|
||||
{
|
||||
if (IsError)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
success(_resultValue!);
|
||||
}
|
||||
|
||||
public async Task<TResult> MatchAsync<TResult>(
|
||||
Func<T, Task<TResult>> success,
|
||||
Func<FinancialError, Task<TResult>> failure,
|
||||
Func<Task<TResult>> nullValue
|
||||
)
|
||||
{
|
||||
return IsError switch
|
||||
{
|
||||
false when _resultValue == null => await nullValue(),
|
||||
true => await failure(_resultError!),
|
||||
_ => await success(_resultValue)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<TResult> MatchAsync<TResult>(
|
||||
Func<T, Task<TResult>> success,
|
||||
Func<FinancialError, Task<TResult>> failure
|
||||
)
|
||||
{
|
||||
return IsError switch
|
||||
{
|
||||
true => await failure(_resultError!),
|
||||
_ => await success(_resultValue!)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<TResult?> MatchAsync<TResult>(Func<T, Task<TResult>> success)
|
||||
{
|
||||
return IsError switch
|
||||
{
|
||||
true => default,
|
||||
_ => await success(_resultValue!)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task MatchAsync(Func<T, Task> success, Func<FinancialError, Task> failure, Func<Task> 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<T, Task> success, Func<FinancialError, Task> failure)
|
||||
{
|
||||
switch (IsError)
|
||||
{
|
||||
case true:
|
||||
await failure.Invoke(_resultError!);
|
||||
return;
|
||||
default:
|
||||
await success(_resultValue!);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MatchAsync(Func<T, Task> success)
|
||||
{
|
||||
if (IsError)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await success(_resultValue!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user