Compare commits

...

1 Commits

Author SHA1 Message Date
Tyler Wilcox cb3420a035 Plaid Ingestion 2026-07-14 20:37:24 -04:00
42 changed files with 4014 additions and 0 deletions

View File

@ -8,6 +8,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
</ItemGroup> </ItemGroup>

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
namespace Asentrafin.Api.Contracts.Plaid;
public record CreatePlaidItemRequest(
string AccessToken,
string? PlaidItemId,
string? InstitutionId,
string? InstitutionName);

View File

@ -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);

View File

@ -0,0 +1,3 @@
namespace Asentrafin.Api.Contracts.Plaid;
public record InvestmentIngestionRequest(DateOnly? StartDate, DateOnly? EndDate);

View File

@ -0,0 +1,8 @@
namespace Asentrafin.Api.Contracts.Transactions;
public record CategorySummaryResponse(
Guid CategoryId,
string CategoryName,
string CategorySlug,
int TransactionCount,
decimal TotalAmount);

View File

@ -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);

View File

@ -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<IActionResult> GetHealth(CancellationToken cancellationToken)
{
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
return Ok(new
{
database = "postgres",
canConnect
});
}
}

View File

@ -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<ActionResult<object>> 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<ActionResult<IngestionSummary>> IngestAccounts(
Guid itemId,
CancellationToken cancellationToken)
{
return Ok(await ingestionService.IngestAccountsAsync(itemId, cancellationToken));
}
[HttpPost("items/{itemId:guid}/ingest/transactions")]
public async Task<ActionResult<IngestionSummary>> IngestTransactions(
Guid itemId,
CancellationToken cancellationToken)
{
return Ok(await ingestionService.IngestTransactionsAsync(itemId, cancellationToken));
}
[HttpPost("items/{itemId:guid}/ingest/investments")]
public async Task<ActionResult<IngestionSummary>> 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<ActionResult<IngestionSummary>> IngestAll(
Guid itemId,
InvestmentIngestionRequest request,
CancellationToken cancellationToken)
{
return Ok(await ingestionService.IngestAllAsync(
itemId,
request.StartDate,
request.EndDate,
cancellationToken));
}
}

View File

@ -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<ActionResult<IReadOnlyList<TransactionCategoryResponse>>> 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<ActionResult<IReadOnlyList<CategorySummaryResponse>>> 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);
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Asentrafin.Api.Entities;
namespace Asentrafin.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<PlaidItem> PlaidItems => Set<PlaidItem>();
public DbSet<PlaidAccount> PlaidAccounts => Set<PlaidAccount>();
public DbSet<PlaidTransaction> PlaidTransactions => Set<PlaidTransaction>();
public DbSet<PlaidSecurity> PlaidSecurities => Set<PlaidSecurity>();
public DbSet<PlaidHolding> PlaidHoldings => Set<PlaidHolding>();
public DbSet<PlaidInvestmentTransaction> PlaidInvestmentTransactions => Set<PlaidInvestmentTransaction>();
public DbSet<TransactionCategory> TransactionCategories => Set<TransactionCategory>();
public DbSet<TransactionClassification> TransactionClassifications => Set<TransactionClassification>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema("app");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}

View File

@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Asentrafin.Api.Data;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
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<AppDbContext>()
.UseNpgsql(connectionString);
return new AppDbContext(optionsBuilder.Options);
}
}

View File

@ -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<PlaidAccount>
{
public void Configure(EntityTypeBuilder<PlaidAccount> 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);
}
}

View File

@ -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<PlaidHolding>
{
public void Configure(EntityTypeBuilder<PlaidHolding> 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);
}
}

View File

@ -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<PlaidInvestmentTransaction>
{
public void Configure(EntityTypeBuilder<PlaidInvestmentTransaction> 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);
}
}

View File

@ -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<PlaidItem>
{
public void Configure(EntityTypeBuilder<PlaidItem> 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();
}
}

View File

@ -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<PlaidSecurity>
{
public void Configure(EntityTypeBuilder<PlaidSecurity> 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();
}
}

View File

@ -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<PlaidTransaction>
{
public void Configure(EntityTypeBuilder<PlaidTransaction> 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);
}
}

View File

@ -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<TransactionCategory>
{
public void Configure(EntityTypeBuilder<TransactionCategory> 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);
}
}

View File

@ -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<TransactionClassification>
{
public void Configure(EntityTypeBuilder<TransactionClassification> 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<TransactionClassification>(classification => classification.PlaidTransactionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(classification => classification.Category)
.WithMany(category => category.Classifications)
.HasForeignKey(classification => classification.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@ -0,0 +1,568 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("AvailableBalance")
.HasColumnType("numeric");
b.Property<DateTimeOffset?>("BalanceLastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("CurrentBalance")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<decimal?>("Limit")
.HasColumnType("numeric");
b.Property<string>("Mask")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OfficialName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PlaidAccountId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Subtype")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("CostBasis")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("InstitutionPrice")
.HasColumnType("numeric");
b.Property<DateOnly?>("InstitutionPriceAsOf")
.HasColumnType("date");
b.Property<decimal?>("InstitutionValue")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<Guid>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidSecurityId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasColumnType("numeric");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountPlaidId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<decimal?>("Fees")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<string>("PlaidInvestmentTransactionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<Guid?>("PlaidSecurityId")
.HasColumnType("uuid");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<decimal>("Quantity")
.HasColumnType("numeric");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("SecurityPlaidId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Subtype")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InstitutionId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("InstitutionName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastAccountsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastInvestmentsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastTransactionsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PlaidItemId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TransactionsCursor")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("ClosePrice")
.HasColumnType("numeric");
b.Property<DateOnly?>("ClosePriceAsOf")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Cusip")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Isin")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PlaidSecurityId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Sedol")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TickerSymbol")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountPlaidId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateOnly?>("AuthorizedDate")
.HasColumnType("date");
b.PrimitiveCollection<string[]>("Categories")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MerchantName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<bool>("Pending")
.HasColumnType("boolean");
b.Property<string>("PersonalFinanceCategoryDetailed")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("PersonalFinanceCategoryPrimary")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid?>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<string>("PlaidTransactionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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
}
}
}

View File

@ -0,0 +1,362 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Asentrafin.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddPlaidIngestion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "app");
migrationBuilder.CreateTable(
name: "plaid_items",
schema: "app",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AccessToken = table.Column<string>(type: "text", nullable: false),
PlaidItemId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
InstitutionId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
InstitutionName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
TransactionsCursor = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastAccountsSyncedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastTransactionsSyncedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastInvestmentsSyncedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
PlaidSecurityId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Cusip = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Isin = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Sedol = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
TickerSymbol = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Name = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ClosePrice = table.Column<decimal>(type: "numeric", nullable: true),
ClosePriceAsOf = table.Column<DateOnly>(type: "date", nullable: true),
IsoCurrencyCode = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
UnofficialCurrencyCode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RawJson = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
PlaidItemId = table.Column<Guid>(type: "uuid", nullable: false),
PlaidAccountId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
OfficialName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Mask = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Subtype = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
CurrentBalance = table.Column<decimal>(type: "numeric", nullable: true),
AvailableBalance = table.Column<decimal>(type: "numeric", nullable: true),
Limit = table.Column<decimal>(type: "numeric", nullable: true),
IsoCurrencyCode = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
UnofficialCurrencyCode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
BalanceLastUpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RawJson = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
PlaidItemId = table.Column<Guid>(type: "uuid", nullable: false),
PlaidAccountId = table.Column<Guid>(type: "uuid", nullable: false),
PlaidSecurityId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric", nullable: false),
InstitutionPrice = table.Column<decimal>(type: "numeric", nullable: true),
InstitutionValue = table.Column<decimal>(type: "numeric", nullable: true),
InstitutionPriceAsOf = table.Column<DateOnly>(type: "date", nullable: true),
CostBasis = table.Column<decimal>(type: "numeric", nullable: true),
IsoCurrencyCode = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
UnofficialCurrencyCode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RawJson = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
PlaidItemId = table.Column<Guid>(type: "uuid", nullable: false),
PlaidAccountId = table.Column<Guid>(type: "uuid", nullable: true),
PlaidSecurityId = table.Column<Guid>(type: "uuid", nullable: true),
PlaidInvestmentTransactionId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
AccountPlaidId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SecurityPlaidId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
Date = table.Column<DateOnly>(type: "date", nullable: false),
Name = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Quantity = table.Column<decimal>(type: "numeric", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Price = table.Column<decimal>(type: "numeric", nullable: true),
Fees = table.Column<decimal>(type: "numeric", nullable: true),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Subtype = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
IsoCurrencyCode = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
UnofficialCurrencyCode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RawJson = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
PlaidItemId = table.Column<Guid>(type: "uuid", nullable: false),
PlaidAccountId = table.Column<Guid>(type: "uuid", nullable: true),
PlaidTransactionId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
AccountPlaidId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: false),
AuthorizedDate = table.Column<DateOnly>(type: "date", nullable: true),
Name = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
MerchantName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Pending = table.Column<bool>(type: "boolean", nullable: false),
IsoCurrencyCode = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
UnofficialCurrencyCode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Categories = table.Column<string[]>(type: "text[]", nullable: false),
PersonalFinanceCategoryPrimary = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
PersonalFinanceCategoryDetailed = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RemovedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RawJson = table.Column<string>(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);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -0,0 +1,700 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("AvailableBalance")
.HasColumnType("numeric");
b.Property<DateTimeOffset?>("BalanceLastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("CurrentBalance")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<decimal?>("Limit")
.HasColumnType("numeric");
b.Property<string>("Mask")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OfficialName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PlaidAccountId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Subtype")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("CostBasis")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("InstitutionPrice")
.HasColumnType("numeric");
b.Property<DateOnly?>("InstitutionPriceAsOf")
.HasColumnType("date");
b.Property<decimal?>("InstitutionValue")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<Guid>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidSecurityId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasColumnType("numeric");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountPlaidId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<decimal?>("Fees")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<string>("PlaidInvestmentTransactionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<Guid?>("PlaidSecurityId")
.HasColumnType("uuid");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<decimal>("Quantity")
.HasColumnType("numeric");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("SecurityPlaidId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Subtype")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InstitutionId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("InstitutionName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastAccountsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastInvestmentsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastTransactionsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PlaidItemId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TransactionsCursor")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("ClosePrice")
.HasColumnType("numeric");
b.Property<DateOnly?>("ClosePriceAsOf")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Cusip")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Isin")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PlaidSecurityId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Sedol")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TickerSymbol")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountPlaidId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateOnly?>("AuthorizedDate")
.HasColumnType("date");
b.PrimitiveCollection<string[]>("Categories")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MerchantName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<bool>("Pending")
.HasColumnType("boolean");
b.Property<string>("PersonalFinanceCategoryDetailed")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("PersonalFinanceCategoryPrimary")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid?>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<string>("PlaidTransactionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("PlaidDetailed")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("PlaidPrimary")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<decimal?>("Confidence")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("PlaidTransactionId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("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
}
}
}

View File

@ -0,0 +1,120 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Asentrafin.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddTransactionCategorization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "transaction_categories",
schema: "app",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
PlaidPrimary = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
PlaidDetailed = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
PlaidTransactionId = table.Column<Guid>(type: "uuid", nullable: false),
CategoryId = table.Column<Guid>(type: "uuid", nullable: false),
Source = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Confidence = table.Column<decimal>(type: "numeric", nullable: true),
Notes = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "transaction_classifications",
schema: "app");
migrationBuilder.DropTable(
name: "transaction_categories",
schema: "app");
}
}
}

View File

@ -0,0 +1,697 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("AvailableBalance")
.HasColumnType("numeric");
b.Property<DateTimeOffset?>("BalanceLastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("CurrentBalance")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<decimal?>("Limit")
.HasColumnType("numeric");
b.Property<string>("Mask")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OfficialName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PlaidAccountId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Subtype")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("CostBasis")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("InstitutionPrice")
.HasColumnType("numeric");
b.Property<DateOnly?>("InstitutionPriceAsOf")
.HasColumnType("date");
b.Property<decimal?>("InstitutionValue")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<Guid>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidSecurityId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasColumnType("numeric");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountPlaidId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<decimal?>("Fees")
.HasColumnType("numeric");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<string>("PlaidInvestmentTransactionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<Guid?>("PlaidSecurityId")
.HasColumnType("uuid");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<decimal>("Quantity")
.HasColumnType("numeric");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("SecurityPlaidId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Subtype")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InstitutionId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("InstitutionName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastAccountsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastInvestmentsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastTransactionsSyncedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PlaidItemId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TransactionsCursor")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("ClosePrice")
.HasColumnType("numeric");
b.Property<DateOnly?>("ClosePriceAsOf")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Cusip")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Isin")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PlaidSecurityId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Sedol")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TickerSymbol")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccountPlaidId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateOnly?>("AuthorizedDate")
.HasColumnType("date");
b.PrimitiveCollection<string[]>("Categories")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<string>("IsoCurrencyCode")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MerchantName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<bool>("Pending")
.HasColumnType("boolean");
b.Property<string>("PersonalFinanceCategoryDetailed")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("PersonalFinanceCategoryPrimary")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid?>("PlaidAccountId")
.HasColumnType("uuid");
b.Property<Guid>("PlaidItemId")
.HasColumnType("uuid");
b.Property<string>("PlaidTransactionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RawJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UnofficialCurrencyCode")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("PlaidDetailed")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("PlaidPrimary")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<decimal?>("Confidence")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("PlaidTransactionId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("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
}
}
}

View File

@ -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<PlaidTransaction> Transactions { get; set; } = [];
public ICollection<PlaidHolding> Holdings { get; set; } = [];
public ICollection<PlaidInvestmentTransaction> InvestmentTransactions { get; set; } = [];
}

View File

@ -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; } = "{}";
}

View File

@ -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; } = "{}";
}

View File

@ -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<PlaidAccount> Accounts { get; set; } = [];
public ICollection<PlaidTransaction> Transactions { get; set; } = [];
public ICollection<PlaidHolding> Holdings { get; set; } = [];
public ICollection<PlaidInvestmentTransaction> InvestmentTransactions { get; set; } = [];
}

View File

@ -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<PlaidHolding> Holdings { get; set; } = [];
public ICollection<PlaidInvestmentTransaction> InvestmentTransactions { get; set; } = [];
}

View File

@ -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; }
}

View File

@ -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<TransactionCategory> Children { get; set; } = [];
public ICollection<TransactionClassification> Classifications { get; set; } = [];
}

View File

@ -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;
}

View File

@ -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); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' was not found.");
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.Configure<PlaidOptions>(builder.Configuration.GetSection("Plaid"));
builder.Services.AddHttpClient<IPlaidClient, PlaidClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<PlaidOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
builder.Services.AddScoped<IPlaidIngestionService, PlaidIngestionService>();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options => builder.Services.AddSwaggerGen(options =>

View File

@ -0,0 +1,17 @@
using System.Text.Json;
namespace Asentrafin.Api.Services.Plaid;
public interface IPlaidClient
{
Task<JsonDocument> AccountsGetAsync(string accessToken, CancellationToken cancellationToken);
Task<JsonDocument> TransactionsSyncAsync(string accessToken, string? cursor, CancellationToken cancellationToken);
Task<JsonDocument> InvestmentsHoldingsGetAsync(string accessToken, CancellationToken cancellationToken);
Task<JsonDocument> InvestmentsTransactionsGetAsync(
string accessToken,
DateOnly startDate,
DateOnly endDate,
int count,
int offset,
CancellationToken cancellationToken);
}

View File

@ -0,0 +1,20 @@
using Asentrafin.Api.Contracts.Plaid;
namespace Asentrafin.Api.Services.Plaid;
public interface IPlaidIngestionService
{
Task<Guid> CreateItemAsync(CreatePlaidItemRequest request, CancellationToken cancellationToken);
Task<IngestionSummary> IngestAccountsAsync(Guid itemId, CancellationToken cancellationToken);
Task<IngestionSummary> IngestTransactionsAsync(Guid itemId, CancellationToken cancellationToken);
Task<IngestionSummary> IngestInvestmentsAsync(
Guid itemId,
DateOnly? startDate,
DateOnly? endDate,
CancellationToken cancellationToken);
Task<IngestionSummary> IngestAllAsync(
Guid itemId,
DateOnly? investmentStartDate,
DateOnly? investmentEndDate,
CancellationToken cancellationToken);
}

View File

@ -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<PlaidOptions> options) : IPlaidClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly PlaidOptions _options = options.Value;
public Task<JsonDocument> AccountsGetAsync(string accessToken, CancellationToken cancellationToken)
{
return PostAsync("/accounts/get", BuildAuthedRequest(accessToken), cancellationToken);
}
public Task<JsonDocument> TransactionsSyncAsync(string accessToken, string? cursor, CancellationToken cancellationToken)
{
var body = BuildAuthedRequest(accessToken);
body["cursor"] = cursor;
body["count"] = 500;
body["options"] = new Dictionary<string, object?>
{
["include_original_description"] = true,
["personal_finance_category_version"] = "v2"
};
return PostAsync("/transactions/sync", body, cancellationToken);
}
public Task<JsonDocument> InvestmentsHoldingsGetAsync(string accessToken, CancellationToken cancellationToken)
{
return PostAsync("/investments/holdings/get", BuildAuthedRequest(accessToken), cancellationToken);
}
public Task<JsonDocument> 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<string, object?>
{
["count"] = count,
["offset"] = offset
};
return PostAsync("/investments/transactions/get", body, cancellationToken);
}
private async Task<JsonDocument> PostAsync(
string path,
Dictionary<string, object?> 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<string, object?> BuildAuthedRequest(string accessToken)
{
return new Dictionary<string, object?>
{
["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.");
}
}
}

View File

@ -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<Guid> 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<IngestionSummary> 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<IngestionSummary> 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<IngestionSummary> 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<IngestionSummary> 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<PlaidItem> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<TransactionCategory> 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..]));
}
}

View File

@ -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();
}
}

View File

@ -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": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

20
docker-compose.yml Normal file
View File

@ -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:

13
dotnet-tools.json Normal file
View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}