diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Account.cs b/wolverine/a-frame-architecture/FinancialApi.Application/Account.cs index 8c73567..b8e0c6e 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/Account.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/Account.cs @@ -1,14 +1,40 @@ namespace FinancialApi.Application; -public record Account(int Id, decimal Balance) +public record Account(int Id, decimal Balance, string AccountType) { + public Account ApplyPosting(decimal amount, EntryType entryType) + { + if (amount <= 0) + { + throw new ArgumentException("Credit 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; + } + + + if (newBalance < 0 && AccountType == "Liability") + { + throw new InvalidOperationException("Insufficient funds."); + } + + return this with { Balance = newBalance }; + } + public Account ApplyFee(decimal amount) { if (amount <= 0) { throw new ArgumentException("Fee must be positive."); } - + if (Balance - amount < 0) { throw new InvalidOperationException("Insufficient funds."); @@ -16,18 +42,19 @@ public record Account(int Id, decimal Balance) return this with { Balance = Balance - amount }; } - + 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 }; } @@ -37,7 +64,7 @@ public record Account(int Id, decimal Balance) { throw new ArgumentException("Credit amount must be positive."); } - + return this with { Balance = Balance + amount }; } } \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/BalancedJournal.cs b/wolverine/a-frame-architecture/FinancialApi.Application/BalancedJournal.cs new file mode 100644 index 0000000..1592967 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/BalancedJournal.cs @@ -0,0 +1,23 @@ +namespace FinancialApi.Application; + +public record BalancedJournal +{ + public JournalEntry Entry { get; } + + private BalancedJournal(JournalEntry entry) => Entry = entry; + + public static BalancedJournal Create(Guid id, string description, DateTimeOffset createdAt, List lines) + { + 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); + + if (debits != credits) + { + throw new InvalidOperationException( + $"GAAP Violation: Total Debits ({debits}) must equal Total Credits ({credits})!" + ); + } + + return new(new JournalEntry(id, description, createdAt, lines)); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/EntryType.cs b/wolverine/a-frame-architecture/FinancialApi.Application/EntryType.cs new file mode 100644 index 0000000..8d485c4 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/EntryType.cs @@ -0,0 +1,7 @@ +namespace FinancialApi.Application; + +public enum EntryType +{ + Debit, + Credit +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/JournalEntry.cs b/wolverine/a-frame-architecture/FinancialApi.Application/JournalEntry.cs new file mode 100644 index 0000000..0bb571c --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/JournalEntry.cs @@ -0,0 +1,21 @@ +namespace FinancialApi.Application; + +public record JournalEntry +{ + public Guid Id { get; init; } + public string Description { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public List Lines { get; init; } = []; + + private JournalEntry() + { + } + + internal JournalEntry(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.Application/JournalLine.cs b/wolverine/a-frame-architecture/FinancialApi.Application/JournalLine.cs new file mode 100644 index 0000000..3ba41f8 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Application/JournalLine.cs @@ -0,0 +1,3 @@ +namespace FinancialApi.Application; + +public record JournalLine(int AccountId, decimal Amount, EntryType Type); \ 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 ae3d0ff..c2d911b 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs @@ -6,6 +6,7 @@ namespace FinancialApi.Infrastructure; public class AccountDbContext(DbContextOptions options) : DbContext(options) { public DbSet Accounts => Set(); + public DbSet JournalEntries => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -13,8 +14,21 @@ public class AccountDbContext(DbContextOptions options) : DbCo .HasKey(x => x.Id); modelBuilder.Entity().HasData( - new Account(1, 100.00m), - new Account(2, 500.00m) + new Account(1, 10000000.00m, "Credit"), + new Account(2, 50000000.00m, "Liability"), + new Account(99999, 0.00m, "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/ApplyFeeHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ApplyFeeHandler.cs index a9cfc66..14c55c9 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ApplyFeeHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ApplyFeeHandler.cs @@ -5,25 +5,53 @@ using Wolverine.Persistence; namespace FinancialApi.Infrastructure; -public static class ApplyFeeHandler -{ - public static Task LoadAsync( - ApplyFeeCommand cmd, - AccountDbContext db) => db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == cmd.AccountId); - - [Transactional] - public static (IStorageAction UpdatedAccount, FeeAppliedEvent Event) Handle( - ApplyFeeCommand cmd, - Account account) - { - var updatedAccount = account.ApplyFee(cmd.Amount); - - var @event = new FeeAppliedEvent(account.Id, cmd.Amount); - - return (Storage.Update(updatedAccount), @event); - } -} - public record ApplyFeeCommand(int AccountId, decimal Amount); -public record FeeAppliedEvent(int AccountId, decimal Amount); \ No newline at end of file +public record FeePair(Account CustomerAccount, Account RevenueAccount); + +public record FeeAppliedEvent(int AccountId, decimal Amount); + +public static class ApplyFeeHandler +{ + public static async Task LoadAsync( + ApplyFeeCommand cmd, + AccountDbContext db) + { + var customer = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == cmd.AccountId); + var revenue = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == 99999); + return (customer == null || revenue == null) ? null : new FeePair(customer, revenue); + } + + [Transactional] + public static ( + IStorageAction CustomerWrite, + IStorageAction RevenueWrite, + IStorageAction JournalWrite, + FeeAppliedEvent Event + ) + Handle( + ApplyFeeCommand cmd, + FeePair pair + ) + { + var updatedCustomer = pair.CustomerAccount.ApplyPosting(cmd.Amount, EntryType.Debit); + var updatedRevenue = pair.RevenueAccount.ApplyPosting(cmd.Amount, EntryType.Credit); + var lines = new List + { + new(pair.CustomerAccount.Id, cmd.Amount, EntryType.Debit), + new(9999, cmd.Amount, EntryType.Credit) + }; + var journalEntry = BalancedJournal.Create(Guid.NewGuid(), + $"Service Fee Applied: {cmd.Amount} to Account {cmd.AccountId}", + DateTimeOffset.UtcNow, lines); + + var @event = new FeeAppliedEvent(cmd.AccountId, cmd.Amount); + + return ( + Storage.Update(updatedCustomer), + Storage.Update(updatedRevenue), + Storage.Insert(journalEntry.Entry), + @event + ); + } +} \ No newline at end of file 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.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/AccountDbContextModelSnapshot.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/AccountDbContextModelSnapshot.cs index 1f84a63..420b8fa 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/AccountDbContextModelSnapshot.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/AccountDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using FinancialApi.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -22,6 +23,10 @@ namespace FinancialApi.Infrastructure.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AccountType") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("Balance") .HasColumnType("TEXT"); @@ -33,14 +38,74 @@ namespace FinancialApi.Infrastructure.Migrations new { Id = 1, - Balance = 100.00m + AccountType = "Credit", + Balance = 10000000.00m }, new { Id = 2, - Balance = 500.00m + 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/TransferFundsHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/TransferFundsHandler.cs index 548714a..448aba2 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/TransferFundsHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/TransferFundsHandler.cs @@ -28,16 +28,31 @@ public static class TransferFundsHandler public static ( IStorageAction SourceWrite, IStorageAction DestWrite, + IStorageAction JournalWrite, FundsTransferredEvent Event ) Handle(TransferFundsCommand cmd, TransferPair pair) { var updatedSource = pair.Source.Debit(cmd.Amount); var updatedDest = pair.Destination.Credit(cmd.Amount); - var sourcePersistence = Storage.Update(updatedSource); - var destPersistence = Storage.Update(updatedDest); + var lines = new List + { + new (pair.Source.Id, cmd.Amount, EntryType.Debit), + new (pair.Destination.Id, cmd.Amount, EntryType.Credit) + }; + + var journalEntry = + BalancedJournal.Create(Guid.NewGuid(), + $"Transfer: {cmd.Amount} from {cmd.SourceAccountId} to {cmd.DestinationAccountId}", + DateTimeOffset.UtcNow, lines); + var @event = new FundsTransferredEvent(cmd.SourceAccountId, cmd.DestinationAccountId, cmd.Amount); - return (sourcePersistence, destPersistence, @event); + 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/FinancialApi.http b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http index bfac6b8..205418e 100644 --- a/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http +++ b/wolverine/a-frame-architecture/FinancialApi/FinancialApi.http @@ -11,7 +11,7 @@ Content-Type: application/json { "accountId": 1, - "amount": 5.0 + "amount": 500.0 } ### Transfer Between Accounts @@ -22,5 +22,5 @@ Content-Type: application/json { "sourceAccountId": 1, "destinationAccountId": 2, - "amount": 30.00 + "amount": 3000.00 }