Adding journal entries and database tables. Increased starting balances.

This commit is contained in:
2026-07-15 12:08:28 +02:00
parent 0de38ef2df
commit 656f545281
13 changed files with 498 additions and 34 deletions
@@ -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 };
}
}
@@ -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<JournalLine> 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));
}
}
@@ -0,0 +1,7 @@
namespace FinancialApi.Application;
public enum EntryType
{
Debit,
Credit
}
@@ -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<JournalLine> Lines { get; init; } = [];
private JournalEntry()
{
}
internal JournalEntry(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines)
{
Lines = lines;
Id = id;
Description = description;
CreatedAt = createdAt;
}
}
@@ -0,0 +1,3 @@
namespace FinancialApi.Application;
public record JournalLine(int AccountId, decimal Amount, EntryType Type);
@@ -6,6 +6,7 @@ namespace FinancialApi.Infrastructure;
public class AccountDbContext(DbContextOptions<AccountDbContext> options) : DbContext(options)
{
public DbSet<Account> Accounts => Set<Account>();
public DbSet<JournalEntry> JournalEntries => Set<JournalEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -13,8 +14,21 @@ public class AccountDbContext(DbContextOptions<AccountDbContext> options) : DbCo
.HasKey(x => x.Id);
modelBuilder.Entity<Account>().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<JournalEntry>(builder =>
{
builder.HasKey(x => x.Id);
builder.OwnsMany(x => x.Lines, line =>
{
line.WithOwner().HasForeignKey("JournalEntryId");
line.Property<int>("Id").ValueGeneratedOnAdd();
line.HasKey("Id");
line.Property(x => x.Type).HasConversion<string>();
});
});
}
}
@@ -5,25 +5,53 @@ using Wolverine.Persistence;
namespace FinancialApi.Infrastructure;
public static class ApplyFeeHandler
{
public static Task<Account?> LoadAsync(
ApplyFeeCommand cmd,
AccountDbContext db) => db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == cmd.AccountId);
[Transactional]
public static (IStorageAction<Account> 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);
public record FeePair(Account CustomerAccount, Account RevenueAccount);
public record FeeAppliedEvent(int AccountId, decimal Amount);
public static class ApplyFeeHandler
{
public static async Task<FeePair?> 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<Account> CustomerWrite,
IStorageAction<Account> RevenueWrite,
IStorageAction<JournalEntry> 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<JournalLine>
{
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
);
}
}
@@ -0,0 +1,115 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("AccountId")
.HasColumnType("INTEGER");
b1.Property<decimal>("Amount")
.HasColumnType("TEXT");
b1.Property<Guid>("JournalEntryId")
.HasColumnType("TEXT");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinancialApi.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJournal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AccountType",
table: "Accounts",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "JournalEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_JournalEntries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JournalLine",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
Amount = table.Column<decimal>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: false),
JournalEntryId = table.Column<Guid>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinancialApi.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateAccountBalances : Migration
{
/// <inheritdoc />
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);
}
/// <inheritdoc />
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);
}
}
}
@@ -1,4 +1,5 @@
// <auto-generated />
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<string>("AccountType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("AccountId")
.HasColumnType("INTEGER");
b1.Property<decimal>("Amount")
.HasColumnType("TEXT");
b1.Property<Guid>("JournalEntryId")
.HasColumnType("TEXT");
b1.Property<string>("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
}
}
@@ -28,16 +28,31 @@ public static class TransferFundsHandler
public static (
IStorageAction<Account> SourceWrite,
IStorageAction<Account> DestWrite,
IStorageAction<JournalEntry> 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<JournalLine>
{
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
);
}
}
@@ -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
}