Private
Public Access
1
0

Initial commit: Purrse personal finance app

Self-hosted, plugin-extensible personal finance manager built with
ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17.

Backend (8 .NET projects):
- Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces
- Data: EF Core DbContext, 16 entity configurations, category seeder
- API: 14 controllers, 15 services, JWT auth, SignalR, middleware
- Plugins: Abstractions + OFX/CSV/QIF file parsers
- Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers)

Frontend (Vue 3 + Vuetify 3 + TypeScript):
- 13 views, Pinia stores, Axios API services with JWT interceptors
- Dashboard, accounts, transactions, categories, imports, and more

Deployment:
- Docker Compose (PostgreSQL 17 + .NET API + nginx frontend)
- Auto-migration on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 08:56:46 -05:00
commit 6520ebf221
169 changed files with 7180 additions and 0 deletions
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class AccountConfiguration : IEntityTypeConfiguration<Account>
{
public void Configure(EntityTypeBuilder<Account> builder)
{
builder.ToTable("accounts");
builder.HasKey(a => a.Id);
builder.Property(a => a.Name).HasMaxLength(200).IsRequired();
builder.Property(a => a.Institution).HasMaxLength(200);
builder.Property(a => a.AccountNumber).HasMaxLength(50);
builder.Property(a => a.Balance).HasPrecision(18, 2);
builder.Property(a => a.CreditLimit).HasPrecision(18, 2);
builder.Property(a => a.InterestRate).HasPrecision(8, 4);
builder.Property(a => a.Notes).HasMaxLength(1000);
builder.HasOne(a => a.User)
.WithMany(u => u.Accounts)
.HasForeignKey(a => a.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(a => a.LoanDetail)
.WithOne(l => l.Account)
.HasForeignKey<LoanDetail>(l => l.AccountId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => a.UserId);
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class AmortizationEntryConfiguration : IEntityTypeConfiguration<AmortizationEntry>
{
public void Configure(EntityTypeBuilder<AmortizationEntry> builder)
{
builder.ToTable("amortization_entries");
builder.HasKey(a => a.Id);
builder.Property(a => a.PaymentAmount).HasPrecision(18, 2);
builder.Property(a => a.PrincipalAmount).HasPrecision(18, 2);
builder.Property(a => a.InterestAmount).HasPrecision(18, 2);
builder.Property(a => a.EscrowAmount).HasPrecision(18, 2);
builder.Property(a => a.RemainingBalance).HasPrecision(18, 2);
builder.HasOne(a => a.LoanDetail)
.WithMany(l => l.AmortizationEntries)
.HasForeignKey(a => a.LoanDetailId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class BudgetConfiguration : IEntityTypeConfiguration<Budget>
{
public void Configure(EntityTypeBuilder<Budget> builder)
{
builder.ToTable("budgets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Notes).HasMaxLength(500);
builder.HasOne(b => b.User)
.WithMany()
.HasForeignKey(b => b.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(b => new { b.UserId, b.Year, b.Month }).IsUnique();
}
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class BudgetItemConfiguration : IEntityTypeConfiguration<BudgetItem>
{
public void Configure(EntityTypeBuilder<BudgetItem> builder)
{
builder.ToTable("budget_items");
builder.HasKey(bi => bi.Id);
builder.Property(bi => bi.BudgetedAmount).HasPrecision(18, 2);
builder.HasOne(bi => bi.Budget)
.WithMany(b => b.Items)
.HasForeignKey(bi => bi.BudgetId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(bi => bi.Category)
.WithMany(c => c.BudgetItems)
.HasForeignKey(bi => bi.CategoryId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ToTable("categories");
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).HasMaxLength(200).IsRequired();
builder.HasOne(c => c.User)
.WithMany()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => new { c.UserId, c.Name, c.ParentId }).IsUnique();
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class ImportBatchConfiguration : IEntityTypeConfiguration<ImportBatch>
{
public void Configure(EntityTypeBuilder<ImportBatch> builder)
{
builder.ToTable("import_batches");
builder.HasKey(b => b.Id);
builder.Property(b => b.FileName).HasMaxLength(500).IsRequired();
builder.Property(b => b.FileType).HasMaxLength(50).IsRequired();
builder.HasOne(b => b.User)
.WithMany()
.HasForeignKey(b => b.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(b => b.Account)
.WithMany()
.HasForeignKey(b => b.AccountId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class InvestmentHoldingConfiguration : IEntityTypeConfiguration<InvestmentHolding>
{
public void Configure(EntityTypeBuilder<InvestmentHolding> builder)
{
builder.ToTable("investment_holdings");
builder.HasKey(h => h.Id);
builder.Property(h => h.Shares).HasPrecision(18, 6);
builder.Property(h => h.CostBasis).HasPrecision(18, 2);
builder.HasOne(h => h.Account)
.WithMany(a => a.InvestmentHoldings)
.HasForeignKey(h => h.AccountId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(h => h.Security)
.WithMany(s => s.Holdings)
.HasForeignKey(h => h.SecurityId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class SecurityConfiguration : IEntityTypeConfiguration<Security>
{
public void Configure(EntityTypeBuilder<Security> builder)
{
builder.ToTable("securities");
builder.HasKey(s => s.Id);
builder.Property(s => s.Symbol).HasMaxLength(20).IsRequired();
builder.Property(s => s.Name).HasMaxLength(200).IsRequired();
builder.Property(s => s.SecurityType).HasMaxLength(50);
builder.HasIndex(s => s.Symbol).IsUnique();
}
}
public class SecurityPriceConfiguration : IEntityTypeConfiguration<SecurityPrice>
{
public void Configure(EntityTypeBuilder<SecurityPrice> builder)
{
builder.ToTable("security_prices");
builder.HasKey(p => p.Id);
builder.Property(p => p.Price).HasPrecision(18, 4);
builder.HasOne(p => p.Security)
.WithMany(s => s.Prices)
.HasForeignKey(p => p.SecurityId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => new { p.SecurityId, p.Date }).IsUnique();
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class LoanDetailConfiguration : IEntityTypeConfiguration<LoanDetail>
{
public void Configure(EntityTypeBuilder<LoanDetail> builder)
{
builder.ToTable("loan_details");
builder.HasKey(l => l.Id);
builder.Property(l => l.OriginalBalance).HasPrecision(18, 2);
builder.Property(l => l.InterestRate).HasPrecision(8, 4);
builder.Property(l => l.MonthlyPayment).HasPrecision(18, 2);
builder.Property(l => l.EscrowAmount).HasPrecision(18, 2);
builder.Property(l => l.ExtraPayment).HasPrecision(18, 2);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class PayeeAliasConfiguration : IEntityTypeConfiguration<PayeeAlias>
{
public void Configure(EntityTypeBuilder<PayeeAlias> builder)
{
builder.ToTable("payee_aliases");
builder.HasKey(a => a.Id);
builder.Property(a => a.Alias).HasMaxLength(300).IsRequired();
builder.HasOne(a => a.Payee)
.WithMany(p => p.Aliases)
.HasForeignKey(a => a.PayeeId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => a.Alias);
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class PayeeConfiguration : IEntityTypeConfiguration<Payee>
{
public void Configure(EntityTypeBuilder<Payee> builder)
{
builder.ToTable("payees");
builder.HasKey(p => p.Id);
builder.Property(p => p.Name).HasMaxLength(300).IsRequired();
builder.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(p => p.DefaultCategory)
.WithMany()
.HasForeignKey(p => p.DefaultCategoryId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(p => new { p.UserId, p.Name }).IsUnique();
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class PluginRegistrationConfiguration : IEntityTypeConfiguration<PluginRegistration>
{
public void Configure(EntityTypeBuilder<PluginRegistration> builder)
{
builder.ToTable("plugin_registrations");
builder.HasKey(p => p.Id);
builder.Property(p => p.PluginId).HasMaxLength(200).IsRequired();
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
builder.Property(p => p.Version).HasMaxLength(50).IsRequired();
builder.Property(p => p.EntryAssembly).HasMaxLength(500);
builder.Property(p => p.EntryType).HasMaxLength(500);
builder.HasIndex(p => p.PluginId).IsUnique();
}
}
public class PluginConfigurationEntityConfiguration : IEntityTypeConfiguration<Purrse.Core.Models.PluginConfiguration>
{
public void Configure(EntityTypeBuilder<Purrse.Core.Models.PluginConfiguration> builder)
{
builder.ToTable("plugin_configurations");
builder.HasKey(c => c.Id);
builder.Property(c => c.Key).HasMaxLength(200).IsRequired();
builder.Property(c => c.Value).HasMaxLength(2000);
builder.HasOne(c => c.PluginRegistration)
.WithMany(p => p.Configurations)
.HasForeignKey(c => c.PluginRegistrationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(c => new { c.PluginRegistrationId, c.Key }).IsUnique();
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class ReconciliationConfiguration : IEntityTypeConfiguration<Reconciliation>
{
public void Configure(EntityTypeBuilder<Reconciliation> builder)
{
builder.ToTable("reconciliations");
builder.HasKey(r => r.Id);
builder.Property(r => r.StatementBalance).HasPrecision(18, 2);
builder.HasOne(r => r.Account)
.WithMany(a => a.Reconciliations)
.HasForeignKey(r => r.AccountId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class ScheduledTransactionConfiguration : IEntityTypeConfiguration<ScheduledTransaction>
{
public void Configure(EntityTypeBuilder<ScheduledTransaction> builder)
{
builder.ToTable("scheduled_transactions");
builder.HasKey(s => s.Id);
builder.Property(s => s.Amount).HasPrecision(18, 2);
builder.Property(s => s.PayeeName).HasMaxLength(500);
builder.Property(s => s.Memo).HasMaxLength(1000);
builder.HasOne(s => s.User)
.WithMany()
.HasForeignKey(s => s.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Account)
.WithMany()
.HasForeignKey(s => s.AccountId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Payee)
.WithMany()
.HasForeignKey(s => s.PayeeId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(s => s.Category)
.WithMany()
.HasForeignKey(s => s.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class TransactionConfiguration : IEntityTypeConfiguration<Transaction>
{
public void Configure(EntityTypeBuilder<Transaction> builder)
{
builder.ToTable("transactions");
builder.HasKey(t => t.Id);
builder.Property(t => t.Amount).HasPrecision(18, 2);
builder.Property(t => t.PayeeName).HasMaxLength(500);
builder.Property(t => t.Memo).HasMaxLength(1000);
builder.Property(t => t.ReferenceNumber).HasMaxLength(100);
builder.Property(t => t.CheckNumber).HasMaxLength(20);
builder.Property(t => t.FitId).HasMaxLength(255);
builder.Property(t => t.Fingerprint).HasMaxLength(64);
builder.HasOne(t => t.Account)
.WithMany(a => a.Transactions)
.HasForeignKey(t => t.AccountId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(t => t.Payee)
.WithMany(p => p.Transactions)
.HasForeignKey(t => t.PayeeId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.Category)
.WithMany(c => c.Transactions)
.HasForeignKey(t => t.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.TransferTransaction)
.WithOne()
.HasForeignKey<Transaction>(t => t.TransferTransactionId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.ImportBatch)
.WithMany(b => b.Transactions)
.HasForeignKey(t => t.ImportBatchId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.Reconciliation)
.WithMany(r => r.Transactions)
.HasForeignKey(t => t.ReconciliationId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(t => t.AccountId);
builder.HasIndex(t => t.Date);
builder.HasIndex(t => t.Fingerprint);
builder.HasIndex(t => t.FitId);
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class TransactionSplitConfiguration : IEntityTypeConfiguration<TransactionSplit>
{
public void Configure(EntityTypeBuilder<TransactionSplit> builder)
{
builder.ToTable("transaction_splits");
builder.HasKey(s => s.Id);
builder.Property(s => s.Amount).HasPrecision(18, 2);
builder.Property(s => s.Memo).HasMaxLength(500);
builder.HasOne(s => s.Transaction)
.WithMany(t => t.Splits)
.HasForeignKey(s => s.TransactionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(s => s.Category)
.WithMany()
.HasForeignKey(s => s.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Username).HasMaxLength(100).IsRequired();
builder.Property(u => u.Email).HasMaxLength(255).IsRequired();
builder.Property(u => u.PasswordHash).HasMaxLength(255).IsRequired();
builder.HasIndex(u => u.Username).IsUnique();
builder.HasIndex(u => u.Email).IsUnique();
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Purrse.Core\Purrse.Core.csproj" />
</ItemGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Purrse.Core.Models;
namespace Purrse.Data;
public class PurrseDbContext : DbContext
{
public PurrseDbContext(DbContextOptions<PurrseDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<Account> Accounts => Set<Account>();
public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<TransactionSplit> TransactionSplits => Set<TransactionSplit>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Payee> Payees => Set<Payee>();
public DbSet<PayeeAlias> PayeeAliases => Set<PayeeAlias>();
public DbSet<LoanDetail> LoanDetails => Set<LoanDetail>();
public DbSet<AmortizationEntry> AmortizationEntries => Set<AmortizationEntry>();
public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<BudgetItem> BudgetItems => Set<BudgetItem>();
public DbSet<ScheduledTransaction> ScheduledTransactions => Set<ScheduledTransaction>();
public DbSet<ImportBatch> ImportBatches => Set<ImportBatch>();
public DbSet<InvestmentHolding> InvestmentHoldings => Set<InvestmentHolding>();
public DbSet<Security> Securities => Set<Security>();
public DbSet<SecurityPrice> SecurityPrices => Set<SecurityPrice>();
public DbSet<Reconciliation> Reconciliations => Set<Reconciliation>();
public DbSet<PluginRegistration> PluginRegistrations => Set<PluginRegistration>();
public DbSet<PluginConfiguration> PluginConfigurations => Set<PluginConfiguration>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(PurrseDbContext).Assembly);
}
}
+100
View File
@@ -0,0 +1,100 @@
using Purrse.Core.Enums;
using Purrse.Core.Models;
namespace Purrse.Data.Seeds;
public static class CategorySeeder
{
public static List<Category> GetDefaultCategories(Guid userId)
{
var categories = new List<Category>();
int sort = 0;
// Income categories
AddCategory(categories, userId, "Income", CategoryType.Income, null, ref sort, true,
new[] { "Salary", "Bonus", "Interest Income", "Dividend Income", "Rental Income", "Freelance Income", "Gift Received", "Refund", "Other Income" });
// Expense categories
AddCategory(categories, userId, "Housing", CategoryType.Expense, null, ref sort, true,
new[] { "Mortgage Payment", "Rent", "Property Tax", "Home Insurance", "HOA Fees", "Home Maintenance", "Home Improvement" });
AddCategory(categories, userId, "Utilities", CategoryType.Expense, null, ref sort, true,
new[] { "Electric", "Gas", "Water & Sewer", "Trash", "Internet", "Phone", "Cable/Streaming" });
AddCategory(categories, userId, "Food", CategoryType.Expense, null, ref sort, true,
new[] { "Groceries", "Restaurants", "Fast Food", "Coffee Shops" });
AddCategory(categories, userId, "Transportation", CategoryType.Expense, null, ref sort, true,
new[] { "Auto Payment", "Auto Insurance", "Fuel", "Parking", "Public Transit", "Auto Maintenance", "Registration & Fees", "Tolls" });
AddCategory(categories, userId, "Healthcare", CategoryType.Expense, null, ref sort, true,
new[] { "Health Insurance", "Doctor", "Dentist", "Pharmacy", "Vision", "Hospital" });
AddCategory(categories, userId, "Personal", CategoryType.Expense, null, ref sort, true,
new[] { "Clothing", "Personal Care", "Hair & Beauty", "Gym & Fitness", "Education", "Books", "Subscriptions" });
AddCategory(categories, userId, "Entertainment", CategoryType.Expense, null, ref sort, true,
new[] { "Movies & Shows", "Music", "Games", "Hobbies", "Sports", "Vacations" });
AddCategory(categories, userId, "Financial", CategoryType.Expense, null, ref sort, true,
new[] { "Bank Fees", "Credit Card Interest", "Loan Interest", "Late Fees", "ATM Fees" });
AddCategory(categories, userId, "Insurance", CategoryType.Expense, null, ref sort, true,
new[] { "Life Insurance", "Disability Insurance", "Umbrella Insurance" });
AddCategory(categories, userId, "Taxes", CategoryType.Expense, null, ref sort, true,
new[] { "Federal Tax", "State Tax", "Local Tax", "Social Security", "Medicare" });
AddCategory(categories, userId, "Gifts & Donations", CategoryType.Expense, null, ref sort, true,
new[] { "Charity", "Gifts Given", "Religious" });
AddCategory(categories, userId, "Pets", CategoryType.Expense, null, ref sort, true,
new[] { "Pet Food", "Veterinary", "Pet Supplies" });
AddCategory(categories, userId, "Children", CategoryType.Expense, null, ref sort, true,
new[] { "Child Care", "School Expenses", "Allowance", "Activities" });
AddCategory(categories, userId, "Miscellaneous", CategoryType.Expense, null, ref sort, true,
new[] { "Other Expense", "Cash & ATM" });
// Transfer categories
AddCategory(categories, userId, "Transfer", CategoryType.Transfer, null, ref sort, true,
new[] { "Account Transfer", "Credit Card Payment", "Loan Payment", "Investment Contribution" });
return categories;
}
private static void AddCategory(List<Category> categories, Guid userId, string parentName,
CategoryType type, Guid? grandParentId, ref int sort, bool isSystem, string[]? children)
{
var parentId = Guid.NewGuid();
categories.Add(new Category
{
Id = parentId,
UserId = userId,
Name = parentName,
Type = type,
ParentId = grandParentId,
SortOrder = sort++,
IsSystem = isSystem,
IsActive = true
});
if (children == null) return;
foreach (var child in children)
{
categories.Add(new Category
{
Id = Guid.NewGuid(),
UserId = userId,
Name = child,
Type = type,
ParentId = parentId,
SortOrder = sort++,
IsSystem = isSystem,
IsActive = true
});
}
}
}