From f92759bef331338f07760f7e5fdc819e456b2ba3 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:41:11 -0500 Subject: [PATCH] Add startup migration to split existing SimpleFIN connections by institution Runs once at startup after DB migrations. For each SimpleFIN connection, re-discovers accounts from the API, groups by institution, and splits connections that contain accounts from multiple banks. Existing sync state and account mappings are preserved. Co-Authored-By: Claude Opus 4.6 --- src/Purrse.Api/Program.cs | 3 + .../Services/SimpleFinConnectionMigration.cs | 117 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/Purrse.Api/Services/SimpleFinConnectionMigration.cs diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index e64724b..e5fa423 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -119,6 +119,9 @@ using (var scope = app.Services.CreateScope()) await db.Database.MigrateAsync(); } +// One-time data migration: split SimpleFIN connections by institution +await SimpleFinConnectionMigration.RunAsync(app.Services, app.Services.GetRequiredService>()); + // Initialize plugin system var pluginService = app.Services.GetRequiredService(); await pluginService.DiscoverAndLoadPluginsAsync(); diff --git a/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs b/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs new file mode 100644 index 0000000..f9d03ce --- /dev/null +++ b/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using Purrse.Core.Enums; +using Purrse.Core.Interfaces.Services; +using Purrse.Core.Models; +using Purrse.Data; +using Purrse.Plugins.BankSync; + +namespace Purrse.Api.Services; + +/// +/// One-time data migration that splits SimpleFIN connections containing accounts +/// from multiple institutions into separate connections per institution. +/// +public static class SimpleFinConnectionMigration +{ + public static async Task RunAsync(IServiceProvider services, ILogger logger) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var encryption = scope.ServiceProvider.GetRequiredService(); + var simpleFin = scope.ServiceProvider.GetRequiredService(); + + var connections = await db.SyncConnections + .Include(c => c.LinkedAccounts) + .Where(c => c.Provider == SyncProvider.SimpleFIN) + .ToListAsync(); + + if (connections.Count == 0) + return; + + var splitCount = 0; + + foreach (var connection in connections) + { + try + { + var accessUrl = encryption.Decrypt(connection.EncryptedAccessToken); + var config = new Dictionary { ["AccessUrl"] = accessUrl }; + var apiAccounts = await simpleFin.GetAccountsAsync(config); + + // Build lookup: ExternalAccountId -> Institution + var institutionByExternalId = apiAccounts.ToDictionary( + a => a.ExternalId, + a => a.Institution ?? "SimpleFIN Connection"); + + // Match each linked account to its institution + var grouped = connection.LinkedAccounts + .GroupBy(la => institutionByExternalId.GetValueOrDefault(la.ExternalAccountId, connection.InstitutionName)) + .ToList(); + + if (grouped.Count <= 1) + { + // Single institution — just ensure the name is correct + var correctName = grouped.FirstOrDefault()?.Key ?? connection.InstitutionName; + if (connection.InstitutionName != correctName) + { + connection.InstitutionName = correctName; + connection.UpdatedAt = DateTime.UtcNow; + } + continue; + } + + // Multiple institutions — keep the first group on the existing connection, split the rest + var first = true; + foreach (var group in grouped) + { + if (first) + { + // Update existing connection to use this group's institution name + connection.InstitutionName = group.Key; + connection.UpdatedAt = DateTime.UtcNow; + first = false; + continue; + } + + // Create a new connection for this institution + var newConnection = new SyncConnection + { + Id = Guid.NewGuid(), + UserId = connection.UserId, + Provider = SyncProvider.SimpleFIN, + InstitutionName = group.Key, + EncryptedAccessToken = connection.EncryptedAccessToken, + IsActive = connection.IsActive, + SyncIntervalMinutes = connection.SyncIntervalMinutes, + LastSyncAt = connection.LastSyncAt, + }; + db.SyncConnections.Add(newConnection); + + // Re-parent linked accounts to the new connection + foreach (var linkedAccount in group) + { + linkedAccount.SyncConnectionId = newConnection.Id; + } + + splitCount++; + } + + logger.LogInformation( + "Split SimpleFIN connection {ConnectionId} into {Count} connections by institution", + connection.Id, grouped.Count); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Failed to check SimpleFIN connection {ConnectionId} for institution split, skipping", + connection.Id); + } + } + + if (splitCount > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("SimpleFIN connection migration complete: created {Count} new connections", splitCount); + } + } +}