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. /// Tracks completion in a `data_migrations` table so it only runs once. /// public static class SimpleFinConnectionMigration { private const string MigrationName = "SplitSimpleFinConnectionsByInstitution"; public static async Task RunAsync(IServiceProvider services, ILogger logger) { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Create tracking table if it doesn't exist, then check if already applied await db.Database.ExecuteSqlRawAsync( "CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)"); var alreadyApplied = await db.Database .SqlQueryRaw($"SELECT 1 AS \"Value\" FROM data_migrations WHERE name = '{MigrationName}'") .AnyAsync(); if (alreadyApplied) return; 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) { await MarkCompleteAsync(db); 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); } await MarkCompleteAsync(db); } private static async Task MarkCompleteAsync(PurrseDbContext db) { await db.Database.ExecuteSqlRawAsync( $"INSERT INTO data_migrations (name, applied_at) VALUES ('{MigrationName}', NOW()) ON CONFLICT DO NOTHING"); } }