diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs index d787a34..9e2f715 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs @@ -8,9 +8,11 @@ public record ApplyFeeCommand(int AccountId, decimal Amount); public record FeeAppliedEvent(int AccountId, decimal Amount); +public record FeePair(Account Source, Account Destination); + public static class ApplyFeeHandler { - public static async Task<(Account, Account)> LoadAsync( + public static async Task LoadAsync( ApplyFeeCommand cmd, IAccountQuery query) { @@ -20,7 +22,7 @@ public static class ApplyFeeHandler { throw new InvalidOperationException($"Cannot process transfer. Account(s) not found."); } - return (source, dest); + return new FeePair(source, dest); } public static ( @@ -31,12 +33,11 @@ public static class ApplyFeeHandler ) Handle( ApplyFeeCommand cmd, - Account source, - Account dest + FeePair pair ) { - var updatedSource = source.ApplyPosting(cmd.Amount, EntryType.Debit); - var updatedRevenue = dest.ApplyPosting(cmd.Amount, EntryType.Credit); + var updatedSource = pair.Source.ApplyPosting(cmd.Amount, EntryType.Debit); + var updatedRevenue = pair.Destination.ApplyPosting(cmd.Amount, EntryType.Credit); var lines = new List { new(updatedSource.Id, cmd.Amount, EntryType.Debit), diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/ReverseJournalHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/ReverseJournalHandler.cs new file mode 100644 index 0000000..de397f2 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/ReverseJournalHandler.cs @@ -0,0 +1,52 @@ +using FinancialApi.Domain; +using Wolverine.Persistence; + +namespace FinancialApi.Application; + +public record ReverseJournalCommand(Guid OriginalJournalId, string Reason); + +public record JournalReversedEvent(Guid OriginalJournalId, Guid ReversalJournalId); + +public record ReversalData(JournalEntry OriginalJournalEntry, IReadOnlyList AffectedAccounts); + +public interface IReversalQuery +{ + Task GetReversalDataAsync(Guid journalEntryId); +} + +public static class ReverseJournalHandler +{ + public static async Task LoadAsync(ReverseJournalCommand cmd, IReversalQuery query) + { + var data = await query.GetReversalDataAsync(cmd.OriginalJournalId); + if (data == null) + { + throw new InvalidOperationException($"Journal Entry {cmd.OriginalJournalId} does not exist"); + } + + return data; + } + + public static ( + IStorageAction ReversalWrite, + IStorageAction[] AccountWrites, + JournalReversedEvent Event) Handle(ReverseJournalCommand cmd, ReversalData data) + { + var reversalJournal = + BalancedJournal.CreateReversal(data.OriginalJournalEntry, cmd.Reason, DateTimeOffset.UtcNow); + var accountState = data.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 accountWrites = accountState.Values.Select(a => (IStorageAction)Storage.Update(a)).ToArray(); + return ( + Storage.Insert(reversalJournal.Entry), + accountWrites, + new JournalReversedEvent(cmd.OriginalJournalId, reversalJournal.Entry.Id) + ); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs index 6a1d154..07c9435 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs @@ -20,4 +20,13 @@ public record BalancedJournal return new(new JournalEntry(id, description, createdAt, lines)); } + + public static BalancedJournal CreateReversal(JournalEntry 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/JournalEntry.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/JournalEntry.cs index e1ce7eb..649fe2f 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Domain/JournalEntry.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/JournalEntry.cs @@ -7,6 +7,10 @@ public record JournalEntry public DateTimeOffset CreatedAt { get; init; } public List Lines { get; init; } = []; + internal JournalEntry() + { + } + internal JournalEntry(Guid id, string description, DateTimeOffset createdAt, List lines) { Lines = lines; 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..d8d0a65 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ReversalQuery.cs @@ -0,0 +1,33 @@ +using FinancialApi.Application; +using Microsoft.EntityFrameworkCore; + +namespace FinancialApi.Infrastructure; + +public class ReversalQuery : IReversalQuery +{ + private readonly AccountDbContext _db; + + public ReversalQuery(AccountDbContext db) => _db = db; + + public async Task GetReversalDataAsync(Guid journalEntryId) + { + var entry = await _db.JournalEntries + .Include(x => x.Lines) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == journalEntryId); + + if (entry == null) + { + return 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 new ReversalData(entry, accounts); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http index 205418e..9f6ca3a 100644 --- a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http +++ b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http @@ -24,3 +24,13 @@ Content-Type: application/json "destinationAccountId": 2, "amount": 3000.00 } + +### Reverse a Journal Entry + +POST {{FinancialApi_HostAddress}}/journal/reverse +Content-Type: application/json + +{ + "originalJournalId": "AD4FC011-9979-4364-A0D4-2CCD922ECB5E", + "reason": "Incorrect Account" +} diff --git a/wolverine/a-frame-architecture/FinancialApi/Program.cs b/wolverine/a-frame-architecture/FinancialApi/Program.cs index 607851e..2faca48 100644 --- a/wolverine/a-frame-architecture/FinancialApi/Program.cs +++ b/wolverine/a-frame-architecture/FinancialApi/Program.cs @@ -21,6 +21,7 @@ builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -67,4 +68,10 @@ app.MapPost("/accounts/transfer", async (TransferFundsCommand cmd, IMessageBus b return Results.Accepted(); }); +app.MapPost("/journal/reverse", async (ReverseJournalCommand cmd, IMessageBus bus) => +{ + await bus.InvokeAsync(cmd); + return Results.Accepted(); +}); + app.Run();