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 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,9 @@ using (var scope = app.Services.CreateScope())
|
|||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-time data migration: split SimpleFIN connections by institution
|
||||||
|
await SimpleFinConnectionMigration.RunAsync(app.Services, app.Services.GetRequiredService<ILogger<Program>>());
|
||||||
|
|
||||||
// Initialize plugin system
|
// Initialize plugin system
|
||||||
var pluginService = app.Services.GetRequiredService<PluginService>();
|
var pluginService = app.Services.GetRequiredService<PluginService>();
|
||||||
await pluginService.DiscoverAndLoadPluginsAsync();
|
await pluginService.DiscoverAndLoadPluginsAsync();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time data migration that splits SimpleFIN connections containing accounts
|
||||||
|
/// from multiple institutions into separate connections per institution.
|
||||||
|
/// </summary>
|
||||||
|
public static class SimpleFinConnectionMigration
|
||||||
|
{
|
||||||
|
public static async Task RunAsync(IServiceProvider services, ILogger logger)
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
|
||||||
|
var encryption = scope.ServiceProvider.GetRequiredService<IEncryptionService>();
|
||||||
|
var simpleFin = scope.ServiceProvider.GetRequiredService<SimpleFinSyncProvider>();
|
||||||
|
|
||||||
|
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<string, string> { ["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user