Private
Public Access
1
0

Implement Phase 6: automated bank sync (Plaid + SimpleFIN) & settings

Add automated transaction syncing via Plaid and SimpleFIN providers,
a Settings page for managing connections and account mappings,
background scheduled syncing, and sync history logging.

New backend: BankSync plugin project with PlaidSyncProvider and
SimpleFinSyncProvider, SyncConnection/LinkedAccount/SyncLog models,
AES-256 encryption service, BankSyncService orchestration,
BankSyncBackgroundService (15-min tick), and BankSyncController
(13 endpoints). EF migration creates sync_connections,
linked_accounts, and sync_logs tables.

New frontend: Settings view with 3 tabs (Profile, Bank Connections,
Sync History), bankSync API service, BankSyncComplete SignalR
notifications, and Settings nav item in sidebar + avatar dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 15:08:23 -05:00
parent eb49147080
commit 3e0446c9be
34 changed files with 3956 additions and 8 deletions
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class SyncConnectionConfiguration : IEntityTypeConfiguration<SyncConnection>
{
public void Configure(EntityTypeBuilder<SyncConnection> builder)
{
builder.ToTable("sync_connections");
builder.HasKey(c => c.Id);
builder.Property(c => c.InstitutionName).HasMaxLength(200).IsRequired();
builder.Property(c => c.InstitutionId).HasMaxLength(100);
builder.Property(c => c.EncryptedAccessToken).HasColumnType("text").IsRequired();
builder.Property(c => c.PlaidItemId).HasMaxLength(200);
builder.Property(c => c.LastSyncError).HasMaxLength(2000);
builder.HasOne(c => c.User)
.WithMany(u => u.SyncConnections)
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(c => c.UserId);
builder.HasIndex(c => c.PlaidItemId);
}
}
public class LinkedAccountConfiguration : IEntityTypeConfiguration<LinkedAccount>
{
public void Configure(EntityTypeBuilder<LinkedAccount> builder)
{
builder.ToTable("linked_accounts");
builder.HasKey(a => a.Id);
builder.Property(a => a.ExternalAccountId).HasMaxLength(200).IsRequired();
builder.Property(a => a.ExternalAccountName).HasMaxLength(200).IsRequired();
builder.Property(a => a.ExternalAccountType).HasMaxLength(50);
builder.Property(a => a.ExternalAccountMask).HasMaxLength(20);
builder.Property(a => a.LastKnownBalance).HasPrecision(18, 2);
builder.HasOne(a => a.SyncConnection)
.WithMany(c => c.LinkedAccounts)
.HasForeignKey(a => a.SyncConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(a => a.Account)
.WithMany(acc => acc.LinkedAccounts)
.HasForeignKey(a => a.AccountId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(a => new { a.SyncConnectionId, a.ExternalAccountId }).IsUnique();
builder.HasIndex(a => a.AccountId);
}
}
public class SyncLogConfiguration : IEntityTypeConfiguration<SyncLog>
{
public void Configure(EntityTypeBuilder<SyncLog> builder)
{
builder.ToTable("sync_logs");
builder.HasKey(l => l.Id);
builder.Property(l => l.ErrorMessage).HasMaxLength(2000);
builder.HasOne(l => l.SyncConnection)
.WithMany(c => c.SyncLogs)
.HasForeignKey(l => l.SyncConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.LinkedAccount)
.WithMany()
.HasForeignKey(l => l.LinkedAccountId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(l => l.SyncConnectionId);
builder.HasIndex(l => l.StartedAt);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Purrse.Data.Migrations
{
/// <inheritdoc />
public partial class AddBankSync : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sync_connections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Provider = table.Column<int>(type: "integer", nullable: false),
InstitutionName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
InstitutionId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
EncryptedAccessToken = table.Column<string>(type: "text", nullable: false),
PlaidItemId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
SyncIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
LastSyncAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSyncError = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sync_connections", x => x.Id);
table.ForeignKey(
name: "FK_sync_connections_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "linked_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SyncConnectionId = table.Column<Guid>(type: "uuid", nullable: false),
AccountId = table.Column<Guid>(type: "uuid", nullable: true),
ExternalAccountId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalAccountType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ExternalAccountMask = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
LastKnownBalance = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastSyncAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_linked_accounts", x => x.Id);
table.ForeignKey(
name: "FK_linked_accounts_accounts_AccountId",
column: x => x.AccountId,
principalTable: "accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_linked_accounts_sync_connections_SyncConnectionId",
column: x => x.SyncConnectionId,
principalTable: "sync_connections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sync_logs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SyncConnectionId = table.Column<Guid>(type: "uuid", nullable: false),
LinkedAccountId = table.Column<Guid>(type: "uuid", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
TransactionsFetched = table.Column<int>(type: "integer", nullable: false),
TransactionsImported = table.Column<int>(type: "integer", nullable: false),
TransactionsSkipped = table.Column<int>(type: "integer", nullable: false),
ErrorMessage = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_sync_logs", x => x.Id);
table.ForeignKey(
name: "FK_sync_logs_linked_accounts_LinkedAccountId",
column: x => x.LinkedAccountId,
principalTable: "linked_accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_sync_logs_sync_connections_SyncConnectionId",
column: x => x.SyncConnectionId,
principalTable: "sync_connections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_linked_accounts_AccountId",
table: "linked_accounts",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_linked_accounts_SyncConnectionId_ExternalAccountId",
table: "linked_accounts",
columns: new[] { "SyncConnectionId", "ExternalAccountId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sync_connections_PlaidItemId",
table: "sync_connections",
column: "PlaidItemId");
migrationBuilder.CreateIndex(
name: "IX_sync_connections_UserId",
table: "sync_connections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_LinkedAccountId",
table: "sync_logs",
column: "LinkedAccountId");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_StartedAt",
table: "sync_logs",
column: "StartedAt");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_SyncConnectionId",
table: "sync_logs",
column: "SyncConnectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sync_logs");
migrationBuilder.DropTable(
name: "linked_accounts");
migrationBuilder.DropTable(
name: "sync_connections");
}
}
}
@@ -305,6 +305,59 @@ namespace Purrse.Data.Migrations
b.ToTable("investment_holdings", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalAccountId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExternalAccountMask")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExternalAccountType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<decimal?>("LastKnownBalance")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SyncConnectionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("SyncConnectionId", "ExternalAccountId")
.IsUnique();
b.ToTable("linked_accounts", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b =>
{
b.Property<Guid>("Id")
@@ -627,6 +680,111 @@ namespace Purrse.Data.Migrations
b.ToTable("security_prices", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("InstitutionId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("InstitutionName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastSyncError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("PlaidItemId")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Provider")
.HasColumnType("integer");
b.Property<int>("SyncIntervalMinutes")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlaidItemId");
b.HasIndex("UserId");
b.ToTable("sync_connections", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.SyncLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid?>("LinkedAccountId")
.HasColumnType("uuid");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("SyncConnectionId")
.HasColumnType("uuid");
b.Property<int>("TransactionsFetched")
.HasColumnType("integer");
b.Property<int>("TransactionsImported")
.HasColumnType("integer");
b.Property<int>("TransactionsSkipped")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("LinkedAccountId");
b.HasIndex("StartedAt");
b.HasIndex("SyncConnectionId");
b.ToTable("sync_logs", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{
b.Property<Guid>("Id")
@@ -902,6 +1060,24 @@ namespace Purrse.Data.Migrations
b.Navigation("Security");
});
modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
.WithMany("LinkedAccounts")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection")
.WithMany("LinkedAccounts")
.HasForeignKey("SyncConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("SyncConnection");
});
modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
@@ -1008,6 +1184,35 @@ namespace Purrse.Data.Migrations
b.Navigation("Security");
});
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.HasOne("Purrse.Core.Models.User", "User")
.WithMany("SyncConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Purrse.Core.Models.SyncLog", b =>
{
b.HasOne("Purrse.Core.Models.LinkedAccount", "LinkedAccount")
.WithMany()
.HasForeignKey("LinkedAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection")
.WithMany("SyncLogs")
.HasForeignKey("SyncConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LinkedAccount");
b.Navigation("SyncConnection");
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
@@ -1076,6 +1281,8 @@ namespace Purrse.Data.Migrations
{
b.Navigation("InvestmentHoldings");
b.Navigation("LinkedAccounts");
b.Navigation("LoanDetail");
b.Navigation("Reconciliations");
@@ -1131,6 +1338,13 @@ namespace Purrse.Data.Migrations
b.Navigation("Prices");
});
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.Navigation("LinkedAccounts");
b.Navigation("SyncLogs");
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{
b.Navigation("Splits");
@@ -1139,6 +1353,8 @@ namespace Purrse.Data.Migrations
modelBuilder.Entity("Purrse.Core.Models.User", b =>
{
b.Navigation("Accounts");
b.Navigation("SyncConnections");
});
#pragma warning restore 612, 618
}
+3
View File
@@ -26,6 +26,9 @@ public class PurrseDbContext : DbContext
public DbSet<Reconciliation> Reconciliations => Set<Reconciliation>();
public DbSet<PluginRegistration> PluginRegistrations => Set<PluginRegistration>();
public DbSet<PluginConfiguration> PluginConfigurations => Set<PluginConfiguration>();
public DbSet<SyncConnection> SyncConnections => Set<SyncConnection>();
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{