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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user