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");
}
}