diff --git a/backend/Asentrafin.Api.csproj b/backend/Asentrafin.Api.csproj
index aeab038..e82e2fc 100644
--- a/backend/Asentrafin.Api.csproj
+++ b/backend/Asentrafin.Api.csproj
@@ -8,6 +8,11 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
diff --git a/backend/Configuration/PlaidOptions.cs b/backend/Configuration/PlaidOptions.cs
new file mode 100644
index 0000000..10384bb
--- /dev/null
+++ b/backend/Configuration/PlaidOptions.cs
@@ -0,0 +1,8 @@
+namespace Asentrafin.Api.Configuration;
+
+public class PlaidOptions
+{
+ public string BaseUrl { get; set; } = "https://sandbox.plaid.com";
+ public string ClientId { get; set; } = string.Empty;
+ public string Secret { get; set; } = string.Empty;
+}
diff --git a/backend/Contracts/Plaid/CreatePlaidItemRequest.cs b/backend/Contracts/Plaid/CreatePlaidItemRequest.cs
new file mode 100644
index 0000000..92a22b6
--- /dev/null
+++ b/backend/Contracts/Plaid/CreatePlaidItemRequest.cs
@@ -0,0 +1,7 @@
+namespace Asentrafin.Api.Contracts.Plaid;
+
+public record CreatePlaidItemRequest(
+ string AccessToken,
+ string? PlaidItemId,
+ string? InstitutionId,
+ string? InstitutionName);
diff --git a/backend/Contracts/Plaid/IngestionSummary.cs b/backend/Contracts/Plaid/IngestionSummary.cs
new file mode 100644
index 0000000..5e8026e
--- /dev/null
+++ b/backend/Contracts/Plaid/IngestionSummary.cs
@@ -0,0 +1,10 @@
+namespace Asentrafin.Api.Contracts.Plaid;
+
+public record IngestionSummary(
+ Guid ItemId,
+ int AccountsUpserted,
+ int TransactionsAddedOrModified,
+ int TransactionsRemoved,
+ int SecuritiesUpserted,
+ int HoldingsUpserted,
+ int InvestmentTransactionsUpserted);
diff --git a/backend/Contracts/Plaid/InvestmentIngestionRequest.cs b/backend/Contracts/Plaid/InvestmentIngestionRequest.cs
new file mode 100644
index 0000000..53834af
--- /dev/null
+++ b/backend/Contracts/Plaid/InvestmentIngestionRequest.cs
@@ -0,0 +1,3 @@
+namespace Asentrafin.Api.Contracts.Plaid;
+
+public record InvestmentIngestionRequest(DateOnly? StartDate, DateOnly? EndDate);
diff --git a/backend/Contracts/Transactions/CategorySummaryResponse.cs b/backend/Contracts/Transactions/CategorySummaryResponse.cs
new file mode 100644
index 0000000..0bf9461
--- /dev/null
+++ b/backend/Contracts/Transactions/CategorySummaryResponse.cs
@@ -0,0 +1,8 @@
+namespace Asentrafin.Api.Contracts.Transactions;
+
+public record CategorySummaryResponse(
+ Guid CategoryId,
+ string CategoryName,
+ string CategorySlug,
+ int TransactionCount,
+ decimal TotalAmount);
diff --git a/backend/Contracts/Transactions/TransactionCategoryResponse.cs b/backend/Contracts/Transactions/TransactionCategoryResponse.cs
new file mode 100644
index 0000000..4e99cf8
--- /dev/null
+++ b/backend/Contracts/Transactions/TransactionCategoryResponse.cs
@@ -0,0 +1,10 @@
+namespace Asentrafin.Api.Contracts.Transactions;
+
+public record TransactionCategoryResponse(
+ Guid Id,
+ Guid? ParentId,
+ string Name,
+ string Slug,
+ string? PlaidPrimary,
+ string? PlaidDetailed,
+ bool IsSystem);
diff --git a/backend/Controllers/DatabaseHealthController.cs b/backend/Controllers/DatabaseHealthController.cs
new file mode 100644
index 0000000..0bbf02d
--- /dev/null
+++ b/backend/Controllers/DatabaseHealthController.cs
@@ -0,0 +1,22 @@
+using Asentrafin.Api.Data;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Asentrafin.Api.Controllers;
+
+[ApiController]
+[Route("api/database")]
+public class DatabaseHealthController(AppDbContext dbContext) : ControllerBase
+{
+ [HttpGet("health")]
+ public async Task GetHealth(CancellationToken cancellationToken)
+ {
+ var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
+
+ return Ok(new
+ {
+ database = "postgres",
+ canConnect
+ });
+ }
+}
diff --git a/backend/Controllers/PlaidIngestionController.cs b/backend/Controllers/PlaidIngestionController.cs
new file mode 100644
index 0000000..f35ea2a
--- /dev/null
+++ b/backend/Controllers/PlaidIngestionController.cs
@@ -0,0 +1,61 @@
+using Asentrafin.Api.Contracts.Plaid;
+using Asentrafin.Api.Services.Plaid;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Asentrafin.Api.Controllers;
+
+[ApiController]
+[Route("api/plaid")]
+public class PlaidIngestionController(IPlaidIngestionService ingestionService) : ControllerBase
+{
+ [HttpPost("items")]
+ public async Task> CreateItem(
+ CreatePlaidItemRequest request,
+ CancellationToken cancellationToken)
+ {
+ var itemId = await ingestionService.CreateItemAsync(request, cancellationToken);
+ return CreatedAtAction(nameof(CreateItem), new { itemId }, new { itemId });
+ }
+
+ [HttpPost("items/{itemId:guid}/ingest/accounts")]
+ public async Task> IngestAccounts(
+ Guid itemId,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await ingestionService.IngestAccountsAsync(itemId, cancellationToken));
+ }
+
+ [HttpPost("items/{itemId:guid}/ingest/transactions")]
+ public async Task> IngestTransactions(
+ Guid itemId,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await ingestionService.IngestTransactionsAsync(itemId, cancellationToken));
+ }
+
+ [HttpPost("items/{itemId:guid}/ingest/investments")]
+ public async Task> IngestInvestments(
+ Guid itemId,
+ InvestmentIngestionRequest request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await ingestionService.IngestInvestmentsAsync(
+ itemId,
+ request.StartDate,
+ request.EndDate,
+ cancellationToken));
+ }
+
+ [HttpPost("items/{itemId:guid}/ingest/all")]
+ public async Task> IngestAll(
+ Guid itemId,
+ InvestmentIngestionRequest request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await ingestionService.IngestAllAsync(
+ itemId,
+ request.StartDate,
+ request.EndDate,
+ cancellationToken));
+ }
+}
diff --git a/backend/Controllers/TransactionAnalyticsController.cs b/backend/Controllers/TransactionAnalyticsController.cs
new file mode 100644
index 0000000..c2d75a4
--- /dev/null
+++ b/backend/Controllers/TransactionAnalyticsController.cs
@@ -0,0 +1,84 @@
+using Asentrafin.Api.Contracts.Transactions;
+using Asentrafin.Api.Data;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Asentrafin.Api.Controllers;
+
+[ApiController]
+[Route("api/transactions")]
+public class TransactionAnalyticsController(AppDbContext dbContext) : ControllerBase
+{
+ [HttpGet("categories")]
+ public async Task>> GetCategories(
+ CancellationToken cancellationToken)
+ {
+ var categories = await dbContext.TransactionCategories
+ .OrderBy(category => category.SortOrder)
+ .ThenBy(category => category.Name)
+ .Select(category => new TransactionCategoryResponse(
+ category.Id,
+ category.ParentId,
+ category.Name,
+ category.Slug,
+ category.PlaidPrimary,
+ category.PlaidDetailed,
+ category.IsSystem))
+ .ToListAsync(cancellationToken);
+
+ return Ok(categories);
+ }
+
+ [HttpGet("category-summaries")]
+ public async Task>> GetCategorySummaries(
+ [FromQuery] DateOnly? startDate,
+ [FromQuery] DateOnly? endDate,
+ [FromQuery] Guid? accountId,
+ [FromQuery] bool includePending = false,
+ CancellationToken cancellationToken = default)
+ {
+ var query = dbContext.PlaidTransactions
+ .AsNoTracking()
+ .Where(transaction => transaction.RemovedAt == null);
+
+ if (!includePending)
+ {
+ query = query.Where(transaction => !transaction.Pending);
+ }
+
+ if (startDate is not null)
+ {
+ query = query.Where(transaction => transaction.Date >= startDate.Value);
+ }
+
+ if (endDate is not null)
+ {
+ query = query.Where(transaction => transaction.Date <= endDate.Value);
+ }
+
+ if (accountId is not null)
+ {
+ query = query.Where(transaction => transaction.PlaidAccountId == accountId.Value);
+ }
+
+ var summaries = await query
+ .Where(transaction => transaction.Classification != null)
+ .GroupBy(transaction => new
+ {
+ transaction.Classification!.Category.Id,
+ transaction.Classification.Category.Name,
+ transaction.Classification.Category.Slug
+ })
+ .Select(group => new CategorySummaryResponse(
+ group.Key.Id,
+ group.Key.Name,
+ group.Key.Slug,
+ group.Count(),
+ group.Sum(transaction => transaction.Amount)))
+ .OrderByDescending(summary => Math.Abs(summary.TotalAmount))
+ .ThenBy(summary => summary.CategoryName)
+ .ToListAsync(cancellationToken);
+
+ return Ok(summaries);
+ }
+}
diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs
new file mode 100644
index 0000000..f30bc5b
--- /dev/null
+++ b/backend/Data/AppDbContext.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using Asentrafin.Api.Entities;
+
+namespace Asentrafin.Api.Data;
+
+public class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet PlaidItems => Set();
+ public DbSet PlaidAccounts => Set();
+ public DbSet PlaidTransactions => Set();
+ public DbSet PlaidSecurities => Set();
+ public DbSet PlaidHoldings => Set();
+ public DbSet PlaidInvestmentTransactions => Set();
+ public DbSet TransactionCategories => Set();
+ public DbSet TransactionClassifications => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.HasDefaultSchema("app");
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
+ }
+}
diff --git a/backend/Data/AppDbContextFactory.cs b/backend/Data/AppDbContextFactory.cs
new file mode 100644
index 0000000..765fbe7
--- /dev/null
+++ b/backend/Data/AppDbContextFactory.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Asentrafin.Api.Data;
+
+public class AppDbContextFactory : IDesignTimeDbContextFactory
+{
+ public AppDbContext CreateDbContext(string[] args)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("ASENTRAFIN_CONNECTION_STRING")
+ ?? "Host=localhost;Port=5432;Database=asentrafin;Username=postgres;Password=postgres";
+
+ var optionsBuilder = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString);
+
+ return new AppDbContext(optionsBuilder.Options);
+ }
+}
diff --git a/backend/Data/Configurations/PlaidAccountConfiguration.cs b/backend/Data/Configurations/PlaidAccountConfiguration.cs
new file mode 100644
index 0000000..5280d42
--- /dev/null
+++ b/backend/Data/Configurations/PlaidAccountConfiguration.cs
@@ -0,0 +1,30 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class PlaidAccountConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("plaid_accounts");
+ builder.HasKey(account => account.Id);
+
+ builder.Property(account => account.PlaidAccountId).HasMaxLength(128).IsRequired();
+ builder.Property(account => account.Name).HasMaxLength(256).IsRequired();
+ builder.Property(account => account.OfficialName).HasMaxLength(512);
+ builder.Property(account => account.Mask).HasMaxLength(32);
+ builder.Property(account => account.Type).HasMaxLength(64).IsRequired();
+ builder.Property(account => account.Subtype).HasMaxLength(64);
+ builder.Property(account => account.IsoCurrencyCode).HasMaxLength(8);
+ builder.Property(account => account.UnofficialCurrencyCode).HasMaxLength(32);
+ builder.Property(account => account.RawJson).HasColumnType("jsonb");
+
+ builder.HasIndex(account => account.PlaidAccountId).IsUnique();
+ builder.HasOne(account => account.PlaidItem)
+ .WithMany(item => item.Accounts)
+ .HasForeignKey(account => account.PlaidItemId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/backend/Data/Configurations/PlaidHoldingConfiguration.cs b/backend/Data/Configurations/PlaidHoldingConfiguration.cs
new file mode 100644
index 0000000..388f6f9
--- /dev/null
+++ b/backend/Data/Configurations/PlaidHoldingConfiguration.cs
@@ -0,0 +1,32 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class PlaidHoldingConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("plaid_holdings");
+ builder.HasKey(holding => holding.Id);
+
+ builder.Property(holding => holding.IsoCurrencyCode).HasMaxLength(8);
+ builder.Property(holding => holding.UnofficialCurrencyCode).HasMaxLength(32);
+ builder.Property(holding => holding.RawJson).HasColumnType("jsonb");
+
+ builder.HasIndex(holding => new { holding.PlaidAccountId, holding.PlaidSecurityId }).IsUnique();
+ builder.HasOne(holding => holding.PlaidItem)
+ .WithMany(item => item.Holdings)
+ .HasForeignKey(holding => holding.PlaidItemId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne(holding => holding.PlaidAccount)
+ .WithMany(account => account.Holdings)
+ .HasForeignKey(holding => holding.PlaidAccountId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne(holding => holding.PlaidSecurity)
+ .WithMany(security => security.Holdings)
+ .HasForeignKey(holding => holding.PlaidSecurityId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/backend/Data/Configurations/PlaidInvestmentTransactionConfiguration.cs b/backend/Data/Configurations/PlaidInvestmentTransactionConfiguration.cs
new file mode 100644
index 0000000..39c97ac
--- /dev/null
+++ b/backend/Data/Configurations/PlaidInvestmentTransactionConfiguration.cs
@@ -0,0 +1,39 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class PlaidInvestmentTransactionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("plaid_investment_transactions");
+ builder.HasKey(transaction => transaction.Id);
+
+ builder.Property(transaction => transaction.PlaidInvestmentTransactionId).HasMaxLength(128).IsRequired();
+ builder.Property(transaction => transaction.AccountPlaidId).HasMaxLength(128).IsRequired();
+ builder.Property(transaction => transaction.SecurityPlaidId).HasMaxLength(128);
+ builder.Property(transaction => transaction.Name).HasMaxLength(512).IsRequired();
+ builder.Property(transaction => transaction.Type).HasMaxLength(64);
+ builder.Property(transaction => transaction.Subtype).HasMaxLength(64);
+ builder.Property(transaction => transaction.IsoCurrencyCode).HasMaxLength(8);
+ builder.Property(transaction => transaction.UnofficialCurrencyCode).HasMaxLength(32);
+ builder.Property(transaction => transaction.RawJson).HasColumnType("jsonb");
+
+ builder.HasIndex(transaction => transaction.PlaidInvestmentTransactionId).IsUnique();
+ builder.HasIndex(transaction => new { transaction.PlaidItemId, transaction.Date });
+ builder.HasOne(transaction => transaction.PlaidItem)
+ .WithMany(item => item.InvestmentTransactions)
+ .HasForeignKey(transaction => transaction.PlaidItemId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne(transaction => transaction.PlaidAccount)
+ .WithMany(account => account.InvestmentTransactions)
+ .HasForeignKey(transaction => transaction.PlaidAccountId)
+ .OnDelete(DeleteBehavior.SetNull);
+ builder.HasOne(transaction => transaction.PlaidSecurity)
+ .WithMany(security => security.InvestmentTransactions)
+ .HasForeignKey(transaction => transaction.PlaidSecurityId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
diff --git a/backend/Data/Configurations/PlaidItemConfiguration.cs b/backend/Data/Configurations/PlaidItemConfiguration.cs
new file mode 100644
index 0000000..bb2023c
--- /dev/null
+++ b/backend/Data/Configurations/PlaidItemConfiguration.cs
@@ -0,0 +1,22 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class PlaidItemConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("plaid_items");
+ builder.HasKey(item => item.Id);
+
+ builder.Property(item => item.AccessToken).IsRequired();
+ builder.Property(item => item.PlaidItemId).HasMaxLength(128);
+ builder.Property(item => item.InstitutionId).HasMaxLength(128);
+ builder.Property(item => item.InstitutionName).HasMaxLength(256);
+ builder.Property(item => item.TransactionsCursor).HasMaxLength(512);
+
+ builder.HasIndex(item => item.PlaidItemId).IsUnique();
+ }
+}
diff --git a/backend/Data/Configurations/PlaidSecurityConfiguration.cs b/backend/Data/Configurations/PlaidSecurityConfiguration.cs
new file mode 100644
index 0000000..0e635f6
--- /dev/null
+++ b/backend/Data/Configurations/PlaidSecurityConfiguration.cs
@@ -0,0 +1,27 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class PlaidSecurityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("plaid_securities");
+ builder.HasKey(security => security.Id);
+
+ builder.Property(security => security.PlaidSecurityId).HasMaxLength(128).IsRequired();
+ builder.Property(security => security.Cusip).HasMaxLength(32);
+ builder.Property(security => security.Isin).HasMaxLength(32);
+ builder.Property(security => security.Sedol).HasMaxLength(32);
+ builder.Property(security => security.TickerSymbol).HasMaxLength(32);
+ builder.Property(security => security.Name).HasMaxLength(512);
+ builder.Property(security => security.Type).HasMaxLength(64);
+ builder.Property(security => security.IsoCurrencyCode).HasMaxLength(8);
+ builder.Property(security => security.UnofficialCurrencyCode).HasMaxLength(32);
+ builder.Property(security => security.RawJson).HasColumnType("jsonb");
+
+ builder.HasIndex(security => security.PlaidSecurityId).IsUnique();
+ }
+}
diff --git a/backend/Data/Configurations/PlaidTransactionConfiguration.cs b/backend/Data/Configurations/PlaidTransactionConfiguration.cs
new file mode 100644
index 0000000..1c8fcbb
--- /dev/null
+++ b/backend/Data/Configurations/PlaidTransactionConfiguration.cs
@@ -0,0 +1,36 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class PlaidTransactionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("plaid_transactions");
+ builder.HasKey(transaction => transaction.Id);
+
+ builder.Property(transaction => transaction.PlaidTransactionId).HasMaxLength(128).IsRequired();
+ builder.Property(transaction => transaction.AccountPlaidId).HasMaxLength(128).IsRequired();
+ builder.Property(transaction => transaction.Name).HasMaxLength(512).IsRequired();
+ builder.Property(transaction => transaction.MerchantName).HasMaxLength(256);
+ builder.Property(transaction => transaction.IsoCurrencyCode).HasMaxLength(8);
+ builder.Property(transaction => transaction.UnofficialCurrencyCode).HasMaxLength(32);
+ builder.Property(transaction => transaction.PersonalFinanceCategoryPrimary).HasMaxLength(128);
+ builder.Property(transaction => transaction.PersonalFinanceCategoryDetailed).HasMaxLength(128);
+ builder.Property(transaction => transaction.Categories).HasColumnType("text[]");
+ builder.Property(transaction => transaction.RawJson).HasColumnType("jsonb");
+
+ builder.HasIndex(transaction => transaction.PlaidTransactionId).IsUnique();
+ builder.HasIndex(transaction => new { transaction.PlaidItemId, transaction.Date });
+ builder.HasOne(transaction => transaction.PlaidItem)
+ .WithMany(item => item.Transactions)
+ .HasForeignKey(transaction => transaction.PlaidItemId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne(transaction => transaction.PlaidAccount)
+ .WithMany(account => account.Transactions)
+ .HasForeignKey(transaction => transaction.PlaidAccountId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
diff --git a/backend/Data/Configurations/TransactionCategoryConfiguration.cs b/backend/Data/Configurations/TransactionCategoryConfiguration.cs
new file mode 100644
index 0000000..ae2e59e
--- /dev/null
+++ b/backend/Data/Configurations/TransactionCategoryConfiguration.cs
@@ -0,0 +1,26 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class TransactionCategoryConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("transaction_categories");
+ builder.HasKey(category => category.Id);
+
+ builder.Property(category => category.Name).HasMaxLength(256).IsRequired();
+ builder.Property(category => category.Slug).HasMaxLength(256).IsRequired();
+ builder.Property(category => category.PlaidPrimary).HasMaxLength(128);
+ builder.Property(category => category.PlaidDetailed).HasMaxLength(128);
+
+ builder.HasIndex(category => category.Slug).IsUnique();
+ builder.HasIndex(category => new { category.PlaidPrimary, category.PlaidDetailed });
+ builder.HasOne(category => category.Parent)
+ .WithMany(category => category.Children)
+ .HasForeignKey(category => category.ParentId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/backend/Data/Configurations/TransactionClassificationConfiguration.cs b/backend/Data/Configurations/TransactionClassificationConfiguration.cs
new file mode 100644
index 0000000..6220407
--- /dev/null
+++ b/backend/Data/Configurations/TransactionClassificationConfiguration.cs
@@ -0,0 +1,28 @@
+using Asentrafin.Api.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Asentrafin.Api.Data.Configurations;
+
+public class TransactionClassificationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("transaction_classifications");
+ builder.HasKey(classification => classification.Id);
+
+ builder.Property(classification => classification.Source).HasMaxLength(64).IsRequired();
+ builder.Property(classification => classification.Notes).HasMaxLength(1024);
+
+ builder.HasIndex(classification => classification.PlaidTransactionId).IsUnique();
+ builder.HasIndex(classification => classification.CategoryId);
+ builder.HasOne(classification => classification.PlaidTransaction)
+ .WithOne(transaction => transaction.Classification)
+ .HasForeignKey(classification => classification.PlaidTransactionId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne(classification => classification.Category)
+ .WithMany(category => category.Classifications)
+ .HasForeignKey(classification => classification.CategoryId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/backend/Data/Migrations/20260707005627_AddPlaidIngestion.Designer.cs b/backend/Data/Migrations/20260707005627_AddPlaidIngestion.Designer.cs
new file mode 100644
index 0000000..feac055
--- /dev/null
+++ b/backend/Data/Migrations/20260707005627_AddPlaidIngestion.Designer.cs
@@ -0,0 +1,568 @@
+//
+using System;
+using Asentrafin.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Asentrafin.Api.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260707005627_AddPlaidIngestion")]
+ partial class AddPlaidIngestion
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("app")
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvailableBalance")
+ .HasColumnType("numeric");
+
+ b.Property("BalanceLastUpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentBalance")
+ .HasColumnType("numeric");
+
+ b.Property("IsoCurrencyCode")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Limit")
+ .HasColumnType("numeric");
+
+ b.Property("Mask")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("OfficialName")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("PlaidAccountId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("PlaidItemId")
+ .HasColumnType("uuid");
+
+ b.Property("RawJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Subtype")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UnofficialCurrencyCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlaidAccountId")
+ .IsUnique();
+
+ b.HasIndex("PlaidItemId");
+
+ b.ToTable("plaid_accounts", "app");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidHolding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CostBasis")
+ .HasColumnType("numeric");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InstitutionPrice")
+ .HasColumnType("numeric");
+
+ b.Property("InstitutionPriceAsOf")
+ .HasColumnType("date");
+
+ b.Property("InstitutionValue")
+ .HasColumnType("numeric");
+
+ b.Property("IsoCurrencyCode")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("PlaidAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("PlaidItemId")
+ .HasColumnType("uuid");
+
+ b.Property("PlaidSecurityId")
+ .HasColumnType("uuid");
+
+ b.Property("Quantity")
+ .HasColumnType("numeric");
+
+ b.Property("RawJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("UnofficialCurrencyCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlaidItemId");
+
+ b.HasIndex("PlaidSecurityId");
+
+ b.HasIndex("PlaidAccountId", "PlaidSecurityId")
+ .IsUnique();
+
+ b.ToTable("plaid_holdings", "app");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidInvestmentTransaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccountPlaidId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Amount")
+ .HasColumnType("numeric");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Fees")
+ .HasColumnType("numeric");
+
+ b.Property("IsoCurrencyCode")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("PlaidAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("PlaidInvestmentTransactionId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("PlaidItemId")
+ .HasColumnType("uuid");
+
+ b.Property("PlaidSecurityId")
+ .HasColumnType("uuid");
+
+ b.Property("Price")
+ .HasColumnType("numeric");
+
+ b.Property("Quantity")
+ .HasColumnType("numeric");
+
+ b.Property("RawJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("SecurityPlaidId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Subtype")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Type")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UnofficialCurrencyCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlaidAccountId");
+
+ b.HasIndex("PlaidInvestmentTransactionId")
+ .IsUnique();
+
+ b.HasIndex("PlaidSecurityId");
+
+ b.HasIndex("PlaidItemId", "Date");
+
+ b.ToTable("plaid_investment_transactions", "app");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccessToken")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InstitutionId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("InstitutionName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("LastAccountsSyncedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastInvestmentsSyncedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastTransactionsSyncedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PlaidItemId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TransactionsCursor")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlaidItemId")
+ .IsUnique();
+
+ b.ToTable("plaid_items", "app");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidSecurity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClosePrice")
+ .HasColumnType("numeric");
+
+ b.Property("ClosePriceAsOf")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Cusip")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Isin")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("IsoCurrencyCode")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Name")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("PlaidSecurityId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("RawJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Sedol")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("TickerSymbol")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Type")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UnofficialCurrencyCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlaidSecurityId")
+ .IsUnique();
+
+ b.ToTable("plaid_securities", "app");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidTransaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccountPlaidId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Amount")
+ .HasColumnType("numeric");
+
+ b.Property("AuthorizedDate")
+ .HasColumnType("date");
+
+ b.PrimitiveCollection("Categories")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("IsoCurrencyCode")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("MerchantName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Pending")
+ .HasColumnType("boolean");
+
+ b.Property("PersonalFinanceCategoryDetailed")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("PersonalFinanceCategoryPrimary")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("PlaidAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("PlaidItemId")
+ .HasColumnType("uuid");
+
+ b.Property("PlaidTransactionId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("RawJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UnofficialCurrencyCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlaidAccountId");
+
+ b.HasIndex("PlaidTransactionId")
+ .IsUnique();
+
+ b.HasIndex("PlaidItemId", "Date");
+
+ b.ToTable("plaid_transactions", "app");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidAccount", b =>
+ {
+ b.HasOne("Asentrafin.Api.Entities.PlaidItem", "PlaidItem")
+ .WithMany("Accounts")
+ .HasForeignKey("PlaidItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("PlaidItem");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidHolding", b =>
+ {
+ b.HasOne("Asentrafin.Api.Entities.PlaidAccount", "PlaidAccount")
+ .WithMany("Holdings")
+ .HasForeignKey("PlaidAccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Asentrafin.Api.Entities.PlaidItem", "PlaidItem")
+ .WithMany("Holdings")
+ .HasForeignKey("PlaidItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Asentrafin.Api.Entities.PlaidSecurity", "PlaidSecurity")
+ .WithMany("Holdings")
+ .HasForeignKey("PlaidSecurityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("PlaidAccount");
+
+ b.Navigation("PlaidItem");
+
+ b.Navigation("PlaidSecurity");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidInvestmentTransaction", b =>
+ {
+ b.HasOne("Asentrafin.Api.Entities.PlaidAccount", "PlaidAccount")
+ .WithMany("InvestmentTransactions")
+ .HasForeignKey("PlaidAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("Asentrafin.Api.Entities.PlaidItem", "PlaidItem")
+ .WithMany("InvestmentTransactions")
+ .HasForeignKey("PlaidItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Asentrafin.Api.Entities.PlaidSecurity", "PlaidSecurity")
+ .WithMany("InvestmentTransactions")
+ .HasForeignKey("PlaidSecurityId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("PlaidAccount");
+
+ b.Navigation("PlaidItem");
+
+ b.Navigation("PlaidSecurity");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidTransaction", b =>
+ {
+ b.HasOne("Asentrafin.Api.Entities.PlaidAccount", "PlaidAccount")
+ .WithMany("Transactions")
+ .HasForeignKey("PlaidAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("Asentrafin.Api.Entities.PlaidItem", "PlaidItem")
+ .WithMany("Transactions")
+ .HasForeignKey("PlaidItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("PlaidAccount");
+
+ b.Navigation("PlaidItem");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidAccount", b =>
+ {
+ b.Navigation("Holdings");
+
+ b.Navigation("InvestmentTransactions");
+
+ b.Navigation("Transactions");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidItem", b =>
+ {
+ b.Navigation("Accounts");
+
+ b.Navigation("Holdings");
+
+ b.Navigation("InvestmentTransactions");
+
+ b.Navigation("Transactions");
+ });
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidSecurity", b =>
+ {
+ b.Navigation("Holdings");
+
+ b.Navigation("InvestmentTransactions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/Data/Migrations/20260707005627_AddPlaidIngestion.cs b/backend/Data/Migrations/20260707005627_AddPlaidIngestion.cs
new file mode 100644
index 0000000..a15d2b9
--- /dev/null
+++ b/backend/Data/Migrations/20260707005627_AddPlaidIngestion.cs
@@ -0,0 +1,362 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Asentrafin.Api.Data.Migrations
+{
+ ///
+ public partial class AddPlaidIngestion : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "app");
+
+ migrationBuilder.CreateTable(
+ name: "plaid_items",
+ schema: "app",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ AccessToken = table.Column(type: "text", nullable: false),
+ PlaidItemId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ InstitutionId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ InstitutionName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ TransactionsCursor = table.Column(type: "character varying(512)", maxLength: 512, nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ LastAccountsSyncedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ LastTransactionsSyncedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ LastInvestmentsSyncedAt = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_plaid_items", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "plaid_securities",
+ schema: "app",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ PlaidSecurityId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Cusip = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ Isin = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ Sedol = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ TickerSymbol = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ Name = table.Column(type: "character varying(512)", maxLength: 512, nullable: true),
+ Type = table.Column(type: "character varying(64)", maxLength: 64, nullable: true),
+ ClosePrice = table.Column(type: "numeric", nullable: true),
+ ClosePriceAsOf = table.Column(type: "date", nullable: true),
+ IsoCurrencyCode = table.Column(type: "character varying(8)", maxLength: 8, nullable: true),
+ UnofficialCurrencyCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ RawJson = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_plaid_securities", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "plaid_accounts",
+ schema: "app",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ PlaidItemId = table.Column(type: "uuid", nullable: false),
+ PlaidAccountId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ OfficialName = table.Column(type: "character varying(512)", maxLength: 512, nullable: true),
+ Mask = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ Type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ Subtype = table.Column(type: "character varying(64)", maxLength: 64, nullable: true),
+ CurrentBalance = table.Column(type: "numeric", nullable: true),
+ AvailableBalance = table.Column(type: "numeric", nullable: true),
+ Limit = table.Column(type: "numeric", nullable: true),
+ IsoCurrencyCode = table.Column(type: "character varying(8)", maxLength: 8, nullable: true),
+ UnofficialCurrencyCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ BalanceLastUpdatedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ RawJson = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_plaid_accounts", x => x.Id);
+ table.ForeignKey(
+ name: "FK_plaid_accounts_plaid_items_PlaidItemId",
+ column: x => x.PlaidItemId,
+ principalSchema: "app",
+ principalTable: "plaid_items",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "plaid_holdings",
+ schema: "app",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ PlaidItemId = table.Column(type: "uuid", nullable: false),
+ PlaidAccountId = table.Column(type: "uuid", nullable: false),
+ PlaidSecurityId = table.Column(type: "uuid", nullable: false),
+ Quantity = table.Column(type: "numeric", nullable: false),
+ InstitutionPrice = table.Column(type: "numeric", nullable: true),
+ InstitutionValue = table.Column(type: "numeric", nullable: true),
+ InstitutionPriceAsOf = table.Column(type: "date", nullable: true),
+ CostBasis = table.Column(type: "numeric", nullable: true),
+ IsoCurrencyCode = table.Column(type: "character varying(8)", maxLength: 8, nullable: true),
+ UnofficialCurrencyCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ RawJson = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_plaid_holdings", x => x.Id);
+ table.ForeignKey(
+ name: "FK_plaid_holdings_plaid_accounts_PlaidAccountId",
+ column: x => x.PlaidAccountId,
+ principalSchema: "app",
+ principalTable: "plaid_accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_plaid_holdings_plaid_items_PlaidItemId",
+ column: x => x.PlaidItemId,
+ principalSchema: "app",
+ principalTable: "plaid_items",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_plaid_holdings_plaid_securities_PlaidSecurityId",
+ column: x => x.PlaidSecurityId,
+ principalSchema: "app",
+ principalTable: "plaid_securities",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "plaid_investment_transactions",
+ schema: "app",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ PlaidItemId = table.Column(type: "uuid", nullable: false),
+ PlaidAccountId = table.Column(type: "uuid", nullable: true),
+ PlaidSecurityId = table.Column(type: "uuid", nullable: true),
+ PlaidInvestmentTransactionId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ AccountPlaidId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ SecurityPlaidId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ Date = table.Column(type: "date", nullable: false),
+ Name = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ Quantity = table.Column(type: "numeric", nullable: false),
+ Amount = table.Column(type: "numeric", nullable: false),
+ Price = table.Column(type: "numeric", nullable: true),
+ Fees = table.Column(type: "numeric", nullable: true),
+ Type = table.Column(type: "character varying(64)", maxLength: 64, nullable: true),
+ Subtype = table.Column(type: "character varying(64)", maxLength: 64, nullable: true),
+ IsoCurrencyCode = table.Column(type: "character varying(8)", maxLength: 8, nullable: true),
+ UnofficialCurrencyCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ RawJson = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_plaid_investment_transactions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_plaid_investment_transactions_plaid_accounts_PlaidAccountId",
+ column: x => x.PlaidAccountId,
+ principalSchema: "app",
+ principalTable: "plaid_accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_plaid_investment_transactions_plaid_items_PlaidItemId",
+ column: x => x.PlaidItemId,
+ principalSchema: "app",
+ principalTable: "plaid_items",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_plaid_investment_transactions_plaid_securities_PlaidSecurit~",
+ column: x => x.PlaidSecurityId,
+ principalSchema: "app",
+ principalTable: "plaid_securities",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "plaid_transactions",
+ schema: "app",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ PlaidItemId = table.Column(type: "uuid", nullable: false),
+ PlaidAccountId = table.Column(type: "uuid", nullable: true),
+ PlaidTransactionId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ AccountPlaidId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Date = table.Column(type: "date", nullable: false),
+ AuthorizedDate = table.Column(type: "date", nullable: true),
+ Name = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ MerchantName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ Amount = table.Column(type: "numeric", nullable: false),
+ Pending = table.Column(type: "boolean", nullable: false),
+ IsoCurrencyCode = table.Column(type: "character varying(8)", maxLength: 8, nullable: true),
+ UnofficialCurrencyCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ Categories = table.Column(type: "text[]", nullable: false),
+ PersonalFinanceCategoryPrimary = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ PersonalFinanceCategoryDetailed = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ RemovedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ RawJson = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_plaid_transactions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_plaid_transactions_plaid_accounts_PlaidAccountId",
+ column: x => x.PlaidAccountId,
+ principalSchema: "app",
+ principalTable: "plaid_accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_plaid_transactions_plaid_items_PlaidItemId",
+ column: x => x.PlaidItemId,
+ principalSchema: "app",
+ principalTable: "plaid_items",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_accounts_PlaidAccountId",
+ schema: "app",
+ table: "plaid_accounts",
+ column: "PlaidAccountId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_accounts_PlaidItemId",
+ schema: "app",
+ table: "plaid_accounts",
+ column: "PlaidItemId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_holdings_PlaidAccountId_PlaidSecurityId",
+ schema: "app",
+ table: "plaid_holdings",
+ columns: new[] { "PlaidAccountId", "PlaidSecurityId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_holdings_PlaidItemId",
+ schema: "app",
+ table: "plaid_holdings",
+ column: "PlaidItemId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_holdings_PlaidSecurityId",
+ schema: "app",
+ table: "plaid_holdings",
+ column: "PlaidSecurityId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_investment_transactions_PlaidAccountId",
+ schema: "app",
+ table: "plaid_investment_transactions",
+ column: "PlaidAccountId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_investment_transactions_PlaidInvestmentTransactionId",
+ schema: "app",
+ table: "plaid_investment_transactions",
+ column: "PlaidInvestmentTransactionId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_investment_transactions_PlaidItemId_Date",
+ schema: "app",
+ table: "plaid_investment_transactions",
+ columns: new[] { "PlaidItemId", "Date" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_investment_transactions_PlaidSecurityId",
+ schema: "app",
+ table: "plaid_investment_transactions",
+ column: "PlaidSecurityId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_items_PlaidItemId",
+ schema: "app",
+ table: "plaid_items",
+ column: "PlaidItemId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_securities_PlaidSecurityId",
+ schema: "app",
+ table: "plaid_securities",
+ column: "PlaidSecurityId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_transactions_PlaidAccountId",
+ schema: "app",
+ table: "plaid_transactions",
+ column: "PlaidAccountId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_transactions_PlaidItemId_Date",
+ schema: "app",
+ table: "plaid_transactions",
+ columns: new[] { "PlaidItemId", "Date" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_plaid_transactions_PlaidTransactionId",
+ schema: "app",
+ table: "plaid_transactions",
+ column: "PlaidTransactionId",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "plaid_holdings",
+ schema: "app");
+
+ migrationBuilder.DropTable(
+ name: "plaid_investment_transactions",
+ schema: "app");
+
+ migrationBuilder.DropTable(
+ name: "plaid_transactions",
+ schema: "app");
+
+ migrationBuilder.DropTable(
+ name: "plaid_securities",
+ schema: "app");
+
+ migrationBuilder.DropTable(
+ name: "plaid_accounts",
+ schema: "app");
+
+ migrationBuilder.DropTable(
+ name: "plaid_items",
+ schema: "app");
+ }
+ }
+}
diff --git a/backend/Data/Migrations/20260708000510_AddTransactionCategorization.Designer.cs b/backend/Data/Migrations/20260708000510_AddTransactionCategorization.Designer.cs
new file mode 100644
index 0000000..c14906c
--- /dev/null
+++ b/backend/Data/Migrations/20260708000510_AddTransactionCategorization.Designer.cs
@@ -0,0 +1,700 @@
+//
+using System;
+using Asentrafin.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Asentrafin.Api.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260708000510_AddTransactionCategorization")]
+ partial class AddTransactionCategorization
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("app")
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Asentrafin.Api.Entities.PlaidAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvailableBalance")
+ .HasColumnType("numeric");
+
+ b.Property("BalanceLastUpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentBalance")
+ .HasColumnType("numeric");
+
+ b.Property("IsoCurrencyCode")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Limit")
+ .HasColumnType("numeric");
+
+ b.Property("Mask")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("OfficialName")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("PlaidAccountId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("PlaidItemId")
+ .HasColumnType("uuid");
+
+ b.Property("RawJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Subtype")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UnofficialCurrencyCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property