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("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.TransactionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("PlaidDetailed") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PlaidPrimary") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("PlaidPrimary", "PlaidDetailed"); + + b.ToTable("transaction_categories", "app"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.TransactionClassification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PlaidTransactionId") + .HasColumnType("uuid"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PlaidTransactionId") + .IsUnique(); + + b.ToTable("transaction_classifications", "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.TransactionCategory", b => + { + b.HasOne("Asentrafin.Api.Entities.TransactionCategory", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.TransactionClassification", b => + { + b.HasOne("Asentrafin.Api.Entities.TransactionCategory", "Category") + .WithMany("Classifications") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Asentrafin.Api.Entities.PlaidTransaction", "PlaidTransaction") + .WithOne("Classification") + .HasForeignKey("Asentrafin.Api.Entities.TransactionClassification", "PlaidTransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("PlaidTransaction"); + }); + + 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"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.PlaidTransaction", b => + { + b.Navigation("Classification"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.TransactionCategory", b => + { + b.Navigation("Children"); + + b.Navigation("Classifications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Data/Migrations/20260708000510_AddTransactionCategorization.cs b/backend/Data/Migrations/20260708000510_AddTransactionCategorization.cs new file mode 100644 index 0000000..27e5fb4 --- /dev/null +++ b/backend/Data/Migrations/20260708000510_AddTransactionCategorization.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Asentrafin.Api.Data.Migrations +{ + /// + public partial class AddTransactionCategorization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "transaction_categories", + schema: "app", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ParentId = table.Column(type: "uuid", nullable: true), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Slug = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + PlaidPrimary = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + PlaidDetailed = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + IsSystem = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_transaction_categories", x => x.Id); + table.ForeignKey( + name: "FK_transaction_categories_transaction_categories_ParentId", + column: x => x.ParentId, + principalSchema: "app", + principalTable: "transaction_categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "transaction_classifications", + schema: "app", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PlaidTransactionId = table.Column(type: "uuid", nullable: false), + CategoryId = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Confidence = table.Column(type: "numeric", nullable: true), + Notes = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_transaction_classifications", x => x.Id); + table.ForeignKey( + name: "FK_transaction_classifications_plaid_transactions_PlaidTransac~", + column: x => x.PlaidTransactionId, + principalSchema: "app", + principalTable: "plaid_transactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_transaction_classifications_transaction_categories_Category~", + column: x => x.CategoryId, + principalSchema: "app", + principalTable: "transaction_categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_transaction_categories_ParentId", + schema: "app", + table: "transaction_categories", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_transaction_categories_PlaidPrimary_PlaidDetailed", + schema: "app", + table: "transaction_categories", + columns: new[] { "PlaidPrimary", "PlaidDetailed" }); + + migrationBuilder.CreateIndex( + name: "IX_transaction_categories_Slug", + schema: "app", + table: "transaction_categories", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_transaction_classifications_CategoryId", + schema: "app", + table: "transaction_classifications", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_transaction_classifications_PlaidTransactionId", + schema: "app", + table: "transaction_classifications", + column: "PlaidTransactionId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "transaction_classifications", + schema: "app"); + + migrationBuilder.DropTable( + name: "transaction_categories", + schema: "app"); + } + } +} diff --git a/backend/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..99a1b42 --- /dev/null +++ b/backend/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,697 @@ +// +using System; +using Asentrafin.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Asentrafin.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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.TransactionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("PlaidDetailed") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PlaidPrimary") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("PlaidPrimary", "PlaidDetailed"); + + b.ToTable("transaction_categories", "app"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.TransactionClassification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PlaidTransactionId") + .HasColumnType("uuid"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PlaidTransactionId") + .IsUnique(); + + b.ToTable("transaction_classifications", "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.TransactionCategory", b => + { + b.HasOne("Asentrafin.Api.Entities.TransactionCategory", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.TransactionClassification", b => + { + b.HasOne("Asentrafin.Api.Entities.TransactionCategory", "Category") + .WithMany("Classifications") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Asentrafin.Api.Entities.PlaidTransaction", "PlaidTransaction") + .WithOne("Classification") + .HasForeignKey("Asentrafin.Api.Entities.TransactionClassification", "PlaidTransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("PlaidTransaction"); + }); + + 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"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.PlaidTransaction", b => + { + b.Navigation("Classification"); + }); + + modelBuilder.Entity("Asentrafin.Api.Entities.TransactionCategory", b => + { + b.Navigation("Children"); + + b.Navigation("Classifications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Entities/PlaidAccount.cs b/backend/Entities/PlaidAccount.cs new file mode 100644 index 0000000..a42972f --- /dev/null +++ b/backend/Entities/PlaidAccount.cs @@ -0,0 +1,27 @@ +namespace Asentrafin.Api.Entities; + +public class PlaidAccount +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlaidItemId { get; set; } + public PlaidItem PlaidItem { get; set; } = null!; + public string PlaidAccountId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? OfficialName { get; set; } + public string? Mask { get; set; } + public string Type { get; set; } = string.Empty; + public string? Subtype { get; set; } + public decimal? CurrentBalance { get; set; } + public decimal? AvailableBalance { get; set; } + public decimal? Limit { get; set; } + public string? IsoCurrencyCode { get; set; } + public string? UnofficialCurrencyCode { get; set; } + public DateTimeOffset? BalanceLastUpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public string RawJson { get; set; } = "{}"; + + public ICollection Transactions { get; set; } = []; + public ICollection Holdings { get; set; } = []; + public ICollection InvestmentTransactions { get; set; } = []; +} diff --git a/backend/Entities/PlaidHolding.cs b/backend/Entities/PlaidHolding.cs new file mode 100644 index 0000000..ca0303b --- /dev/null +++ b/backend/Entities/PlaidHolding.cs @@ -0,0 +1,22 @@ +namespace Asentrafin.Api.Entities; + +public class PlaidHolding +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlaidItemId { get; set; } + public PlaidItem PlaidItem { get; set; } = null!; + public Guid PlaidAccountId { get; set; } + public PlaidAccount PlaidAccount { get; set; } = null!; + public Guid PlaidSecurityId { get; set; } + public PlaidSecurity PlaidSecurity { get; set; } = null!; + public decimal Quantity { get; set; } + public decimal? InstitutionPrice { get; set; } + public decimal? InstitutionValue { get; set; } + public DateOnly? InstitutionPriceAsOf { get; set; } + public decimal? CostBasis { get; set; } + public string? IsoCurrencyCode { get; set; } + public string? UnofficialCurrencyCode { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public string RawJson { get; set; } = "{}"; +} diff --git a/backend/Entities/PlaidInvestmentTransaction.cs b/backend/Entities/PlaidInvestmentTransaction.cs new file mode 100644 index 0000000..58512fb --- /dev/null +++ b/backend/Entities/PlaidInvestmentTransaction.cs @@ -0,0 +1,28 @@ +namespace Asentrafin.Api.Entities; + +public class PlaidInvestmentTransaction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlaidItemId { get; set; } + public PlaidItem PlaidItem { get; set; } = null!; + public Guid? PlaidAccountId { get; set; } + public PlaidAccount? PlaidAccount { get; set; } + public Guid? PlaidSecurityId { get; set; } + public PlaidSecurity? PlaidSecurity { get; set; } + public string PlaidInvestmentTransactionId { get; set; } = string.Empty; + public string AccountPlaidId { get; set; } = string.Empty; + public string? SecurityPlaidId { get; set; } + public DateOnly Date { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal Amount { get; set; } + public decimal? Price { get; set; } + public decimal? Fees { get; set; } + public string? Type { get; set; } + public string? Subtype { get; set; } + public string? IsoCurrencyCode { get; set; } + public string? UnofficialCurrencyCode { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public string RawJson { get; set; } = "{}"; +} diff --git a/backend/Entities/PlaidItem.cs b/backend/Entities/PlaidItem.cs new file mode 100644 index 0000000..2ef3afc --- /dev/null +++ b/backend/Entities/PlaidItem.cs @@ -0,0 +1,21 @@ +namespace Asentrafin.Api.Entities; + +public class PlaidItem +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string AccessToken { get; set; } = string.Empty; + public string? PlaidItemId { get; set; } + public string? InstitutionId { get; set; } + public string? InstitutionName { get; set; } + public string? TransactionsCursor { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastAccountsSyncedAt { get; set; } + public DateTimeOffset? LastTransactionsSyncedAt { get; set; } + public DateTimeOffset? LastInvestmentsSyncedAt { get; set; } + + public ICollection Accounts { get; set; } = []; + public ICollection Transactions { get; set; } = []; + public ICollection Holdings { get; set; } = []; + public ICollection InvestmentTransactions { get; set; } = []; +} diff --git a/backend/Entities/PlaidSecurity.cs b/backend/Entities/PlaidSecurity.cs new file mode 100644 index 0000000..64edd94 --- /dev/null +++ b/backend/Entities/PlaidSecurity.cs @@ -0,0 +1,23 @@ +namespace Asentrafin.Api.Entities; + +public class PlaidSecurity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string PlaidSecurityId { get; set; } = string.Empty; + public string? Cusip { get; set; } + public string? Isin { get; set; } + public string? Sedol { get; set; } + public string? TickerSymbol { get; set; } + public string? Name { get; set; } + public string? Type { get; set; } + public decimal? ClosePrice { get; set; } + public DateOnly? ClosePriceAsOf { get; set; } + public string? IsoCurrencyCode { get; set; } + public string? UnofficialCurrencyCode { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public string RawJson { get; set; } = "{}"; + + public ICollection Holdings { get; set; } = []; + public ICollection InvestmentTransactions { get; set; } = []; +} diff --git a/backend/Entities/PlaidTransaction.cs b/backend/Entities/PlaidTransaction.cs new file mode 100644 index 0000000..032e127 --- /dev/null +++ b/backend/Entities/PlaidTransaction.cs @@ -0,0 +1,28 @@ +namespace Asentrafin.Api.Entities; + +public class PlaidTransaction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlaidItemId { get; set; } + public PlaidItem PlaidItem { get; set; } = null!; + public Guid? PlaidAccountId { get; set; } + public PlaidAccount? PlaidAccount { get; set; } + public string PlaidTransactionId { get; set; } = string.Empty; + public string AccountPlaidId { get; set; } = string.Empty; + public DateOnly Date { get; set; } + public DateOnly? AuthorizedDate { get; set; } + public string Name { get; set; } = string.Empty; + public string? MerchantName { get; set; } + public decimal Amount { get; set; } + public bool Pending { get; set; } + public string? IsoCurrencyCode { get; set; } + public string? UnofficialCurrencyCode { get; set; } + public string[] Categories { get; set; } = []; + public string? PersonalFinanceCategoryPrimary { get; set; } + public string? PersonalFinanceCategoryDetailed { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? RemovedAt { get; set; } + public string RawJson { get; set; } = "{}"; + public TransactionClassification? Classification { get; set; } +} diff --git a/backend/Entities/TransactionCategory.cs b/backend/Entities/TransactionCategory.cs new file mode 100644 index 0000000..3abe529 --- /dev/null +++ b/backend/Entities/TransactionCategory.cs @@ -0,0 +1,19 @@ +namespace Asentrafin.Api.Entities; + +public class TransactionCategory +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? ParentId { get; set; } + public TransactionCategory? Parent { get; set; } + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string? PlaidPrimary { get; set; } + public string? PlaidDetailed { get; set; } + public int SortOrder { get; set; } + public bool IsSystem { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Children { get; set; } = []; + public ICollection Classifications { get; set; } = []; +} diff --git a/backend/Entities/TransactionClassification.cs b/backend/Entities/TransactionClassification.cs new file mode 100644 index 0000000..49cb551 --- /dev/null +++ b/backend/Entities/TransactionClassification.cs @@ -0,0 +1,15 @@ +namespace Asentrafin.Api.Entities; + +public class TransactionClassification +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlaidTransactionId { get; set; } + public PlaidTransaction PlaidTransaction { get; set; } = null!; + public Guid CategoryId { get; set; } + public TransactionCategory Category { get; set; } = null!; + public string Source { get; set; } = "plaid"; + public decimal? Confidence { get; set; } + public string? Notes { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/backend/Program.cs b/backend/Program.cs index ae7a0ba..237efb1 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,7 +1,26 @@ +using Asentrafin.Api.Data; +using Asentrafin.Api.Configuration; +using Asentrafin.Api.Services.Plaid; +using Microsoft.EntityFrameworkCore; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' was not found."); + +builder.Services.AddDbContextPool(options => + options.UseNpgsql(connectionString)); + +builder.Services.Configure(builder.Configuration.GetSection("Plaid")); +builder.Services.AddHttpClient((serviceProvider, client) => +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); +}); +builder.Services.AddScoped(); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => diff --git a/backend/Services/Plaid/IPlaidClient.cs b/backend/Services/Plaid/IPlaidClient.cs new file mode 100644 index 0000000..f0c40a7 --- /dev/null +++ b/backend/Services/Plaid/IPlaidClient.cs @@ -0,0 +1,17 @@ +using System.Text.Json; + +namespace Asentrafin.Api.Services.Plaid; + +public interface IPlaidClient +{ + Task AccountsGetAsync(string accessToken, CancellationToken cancellationToken); + Task TransactionsSyncAsync(string accessToken, string? cursor, CancellationToken cancellationToken); + Task InvestmentsHoldingsGetAsync(string accessToken, CancellationToken cancellationToken); + Task InvestmentsTransactionsGetAsync( + string accessToken, + DateOnly startDate, + DateOnly endDate, + int count, + int offset, + CancellationToken cancellationToken); +} diff --git a/backend/Services/Plaid/IPlaidIngestionService.cs b/backend/Services/Plaid/IPlaidIngestionService.cs new file mode 100644 index 0000000..f397293 --- /dev/null +++ b/backend/Services/Plaid/IPlaidIngestionService.cs @@ -0,0 +1,20 @@ +using Asentrafin.Api.Contracts.Plaid; + +namespace Asentrafin.Api.Services.Plaid; + +public interface IPlaidIngestionService +{ + Task CreateItemAsync(CreatePlaidItemRequest request, CancellationToken cancellationToken); + Task IngestAccountsAsync(Guid itemId, CancellationToken cancellationToken); + Task IngestTransactionsAsync(Guid itemId, CancellationToken cancellationToken); + Task IngestInvestmentsAsync( + Guid itemId, + DateOnly? startDate, + DateOnly? endDate, + CancellationToken cancellationToken); + Task IngestAllAsync( + Guid itemId, + DateOnly? investmentStartDate, + DateOnly? investmentEndDate, + CancellationToken cancellationToken); +} diff --git a/backend/Services/Plaid/PlaidClient.cs b/backend/Services/Plaid/PlaidClient.cs new file mode 100644 index 0000000..cfd1bdf --- /dev/null +++ b/backend/Services/Plaid/PlaidClient.cs @@ -0,0 +1,95 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Asentrafin.Api.Configuration; +using Microsoft.Extensions.Options; + +namespace Asentrafin.Api.Services.Plaid; + +public class PlaidClient(HttpClient httpClient, IOptions options) : IPlaidClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly PlaidOptions _options = options.Value; + + public Task AccountsGetAsync(string accessToken, CancellationToken cancellationToken) + { + return PostAsync("/accounts/get", BuildAuthedRequest(accessToken), cancellationToken); + } + + public Task TransactionsSyncAsync(string accessToken, string? cursor, CancellationToken cancellationToken) + { + var body = BuildAuthedRequest(accessToken); + body["cursor"] = cursor; + body["count"] = 500; + body["options"] = new Dictionary + { + ["include_original_description"] = true, + ["personal_finance_category_version"] = "v2" + }; + + return PostAsync("/transactions/sync", body, cancellationToken); + } + + public Task InvestmentsHoldingsGetAsync(string accessToken, CancellationToken cancellationToken) + { + return PostAsync("/investments/holdings/get", BuildAuthedRequest(accessToken), cancellationToken); + } + + public Task InvestmentsTransactionsGetAsync( + string accessToken, + DateOnly startDate, + DateOnly endDate, + int count, + int offset, + CancellationToken cancellationToken) + { + var body = BuildAuthedRequest(accessToken); + body["start_date"] = startDate.ToString("yyyy-MM-dd"); + body["end_date"] = endDate.ToString("yyyy-MM-dd"); + body["options"] = new Dictionary + { + ["count"] = count, + ["offset"] = offset + }; + + return PostAsync("/investments/transactions/get", body, cancellationToken); + } + + private async Task PostAsync( + string path, + Dictionary body, + CancellationToken cancellationToken) + { + EnsureConfigured(); + + using var response = await httpClient.PostAsJsonAsync(path, body, JsonOptions, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Plaid request to {path} failed with {(int)response.StatusCode}: {responseBody}", + null, + response.StatusCode); + } + + return JsonDocument.Parse(responseBody); + } + + private Dictionary BuildAuthedRequest(string accessToken) + { + return new Dictionary + { + ["client_id"] = _options.ClientId, + ["secret"] = _options.Secret, + ["access_token"] = accessToken + }; + } + + private void EnsureConfigured() + { + if (string.IsNullOrWhiteSpace(_options.ClientId) || string.IsNullOrWhiteSpace(_options.Secret)) + { + throw new InvalidOperationException("Plaid ClientId and Secret must be configured."); + } + } +} diff --git a/backend/Services/Plaid/PlaidIngestionService.cs b/backend/Services/Plaid/PlaidIngestionService.cs new file mode 100644 index 0000000..88346b8 --- /dev/null +++ b/backend/Services/Plaid/PlaidIngestionService.cs @@ -0,0 +1,617 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Asentrafin.Api.Contracts.Plaid; +using Asentrafin.Api.Data; +using Asentrafin.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Asentrafin.Api.Services.Plaid; + +public class PlaidIngestionService(AppDbContext dbContext, IPlaidClient plaidClient) : IPlaidIngestionService +{ + private static readonly Regex SlugSanitizer = new("[^a-z0-9]+", RegexOptions.Compiled); + + public async Task CreateItemAsync(CreatePlaidItemRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.AccessToken)) + { + throw new ArgumentException("AccessToken is required.", nameof(request)); + } + + var existingItem = request.PlaidItemId is null + ? null + : await dbContext.PlaidItems + .SingleOrDefaultAsync(item => item.PlaidItemId == request.PlaidItemId, cancellationToken); + + if (existingItem is not null) + { + existingItem.AccessToken = request.AccessToken; + existingItem.InstitutionId = request.InstitutionId; + existingItem.InstitutionName = request.InstitutionName; + existingItem.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return existingItem.Id; + } + + var item = new PlaidItem + { + AccessToken = request.AccessToken, + PlaidItemId = request.PlaidItemId, + InstitutionId = request.InstitutionId, + InstitutionName = request.InstitutionName + }; + + dbContext.PlaidItems.Add(item); + await dbContext.SaveChangesAsync(cancellationToken); + return item.Id; + } + + public async Task IngestAccountsAsync(Guid itemId, CancellationToken cancellationToken) + { + var item = await GetItemAsync(itemId, cancellationToken); + using var response = await plaidClient.AccountsGetAsync(item.AccessToken, cancellationToken); + + var accountsUpserted = await UpsertAccountsAsync(item, response.RootElement, cancellationToken); + item.LastAccountsSyncedAt = DateTimeOffset.UtcNow; + item.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + return new IngestionSummary(item.Id, accountsUpserted, 0, 0, 0, 0, 0); + } + + public async Task IngestTransactionsAsync(Guid itemId, CancellationToken cancellationToken) + { + var item = await GetItemAsync(itemId, cancellationToken); + var cursor = item.TransactionsCursor; + var accountsUpserted = 0; + var transactionsUpserted = 0; + var transactionsRemoved = 0; + var hasMore = true; + + while (hasMore) + { + using var response = await plaidClient.TransactionsSyncAsync(item.AccessToken, cursor, cancellationToken); + var root = response.RootElement; + + accountsUpserted += await UpsertAccountsAsync(item, root, cancellationToken); + transactionsUpserted += await UpsertTransactionsAsync(item, root, "added", cancellationToken); + transactionsUpserted += await UpsertTransactionsAsync(item, root, "modified", cancellationToken); + transactionsRemoved += await MarkTransactionsRemovedAsync(root, cancellationToken); + + cursor = root.String("next_cursor") ?? cursor; + hasMore = root.Property("has_more") is { ValueKind: JsonValueKind.True }; + } + + item.TransactionsCursor = cursor; + item.LastTransactionsSyncedAt = DateTimeOffset.UtcNow; + item.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + return new IngestionSummary(item.Id, accountsUpserted, transactionsUpserted, transactionsRemoved, 0, 0, 0); + } + + public async Task IngestInvestmentsAsync( + Guid itemId, + DateOnly? startDate, + DateOnly? endDate, + CancellationToken cancellationToken) + { + var item = await GetItemAsync(itemId, cancellationToken); + using var holdingsResponse = await plaidClient.InvestmentsHoldingsGetAsync(item.AccessToken, cancellationToken); + + var accountsUpserted = await UpsertAccountsAsync(item, holdingsResponse.RootElement, cancellationToken); + var securitiesUpserted = await UpsertSecuritiesAsync(holdingsResponse.RootElement, cancellationToken); + var holdingsUpserted = await UpsertHoldingsAsync(item, holdingsResponse.RootElement, cancellationToken); + + var rangeEnd = endDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var rangeStart = startDate ?? rangeEnd.AddYears(-2); + + var investmentTransactionsUpserted = 0; + var count = 500; + var offset = 0; + var totalInvestmentTransactions = 0; + + do + { + using var transactionsResponse = await plaidClient.InvestmentsTransactionsGetAsync( + item.AccessToken, + rangeStart, + rangeEnd, + count, + offset, + cancellationToken); + + accountsUpserted += await UpsertAccountsAsync(item, transactionsResponse.RootElement, cancellationToken); + securitiesUpserted += await UpsertSecuritiesAsync(transactionsResponse.RootElement, cancellationToken); + investmentTransactionsUpserted += await UpsertInvestmentTransactionsAsync( + item, + transactionsResponse.RootElement, + cancellationToken); + + totalInvestmentTransactions = transactionsResponse.RootElement.Int("total_investment_transactions") ?? 0; + offset += count; + } + while (offset < totalInvestmentTransactions); + + item.LastInvestmentsSyncedAt = DateTimeOffset.UtcNow; + item.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + return new IngestionSummary( + item.Id, + accountsUpserted, + 0, + 0, + securitiesUpserted, + holdingsUpserted, + investmentTransactionsUpserted); + } + + public async Task IngestAllAsync( + Guid itemId, + DateOnly? investmentStartDate, + DateOnly? investmentEndDate, + CancellationToken cancellationToken) + { + var accounts = await IngestAccountsAsync(itemId, cancellationToken); + var transactions = await IngestTransactionsAsync(itemId, cancellationToken); + var investments = await IngestInvestmentsAsync(itemId, investmentStartDate, investmentEndDate, cancellationToken); + + return new IngestionSummary( + itemId, + accounts.AccountsUpserted + transactions.AccountsUpserted + investments.AccountsUpserted, + transactions.TransactionsAddedOrModified, + transactions.TransactionsRemoved, + investments.SecuritiesUpserted, + investments.HoldingsUpserted, + investments.InvestmentTransactionsUpserted); + } + + private async Task GetItemAsync(Guid itemId, CancellationToken cancellationToken) + { + return await dbContext.PlaidItems.SingleOrDefaultAsync(item => item.Id == itemId, cancellationToken) + ?? throw new KeyNotFoundException($"Plaid item '{itemId}' was not found."); + } + + private async Task UpsertAccountsAsync( + PlaidItem item, + JsonElement root, + CancellationToken cancellationToken) + { + var accounts = root.Property("accounts"); + if (accounts is not { ValueKind: JsonValueKind.Array }) + { + return 0; + } + + var existingAccounts = await dbContext.PlaidAccounts + .Where(account => account.PlaidItemId == item.Id) + .ToDictionaryAsync(account => account.PlaidAccountId, cancellationToken); + + var upserted = 0; + foreach (var accountJson in accounts.Value.EnumerateArray()) + { + var plaidAccountId = accountJson.String("account_id"); + if (string.IsNullOrWhiteSpace(plaidAccountId)) + { + continue; + } + + if (!existingAccounts.TryGetValue(plaidAccountId, out var account)) + { + account = new PlaidAccount + { + PlaidItemId = item.Id, + PlaidAccountId = plaidAccountId + }; + dbContext.PlaidAccounts.Add(account); + existingAccounts[plaidAccountId] = account; + } + + var balances = accountJson.Property("balances"); + account.Name = accountJson.String("name") ?? account.Name; + account.OfficialName = accountJson.String("official_name"); + account.Mask = accountJson.String("mask"); + account.Type = accountJson.String("type") ?? account.Type; + account.Subtype = accountJson.String("subtype"); + account.CurrentBalance = balances?.Decimal("current"); + account.AvailableBalance = balances?.Decimal("available"); + account.Limit = balances?.Decimal("limit"); + account.IsoCurrencyCode = balances?.String("iso_currency_code"); + account.UnofficialCurrencyCode = balances?.String("unofficial_currency_code"); + account.BalanceLastUpdatedAt = balances?.DateTimeOffset("last_updated_datetime"); + account.RawJson = accountJson.GetRawText(); + account.UpdatedAt = DateTimeOffset.UtcNow; + upserted++; + } + + return upserted; + } + + private async Task UpsertTransactionsAsync( + PlaidItem item, + JsonElement root, + string arrayName, + CancellationToken cancellationToken) + { + var transactions = root.Property(arrayName); + if (transactions is not { ValueKind: JsonValueKind.Array }) + { + return 0; + } + + var accountLookup = await dbContext.PlaidAccounts + .Where(account => account.PlaidItemId == item.Id) + .ToDictionaryAsync(account => account.PlaidAccountId, account => account.Id, cancellationToken); + + var ids = transactions.Value.EnumerateArray() + .Select(transaction => transaction.String("transaction_id")) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .ToArray(); + + var existingTransactions = await dbContext.PlaidTransactions + .Where(transaction => ids.Contains(transaction.PlaidTransactionId)) + .ToDictionaryAsync(transaction => transaction.PlaidTransactionId, cancellationToken); + + var upserted = 0; + foreach (var transactionJson in transactions.Value.EnumerateArray()) + { + var plaidTransactionId = transactionJson.String("transaction_id"); + var accountPlaidId = transactionJson.String("account_id"); + var date = transactionJson.Date("date"); + + if (string.IsNullOrWhiteSpace(plaidTransactionId) || + string.IsNullOrWhiteSpace(accountPlaidId) || + date is null) + { + continue; + } + + if (!existingTransactions.TryGetValue(plaidTransactionId, out var transaction)) + { + transaction = new PlaidTransaction + { + PlaidItemId = item.Id, + PlaidTransactionId = plaidTransactionId + }; + dbContext.PlaidTransactions.Add(transaction); + existingTransactions[plaidTransactionId] = transaction; + } + + var personalFinanceCategory = transactionJson.Property("personal_finance_category"); + transaction.AccountPlaidId = accountPlaidId; + transaction.PlaidAccountId = accountLookup.TryGetValue(accountPlaidId, out var accountId) + ? accountId + : null; + transaction.Date = date.Value; + transaction.AuthorizedDate = transactionJson.Date("authorized_date"); + transaction.Name = transactionJson.String("name") ?? transaction.Name; + transaction.MerchantName = transactionJson.String("merchant_name"); + transaction.Amount = transactionJson.Decimal("amount") ?? transaction.Amount; + transaction.Pending = transactionJson.Bool("pending"); + transaction.IsoCurrencyCode = transactionJson.String("iso_currency_code"); + transaction.UnofficialCurrencyCode = transactionJson.String("unofficial_currency_code"); + transaction.Categories = transactionJson.StringArray("category"); + transaction.PersonalFinanceCategoryPrimary = personalFinanceCategory?.String("primary"); + transaction.PersonalFinanceCategoryDetailed = personalFinanceCategory?.String("detailed"); + transaction.RawJson = transactionJson.GetRawText(); + transaction.RemovedAt = null; + transaction.UpdatedAt = DateTimeOffset.UtcNow; + await UpsertPlaidClassificationAsync(transaction, personalFinanceCategory, cancellationToken); + upserted++; + } + + return upserted; + } + + private async Task MarkTransactionsRemovedAsync(JsonElement root, CancellationToken cancellationToken) + { + var removed = root.Property("removed"); + if (removed is not { ValueKind: JsonValueKind.Array }) + { + return 0; + } + + var removedIds = removed.Value.EnumerateArray() + .Select(transaction => transaction.String("transaction_id")) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .ToArray(); + + var transactions = await dbContext.PlaidTransactions + .Where(transaction => removedIds.Contains(transaction.PlaidTransactionId)) + .ToListAsync(cancellationToken); + + foreach (var transaction in transactions) + { + transaction.RemovedAt = DateTimeOffset.UtcNow; + transaction.UpdatedAt = DateTimeOffset.UtcNow; + } + + return transactions.Count; + } + + private async Task UpsertSecuritiesAsync(JsonElement root, CancellationToken cancellationToken) + { + var securities = root.Property("securities"); + if (securities is not { ValueKind: JsonValueKind.Array }) + { + return 0; + } + + var ids = securities.Value.EnumerateArray() + .Select(security => security.String("security_id")) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .ToArray(); + + var existingSecurities = await dbContext.PlaidSecurities + .Where(security => ids.Contains(security.PlaidSecurityId)) + .ToDictionaryAsync(security => security.PlaidSecurityId, cancellationToken); + + var upserted = 0; + foreach (var securityJson in securities.Value.EnumerateArray()) + { + var plaidSecurityId = securityJson.String("security_id"); + if (string.IsNullOrWhiteSpace(plaidSecurityId)) + { + continue; + } + + if (!existingSecurities.TryGetValue(plaidSecurityId, out var security)) + { + security = new PlaidSecurity + { + PlaidSecurityId = plaidSecurityId + }; + dbContext.PlaidSecurities.Add(security); + existingSecurities[plaidSecurityId] = security; + } + + security.Cusip = securityJson.String("cusip"); + security.Isin = securityJson.String("isin"); + security.Sedol = securityJson.String("sedol"); + security.TickerSymbol = securityJson.String("ticker_symbol"); + security.Name = securityJson.String("name"); + security.Type = securityJson.String("type"); + security.ClosePrice = securityJson.Decimal("close_price"); + security.ClosePriceAsOf = securityJson.Date("close_price_as_of"); + security.IsoCurrencyCode = securityJson.String("iso_currency_code"); + security.UnofficialCurrencyCode = securityJson.String("unofficial_currency_code"); + security.RawJson = securityJson.GetRawText(); + security.UpdatedAt = DateTimeOffset.UtcNow; + upserted++; + } + + return upserted; + } + + private async Task UpsertHoldingsAsync( + PlaidItem item, + JsonElement root, + CancellationToken cancellationToken) + { + var holdings = root.Property("holdings"); + if (holdings is not { ValueKind: JsonValueKind.Array }) + { + return 0; + } + + var accountLookup = await dbContext.PlaidAccounts + .Where(account => account.PlaidItemId == item.Id) + .ToDictionaryAsync(account => account.PlaidAccountId, account => account.Id, cancellationToken); + var securityLookup = await dbContext.PlaidSecurities + .ToDictionaryAsync(security => security.PlaidSecurityId, security => security.Id, cancellationToken); + var existingHoldings = await dbContext.PlaidHoldings + .Where(holding => holding.PlaidItemId == item.Id) + .ToListAsync(cancellationToken); + + var upserted = 0; + foreach (var holdingJson in holdings.Value.EnumerateArray()) + { + var accountPlaidId = holdingJson.String("account_id"); + var securityPlaidId = holdingJson.String("security_id"); + + if (string.IsNullOrWhiteSpace(accountPlaidId) || + string.IsNullOrWhiteSpace(securityPlaidId) || + !accountLookup.TryGetValue(accountPlaidId, out var accountId) || + !securityLookup.TryGetValue(securityPlaidId, out var securityId)) + { + continue; + } + + var holding = existingHoldings.SingleOrDefault(existing => + existing.PlaidAccountId == accountId && existing.PlaidSecurityId == securityId); + + if (holding is null) + { + holding = new PlaidHolding + { + PlaidItemId = item.Id, + PlaidAccountId = accountId, + PlaidSecurityId = securityId + }; + dbContext.PlaidHoldings.Add(holding); + existingHoldings.Add(holding); + } + + holding.Quantity = holdingJson.Decimal("quantity") ?? holding.Quantity; + holding.InstitutionPrice = holdingJson.Decimal("institution_price"); + holding.InstitutionValue = holdingJson.Decimal("institution_value"); + holding.InstitutionPriceAsOf = holdingJson.Date("institution_price_as_of"); + holding.CostBasis = holdingJson.Decimal("cost_basis"); + holding.IsoCurrencyCode = holdingJson.String("iso_currency_code"); + holding.UnofficialCurrencyCode = holdingJson.String("unofficial_currency_code"); + holding.RawJson = holdingJson.GetRawText(); + holding.UpdatedAt = DateTimeOffset.UtcNow; + upserted++; + } + + return upserted; + } + + private async Task UpsertInvestmentTransactionsAsync( + PlaidItem item, + JsonElement root, + CancellationToken cancellationToken) + { + var transactions = root.Property("investment_transactions"); + if (transactions is not { ValueKind: JsonValueKind.Array }) + { + return 0; + } + + var accountLookup = await dbContext.PlaidAccounts + .Where(account => account.PlaidItemId == item.Id) + .ToDictionaryAsync(account => account.PlaidAccountId, account => account.Id, cancellationToken); + var securityLookup = await dbContext.PlaidSecurities + .ToDictionaryAsync(security => security.PlaidSecurityId, security => security.Id, cancellationToken); + var ids = transactions.Value.EnumerateArray() + .Select(transaction => transaction.String("investment_transaction_id")) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .ToArray(); + var existingTransactions = await dbContext.PlaidInvestmentTransactions + .Where(transaction => ids.Contains(transaction.PlaidInvestmentTransactionId)) + .ToDictionaryAsync(transaction => transaction.PlaidInvestmentTransactionId, cancellationToken); + + var upserted = 0; + foreach (var transactionJson in transactions.Value.EnumerateArray()) + { + var investmentTransactionId = transactionJson.String("investment_transaction_id"); + var accountPlaidId = transactionJson.String("account_id"); + var date = transactionJson.Date("date"); + + if (string.IsNullOrWhiteSpace(investmentTransactionId) || + string.IsNullOrWhiteSpace(accountPlaidId) || + date is null) + { + continue; + } + + if (!existingTransactions.TryGetValue(investmentTransactionId, out var transaction)) + { + transaction = new PlaidInvestmentTransaction + { + PlaidItemId = item.Id, + PlaidInvestmentTransactionId = investmentTransactionId + }; + dbContext.PlaidInvestmentTransactions.Add(transaction); + existingTransactions[investmentTransactionId] = transaction; + } + + var securityPlaidId = transactionJson.String("security_id"); + transaction.AccountPlaidId = accountPlaidId; + transaction.PlaidAccountId = accountLookup.TryGetValue(accountPlaidId, out var accountId) + ? accountId + : null; + transaction.SecurityPlaidId = securityPlaidId; + transaction.PlaidSecurityId = securityPlaidId is not null && + securityLookup.TryGetValue(securityPlaidId, out var securityId) + ? securityId + : null; + transaction.Date = date.Value; + transaction.Name = transactionJson.String("name") ?? transaction.Name; + transaction.Quantity = transactionJson.Decimal("quantity") ?? transaction.Quantity; + transaction.Amount = transactionJson.Decimal("amount") ?? transaction.Amount; + transaction.Price = transactionJson.Decimal("price"); + transaction.Fees = transactionJson.Decimal("fees"); + transaction.Type = transactionJson.String("type"); + transaction.Subtype = transactionJson.String("subtype"); + transaction.IsoCurrencyCode = transactionJson.String("iso_currency_code"); + transaction.UnofficialCurrencyCode = transactionJson.String("unofficial_currency_code"); + transaction.RawJson = transactionJson.GetRawText(); + transaction.UpdatedAt = DateTimeOffset.UtcNow; + upserted++; + } + + return upserted; + } + + private async Task UpsertPlaidClassificationAsync( + PlaidTransaction transaction, + JsonElement? personalFinanceCategory, + CancellationToken cancellationToken) + { + var primary = personalFinanceCategory?.String("primary") ?? "UNCATEGORIZED"; + var detailed = personalFinanceCategory?.String("detailed") ?? primary; + var category = await GetOrCreateCategoryAsync(primary, detailed, cancellationToken); + + var classification = await dbContext.TransactionClassifications + .SingleOrDefaultAsync( + existing => existing.PlaidTransactionId == transaction.Id, + cancellationToken); + + if (classification is not null && classification.Source != "plaid") + { + return; + } + + if (classification is null) + { + classification = new TransactionClassification + { + PlaidTransaction = transaction, + Source = "plaid" + }; + dbContext.TransactionClassifications.Add(classification); + } + + classification.Category = category; + classification.Source = "plaid"; + classification.UpdatedAt = DateTimeOffset.UtcNow; + } + + private async Task GetOrCreateCategoryAsync( + string primary, + string detailed, + CancellationToken cancellationToken) + { + var slug = BuildCategorySlug(detailed); + var category = await dbContext.TransactionCategories + .SingleOrDefaultAsync(existing => existing.Slug == slug, cancellationToken); + + if (category is not null) + { + category.PlaidPrimary ??= primary; + category.PlaidDetailed ??= detailed; + category.UpdatedAt = DateTimeOffset.UtcNow; + return category; + } + + category = new TransactionCategory + { + Name = BuildCategoryName(detailed), + Slug = slug, + PlaidPrimary = primary, + PlaidDetailed = detailed, + IsSystem = true + }; + + dbContext.TransactionCategories.Add(category); + return category; + } + + private static string BuildCategorySlug(string value) + { + var normalized = value.Trim().ToLowerInvariant().Replace("&", "and"); + normalized = SlugSanitizer.Replace(normalized, "-").Trim('-'); + + return string.IsNullOrWhiteSpace(normalized) + ? "uncategorized" + : normalized; + } + + private static string BuildCategoryName(string value) + { + return string.Join( + " ", + value.ToLowerInvariant() + .Split(['_', '-'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(word => char.ToUpperInvariant(word[0]) + word[1..])); + } +} diff --git a/backend/Services/Plaid/PlaidJson.cs b/backend/Services/Plaid/PlaidJson.cs new file mode 100644 index 0000000..50a0803 --- /dev/null +++ b/backend/Services/Plaid/PlaidJson.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Text.Json; + +namespace Asentrafin.Api.Services.Plaid; + +internal static class PlaidJson +{ + public static JsonElement? Property(this JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) + ? value + : null; + } + + public static string? String(this JsonElement element, string name) + { + var value = element.Property(name); + return value is { ValueKind: JsonValueKind.String } ? value.Value.GetString() : null; + } + + public static decimal? Decimal(this JsonElement element, string name) + { + var value = element.Property(name); + return value is { ValueKind: JsonValueKind.Number } && value.Value.TryGetDecimal(out var parsed) + ? parsed + : null; + } + + public static int? Int(this JsonElement element, string name) + { + var value = element.Property(name); + return value is { ValueKind: JsonValueKind.Number } && value.Value.TryGetInt32(out var parsed) + ? parsed + : null; + } + + public static bool Bool(this JsonElement element, string name) + { + var value = element.Property(name); + return value is { ValueKind: JsonValueKind.True } || + value is { ValueKind: JsonValueKind.False } && value.Value.GetBoolean(); + } + + public static DateOnly? Date(this JsonElement element, string name) + { + var value = element.String(name); + return DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + ? parsed + : null; + } + + public static DateTimeOffset? DateTimeOffset(this JsonElement element, string name) + { + var value = element.String(name); + return System.DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed + : null; + } + + public static string[] StringArray(this JsonElement element, string name) + { + var value = element.Property(name); + if (value is not { ValueKind: JsonValueKind.Array }) + { + return []; + } + + return value.Value.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } +} diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 0c208ae..6068426 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -1,4 +1,12 @@ { + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=asentrafin;Username=postgres;Password=postgres" + }, + "Plaid": { + "BaseUrl": "https://sandbox.plaid.com", + "ClientId": "", + "Secret": "" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32a5db2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + postgres: + image: postgres:17-alpine + container_name: asentrafin-postgres + environment: + POSTGRES_DB: asentrafin + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d asentrafin"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres-data: diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..807729e --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file