diff --git a/frontend/src/services/bankSync.ts b/frontend/src/services/bankSync.ts index 484550b..c0df41b 100644 --- a/frontend/src/services/bankSync.ts +++ b/frontend/src/services/bankSync.ts @@ -5,10 +5,17 @@ import type { DiscoveredAccount, SyncResult, SyncLogEntry, - CreateLinkTokenResponse + CreateLinkTokenResponse, + PlaidCredentials } from '@/types' export const bankSyncApi = { + getPlaidCredentials: () => + api.get('/bank-sync/plaid/credentials'), + + savePlaidCredentials: (data: { clientId: string; secret: string; environment: string }) => + api.post('/bank-sync/plaid/credentials', data), + getConnections: () => api.get('/bank-sync/connections'), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6efe145..df9ffa7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -404,6 +404,11 @@ export interface AppNotification { } // Bank Sync +export interface PlaidCredentials { + isConfigured: boolean + environment: string +} + export type SyncProvider = 'Plaid' | 'SimpleFIN' export interface SyncConnection { diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 3aac2f3..db17678 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -25,8 +25,63 @@ + + + + mdi-key + Plaid API Credentials + Configured + Not configured + + +

+ Enter your Plaid developer credentials. Get them from your Plaid dashboard. +

+ + + + + + + + + + + + + Save + + + +
+
+
- + Connect via Plaid @@ -35,7 +90,7 @@
- No bank connections yet. Click a button above to connect your bank. + No bank connections yet. Configure your API credentials above, then click a button to connect your bank. @@ -308,7 +363,7 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useAuthStore } from '@/stores/auth' import { bankSyncApi } from '@/services/bankSync' import { accountsApi } from '@/services/accounts' -import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry } from '@/types' +import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials } from '@/types' const authStore = useAuthStore() @@ -319,7 +374,13 @@ const accounts = ref([]) const syncIntervals = reactive>({}) const syncingConnections = reactive>({}) -// Plaid +// Plaid credentials +const plaidCredentials = ref({ isConfigured: false, environment: 'sandbox' }) +const plaidForm = reactive({ clientId: '', secret: '', environment: 'sandbox' }) +const plaidCredsSaving = ref(false) +const plaidEnvironments = ['sandbox', 'development', 'production'] + +// Plaid link const plaidLoading = ref(false) // SimpleFIN @@ -396,6 +457,37 @@ function formatCurrency(value: number) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value) } +async function loadPlaidCredentials() { + try { + const { data } = await bankSyncApi.getPlaidCredentials() + plaidCredentials.value = data + plaidForm.environment = data.environment + } catch { + // Not configured yet + } +} + +async function savePlaidCredentials() { + plaidCredsSaving.value = true + try { + const { data } = await bankSyncApi.savePlaidCredentials({ + clientId: plaidForm.clientId, + secret: plaidForm.secret, + environment: plaidForm.environment + }) + plaidCredentials.value = data + plaidForm.clientId = '' + plaidForm.secret = '' + syncResultMessage.value = 'Plaid credentials saved' + showSyncResult.value = true + } catch (err: any) { + errorMessage.value = err.response?.data?.error || 'Failed to save Plaid credentials' + showError.value = true + } finally { + plaidCredsSaving.value = false + } +} + async function loadConnections() { loading.value = true try { @@ -583,6 +675,6 @@ async function deleteConnection() { } onMounted(async () => { - await Promise.all([loadConnections(), loadAccounts(), loadSyncLogs()]) + await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs()]) }) diff --git a/src/Purrse.Api/Controllers/BankSyncController.cs b/src/Purrse.Api/Controllers/BankSyncController.cs index 989b516..ae3b498 100644 --- a/src/Purrse.Api/Controllers/BankSyncController.cs +++ b/src/Purrse.Api/Controllers/BankSyncController.cs @@ -35,6 +35,14 @@ public class BankSyncController : ControllerBase return NoContent(); } + [HttpGet("plaid/credentials")] + public async Task> GetPlaidCredentials() + => Ok(await _bankSyncService.GetPlaidCredentialsAsync(UserId)); + + [HttpPost("plaid/credentials")] + public async Task> SavePlaidCredentials(SavePlaidCredentialsRequest request) + => Ok(await _bankSyncService.SavePlaidCredentialsAsync(UserId, request)); + [HttpPost("plaid/create-link-token")] public async Task> CreatePlaidLinkToken() => Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId)); diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index 5ec87fa..26328b7 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -86,7 +86,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Bank sync providers -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Stateless — credentials passed per-call builder.Services.AddHttpClient(); // Plugin system diff --git a/src/Purrse.Api/Services/BankSyncService.cs b/src/Purrse.Api/Services/BankSyncService.cs index 1e2ddc1..5ed106b 100644 --- a/src/Purrse.Api/Services/BankSyncService.cs +++ b/src/Purrse.Api/Services/BankSyncService.cs @@ -8,6 +8,7 @@ using Purrse.Core.Interfaces.Services; using Purrse.Core.Models; using Purrse.Data; using Purrse.Plugins.BankSync; +using Purrse.Plugins.BankSync.Models; namespace Purrse.Api.Services; @@ -42,6 +43,42 @@ public class BankSyncService : IBankSyncService _logger = logger; } + // --- Provider Credentials --- + + public async Task GetPlaidCredentialsAsync(Guid userId) + { + var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId); + if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId)) + return new PlaidCredentialsResponse(false, "sandbox"); + + return new PlaidCredentialsResponse(true, settings.PlaidEnvironment); + } + + public async Task SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request) + { + var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId); + + if (settings == null) + { + settings = new BankSyncSettings + { + Id = Guid.NewGuid(), + UserId = userId + }; + _db.BankSyncSettings.Add(settings); + } + + settings.EncryptedPlaidClientId = _encryption.Encrypt(request.ClientId); + settings.EncryptedPlaidSecret = _encryption.Encrypt(request.Secret); + settings.PlaidEnvironment = request.Environment; + settings.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(); + return new PlaidCredentialsResponse(true, settings.PlaidEnvironment); + } + + // --- Connection CRUD --- + public async Task> GetConnectionsAsync(Guid userId) { var connections = await _db.SyncConnections @@ -88,15 +125,19 @@ public class BankSyncService : IBankSyncService await _db.SaveChangesAsync(); } + // --- Plaid Flow --- + public async Task CreatePlaidLinkTokenAsync(Guid userId) { - var result = await _plaid.CreateLinkTokenAsync(userId.ToString()); + var credentials = await GetPlaidCredentialsInternal(userId); + var result = await _plaid.CreateLinkTokenAsync(userId.ToString(), credentials); return new CreateLinkTokenResponse(result.LinkToken, result.Expiration); } public async Task CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request) { - var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken); + var credentials = await GetPlaidCredentialsInternal(userId); + var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken, credentials); var connection = new SyncConnection { @@ -113,32 +154,9 @@ public class BankSyncService : IBankSyncService await _db.SaveChangesAsync(); // Discover accounts - await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken); + var config = BuildProviderConfig(connection, exchangeResult.AccessToken, credentials); + var accounts = await _plaid.GetAccountsAsync(config); - return await GetConnectionAsync(userId, connection.Id); - } - - public async Task CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request) - { - var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken); - - // Get accounts to determine institution name - var config = new Dictionary { ["AccessUrl"] = accessUrl }; - var accounts = await _simpleFin.GetAccountsAsync(config); - var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection"; - - var connection = new SyncConnection - { - Id = Guid.NewGuid(), - UserId = userId, - Provider = SyncProvider.SimpleFIN, - InstitutionName = institutionName, - EncryptedAccessToken = _encryption.Encrypt(accessUrl) - }; - - _db.SyncConnections.Add(connection); - - // Create linked accounts from discovered accounts foreach (var account in accounts) { _db.LinkedAccounts.Add(new LinkedAccount @@ -157,6 +175,47 @@ public class BankSyncService : IBankSyncService return await GetConnectionAsync(userId, connection.Id); } + // --- SimpleFIN Flow --- + + public async Task CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request) + { + var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken); + + var config = new Dictionary { ["AccessUrl"] = accessUrl }; + var accounts = await _simpleFin.GetAccountsAsync(config); + var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection"; + + var connection = new SyncConnection + { + Id = Guid.NewGuid(), + UserId = userId, + Provider = SyncProvider.SimpleFIN, + InstitutionName = institutionName, + EncryptedAccessToken = _encryption.Encrypt(accessUrl) + }; + + _db.SyncConnections.Add(connection); + + foreach (var account in accounts) + { + _db.LinkedAccounts.Add(new LinkedAccount + { + Id = Guid.NewGuid(), + SyncConnectionId = connection.Id, + ExternalAccountId = account.ExternalId, + ExternalAccountName = account.Name, + ExternalAccountType = account.AccountType, + LastKnownBalance = account.Balance + }); + } + + await _db.SaveChangesAsync(); + + return await GetConnectionAsync(userId, connection.Id); + } + + // --- Account Linking --- + public async Task> DiscoverAccountsAsync(Guid userId, Guid connectionId) { var connection = await _db.SyncConnections @@ -164,19 +223,16 @@ public class BankSyncService : IBankSyncService ?? throw new KeyNotFoundException("Connection not found"); var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken); - var config = BuildProviderConfig(connection, accessToken); + PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid + ? await GetPlaidCredentialsInternal(userId) : null; + var config = BuildProviderConfig(connection, accessToken, plaidCreds); var accounts = connection.Provider == SyncProvider.Plaid ? await _plaid.GetAccountsAsync(config) : await _simpleFin.GetAccountsAsync(config); return accounts.Select(a => new DiscoveredAccountResponse( - a.ExternalId, - a.Name, - a.AccountType, - null, - a.Balance, - a.Currency + a.ExternalId, a.Name, a.AccountType, null, a.Balance, a.Currency )).ToList(); } @@ -193,7 +249,6 @@ public class BankSyncService : IBankSyncService if (request.AutoCreate && request.AccountId == null) { - // Auto-create a Purrse account var accountType = Enum.TryParse(linkedAccount.ExternalAccountType, true, out var at) ? at : AccountType.Checking; @@ -216,7 +271,6 @@ public class BankSyncService : IBankSyncService await _db.SaveChangesAsync(); - // Reload with Account nav linkedAccount = await _db.LinkedAccounts .Include(la => la.Account) .FirstAsync(la => la.Id == linkedAccount.Id); @@ -239,6 +293,8 @@ public class BankSyncService : IBankSyncService return MapLinkedAccountResponse(linkedAccount); } + // --- Sync --- + public async Task SyncNowAsync(Guid userId, Guid connectionId) { var connection = await _db.SyncConnections @@ -269,15 +325,11 @@ public class BankSyncService : IBankSyncService totalSynced += result.AccountsSynced; } - return new SyncResultResponse( - allResults.Count, - totalSynced, - totalFetched, - totalImported, - totalSkipped, - allResults); + return new SyncResultResponse(allResults.Count, totalSynced, totalFetched, totalImported, totalSkipped, allResults); } + // --- Logs --- + public async Task> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50) { var query = _db.SyncLogs @@ -292,26 +344,37 @@ public class BankSyncService : IBankSyncService .OrderByDescending(l => l.StartedAt) .Take(limit) .Select(l => new SyncLogResponse( - l.Id, - l.SyncConnectionId, - l.SyncConnection.InstitutionName, + l.Id, l.SyncConnectionId, l.SyncConnection.InstitutionName, l.LinkedAccountId, l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null, - l.Status, - l.TransactionsFetched, - l.TransactionsImported, - l.TransactionsSkipped, - l.ErrorMessage, - l.StartedAt, - l.CompletedAt, - l.Duration)) + l.Status, l.TransactionsFetched, l.TransactionsImported, l.TransactionsSkipped, + l.ErrorMessage, l.StartedAt, l.CompletedAt, l.Duration)) .ToListAsync(); } + // --- Internal Helpers --- + + private async Task GetPlaidCredentialsInternal(Guid userId) + { + var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId); + + if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId) || string.IsNullOrEmpty(settings.EncryptedPlaidSecret)) + throw new InvalidOperationException("Plaid credentials are not configured. Go to Settings to enter your Plaid API keys."); + + return new PlaidConfig + { + ClientId = _encryption.Decrypt(settings.EncryptedPlaidClientId), + Secret = _encryption.Decrypt(settings.EncryptedPlaidSecret), + Environment = settings.PlaidEnvironment + }; + } + private async Task SyncConnectionInternal(Guid userId, SyncConnection connection) { var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken); - var config = BuildProviderConfig(connection, accessToken); + PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid + ? await GetPlaidCredentialsInternal(userId) : null; + var config = BuildProviderConfig(connection, accessToken, plaidCreds); var enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList(); var accountResults = new List(); @@ -349,10 +412,8 @@ public class BankSyncService : IBankSyncService log.Duration = log.CompletedAt - log.StartedAt; accountResults.Add(new SyncAccountResult( - linkedAccount.ExternalAccountName, - linkedAccount.Account?.Name, - 0, 0, 0, - parseResult.ErrorMessage)); + linkedAccount.ExternalAccountName, linkedAccount.Account?.Name, + 0, 0, 0, parseResult.ErrorMessage)); continue; } @@ -399,7 +460,6 @@ public class BankSyncService : IBankSyncService imported++; } - // Update account balance if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue) { linkedAccount.Account.Balance = parseResult.LedgerBalance.Value; @@ -421,8 +481,7 @@ public class BankSyncService : IBankSyncService totalSkipped += skipped; accountResults.Add(new SyncAccountResult( - linkedAccount.ExternalAccountName, - linkedAccount.Account?.Name, + linkedAccount.ExternalAccountName, linkedAccount.Account?.Name, fetched, imported, skipped, null)); } catch (Exception ex) @@ -436,8 +495,7 @@ public class BankSyncService : IBankSyncService log.Duration = log.CompletedAt - log.StartedAt; accountResults.Add(new SyncAccountResult( - linkedAccount.ExternalAccountName, - linkedAccount.Account?.Name, + linkedAccount.ExternalAccountName, linkedAccount.Account?.Name, 0, 0, 0, ex.Message)); } } @@ -450,7 +508,6 @@ public class BankSyncService : IBankSyncService await _db.SaveChangesAsync(); - // Send SignalR notification await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new { connectionId = connection.Id, @@ -463,67 +520,39 @@ public class BankSyncService : IBankSyncService return new SyncResultResponse( enabledAccounts.Count, accountResults.Count(r => r.Error == null), - totalFetched, - totalImported, - totalSkipped, + totalFetched, totalImported, totalSkipped, accountResults); } - private async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken) + private static Dictionary BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials) { - var config = BuildProviderConfig(connection, accessToken); - var accounts = await _plaid.GetAccountsAsync(config); - - foreach (var account in accounts) + if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null) { - _db.LinkedAccounts.Add(new LinkedAccount + return new Dictionary { - Id = Guid.NewGuid(), - SyncConnectionId = connection.Id, - ExternalAccountId = account.ExternalId, - ExternalAccountName = account.Name, - ExternalAccountType = account.AccountType, - LastKnownBalance = account.Balance - }); + ["AccessToken"] = accessToken, + ["PlaidClientId"] = plaidCredentials.ClientId, + ["PlaidSecret"] = plaidCredentials.Secret, + ["PlaidEnvironment"] = plaidCredentials.Environment + }; } - await _db.SaveChangesAsync(); - } - - private static Dictionary BuildProviderConfig(SyncConnection connection, string accessToken) - { - return connection.Provider == SyncProvider.Plaid - ? new Dictionary { ["AccessToken"] = accessToken } - : new Dictionary { ["AccessUrl"] = accessToken }; + return new Dictionary { ["AccessUrl"] = accessToken }; } private static SyncConnectionResponse MapConnectionResponse(SyncConnection c) { return new SyncConnectionResponse( - c.Id, - c.Provider, - c.InstitutionName, - c.InstitutionId, - c.IsActive, - c.SyncIntervalMinutes, - c.LastSyncAt, - c.LastSyncError, - c.CreatedAt, + c.Id, c.Provider, c.InstitutionName, c.InstitutionId, + c.IsActive, c.SyncIntervalMinutes, c.LastSyncAt, c.LastSyncError, c.CreatedAt, c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList()); } private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la) { return new LinkedAccountResponse( - la.Id, - la.ExternalAccountId, - la.ExternalAccountName, - la.ExternalAccountType, - la.ExternalAccountMask, - la.LastKnownBalance, - la.IsEnabled, - la.AccountId, - la.Account?.Name, - la.LastSyncAt); + la.Id, la.ExternalAccountId, la.ExternalAccountName, + la.ExternalAccountType, la.ExternalAccountMask, la.LastKnownBalance, + la.IsEnabled, la.AccountId, la.Account?.Name, la.LastSyncAt); } } diff --git a/src/Purrse.Api/appsettings.json b/src/Purrse.Api/appsettings.json index 514cefb..4ea2006 100644 --- a/src/Purrse.Api/appsettings.json +++ b/src/Purrse.Api/appsettings.json @@ -18,11 +18,6 @@ }, "BankSync": { "EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!", - "Plaid": { - "ClientId": "", - "Secret": "", - "Environment": "sandbox" - }, "DefaultSyncIntervalMinutes": 360, "BackgroundCheckIntervalMinutes": 15 }, diff --git a/src/Purrse.Core/DTOs/BankSyncDtos.cs b/src/Purrse.Core/DTOs/BankSyncDtos.cs index 94c4df6..f7aecab 100644 --- a/src/Purrse.Core/DTOs/BankSyncDtos.cs +++ b/src/Purrse.Core/DTOs/BankSyncDtos.cs @@ -2,6 +2,10 @@ using Purrse.Core.Enums; namespace Purrse.Core.DTOs; +// Provider credentials +public record SavePlaidCredentialsRequest(string ClientId, string Secret, string Environment); +public record PlaidCredentialsResponse(bool IsConfigured, string Environment); + // Plaid public record CreatePlaidConnectionRequest(string PublicToken); public record CreateLinkTokenResponse(string LinkToken, string Expiration); diff --git a/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs b/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs index d85ee18..e18edcf 100644 --- a/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs +++ b/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs @@ -10,6 +10,10 @@ public interface IBankSyncService Task UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request); Task DeleteConnectionAsync(Guid userId, Guid connectionId); + // Provider credentials + Task GetPlaidCredentialsAsync(Guid userId); + Task SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request); + // Plaid flow Task CreatePlaidLinkTokenAsync(Guid userId); Task CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request); diff --git a/src/Purrse.Core/Models/BankSyncSettings.cs b/src/Purrse.Core/Models/BankSyncSettings.cs new file mode 100644 index 0000000..4b75a0a --- /dev/null +++ b/src/Purrse.Core/Models/BankSyncSettings.cs @@ -0,0 +1,13 @@ +namespace Purrse.Core.Models; + +public class BankSyncSettings +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string? EncryptedPlaidClientId { get; set; } + public string? EncryptedPlaidSecret { get; set; } + public string PlaidEnvironment { get; set; } = "sandbox"; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/User.cs b/src/Purrse.Core/Models/User.cs index 61baee9..595cd7e 100644 --- a/src/Purrse.Core/Models/User.cs +++ b/src/Purrse.Core/Models/User.cs @@ -13,4 +13,5 @@ public class User public ICollection Accounts { get; set; } = new List(); public ICollection SyncConnections { get; set; } = new List(); + public BankSyncSettings? BankSyncSettings { get; set; } } diff --git a/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs b/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs index aa71ca7..c2331dc 100644 --- a/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs +++ b/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs @@ -53,6 +53,25 @@ public class LinkedAccountConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("bank_sync_settings"); + builder.HasKey(s => s.Id); + builder.Property(s => s.EncryptedPlaidClientId).HasColumnType("text"); + builder.Property(s => s.EncryptedPlaidSecret).HasColumnType("text"); + builder.Property(s => s.PlaidEnvironment).HasMaxLength(20); + + builder.HasOne(s => s.User) + .WithOne(u => u.BankSyncSettings) + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.UserId).IsUnique(); + } +} + public class SyncLogConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/Purrse.Data/Migrations/20260208215030_AddBankSyncSettings.Designer.cs b/src/Purrse.Data/Migrations/20260208215030_AddBankSyncSettings.Designer.cs new file mode 100644 index 0000000..f307a57 --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208215030_AddBankSyncSettings.Designer.cs @@ -0,0 +1,1409 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Purrse.Data; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + [DbContext(typeof(PurrseDbContext))] + [Migration("20260208215030_AddBankSyncSettings")] + partial class AddBankSyncSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Balance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditLimit") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Institution") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LoanDetailId") + .HasColumnType("uuid"); + + b.Property("PaymentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentNumber") + .HasColumnType("integer"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RemainingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("LoanDetailId"); + + b.ToTable("amortization_entries", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedPlaidClientId") + .HasColumnType("text"); + + b.Property("EncryptedPlaidSecret") + .HasColumnType("text"); + + b.Property("PlaidEnvironment") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("bank_sync_settings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique(); + + b.ToTable("budgets", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("BudgetedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("budget_items", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("UserId", "Name", "ParentId") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DuplicateCount") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedCount") + .HasColumnType("integer"); + + b.Property("ParsedDataJson") + .HasColumnType("text"); + + b.Property("SkippedCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalCount") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("UserId"); + + b.ToTable("import_batches", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AsOfDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CostBasis") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.Property("Shares") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SecurityId"); + + b.ToTable("investment_holdings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalAccountId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountMask") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastKnownBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExtraPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("MaturityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MonthlyPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginalBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TermMonths") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("loan_details", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultCategoryId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCategoryId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("payees", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Alias"); + + b.HasIndex("PayeeId"); + + b.ToTable("payee_aliases", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginRegistrationId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("PluginRegistrationId", "Key") + .IsUnique(); + + b.ToTable("plugin_configurations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryAssembly") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PluginId") + .IsUnique(); + + b.ToTable("plugin_registrations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("StatementBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("StatementDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("reconciliations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AutoPost") + .HasColumnType("boolean"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NextDueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReminderDaysBefore") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("UserId"); + + b.ToTable("scheduled_transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol") + .IsUnique(); + + b.ToTable("securities", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SecurityId", "Date") + .IsUnique(); + + b.ToTable("security_prices", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstitutionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstitutionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PlaidItemId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LinkedAccountId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.Property("TransactionsFetched") + .HasColumnType("integer"); + + b.Property("TransactionsImported") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CheckNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FitId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ImportBatchId") + .HasColumnType("uuid"); + + b.Property("IsVoid") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciliationId") + .HasColumnType("uuid"); + + b.Property("ReferenceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TransferTransactionId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Date"); + + b.HasIndex("Fingerprint"); + + b.HasIndex("FitId"); + + b.HasIndex("ImportBatchId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("ReconciliationId"); + + b.HasIndex("TransferTransactionId") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("TransactionId"); + + b.ToTable("transaction_splits", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail") + .WithMany("AmortizationEntries") + .HasForeignKey("LoanDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoanDetail"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("BankSyncSettings") + .HasForeignKey("Purrse.Core.Models.BankSyncSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.HasOne("Purrse.Core.Models.Budget", "Budget") + .WithMany("Items") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("BudgetItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.HasOne("Purrse.Core.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("InvestmentHoldings") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Holdings") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + 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") + .WithOne("LoanDetail") + .HasForeignKey("Purrse.Core.Models.LoanDetail", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.HasOne("Purrse.Core.Models.Category", "DefaultCategory") + .WithMany() + .HasForeignKey("DefaultCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DefaultCategory"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Aliases") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payee"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.HasOne("Purrse.Core.Models.PluginRegistration", "PluginRegistration") + .WithMany("Configurations") + .HasForeignKey("PluginRegistrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PluginRegistration"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Reconciliations") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("Payee"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Prices") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.ImportBatch", "ImportBatch") + .WithMany("Transactions") + .HasForeignKey("ImportBatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Transactions") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Reconciliation", "Reconciliation") + .WithMany("Transactions") + .HasForeignKey("ReconciliationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "TransferTransaction") + .WithOne() + .HasForeignKey("Purrse.Core.Models.Transaction", "TransferTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ImportBatch"); + + b.Navigation("Payee"); + + b.Navigation("Reconciliation"); + + b.Navigation("TransferTransaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "Transaction") + .WithMany("Splits") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Navigation("InvestmentHoldings"); + + b.Navigation("LinkedAccounts"); + + b.Navigation("LoanDetail"); + + b.Navigation("Reconciliations"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Navigation("BudgetItems"); + + b.Navigation("Children"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Navigation("AmortizationEntries"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Navigation("Aliases"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Navigation("Holdings"); + + 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"); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("BankSyncSettings"); + + b.Navigation("SyncConnections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Purrse.Data/Migrations/20260208215030_AddBankSyncSettings.cs b/src/Purrse.Data/Migrations/20260208215030_AddBankSyncSettings.cs new file mode 100644 index 0000000..4bc5459 --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208215030_AddBankSyncSettings.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + /// + public partial class AddBankSyncSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "bank_sync_settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + EncryptedPlaidClientId = table.Column(type: "text", nullable: true), + EncryptedPlaidSecret = table.Column(type: "text", nullable: true), + PlaidEnvironment = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_bank_sync_settings", x => x.Id); + table.ForeignKey( + name: "FK_bank_sync_settings_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_bank_sync_settings_UserId", + table: "bank_sync_settings", + column: "UserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "bank_sync_settings"); + } + } +} diff --git a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs index c28bc3c..9acf69e 100644 --- a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs +++ b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs @@ -127,6 +127,37 @@ namespace Purrse.Data.Migrations b.ToTable("amortization_entries", (string)null); }); + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedPlaidClientId") + .HasColumnType("text"); + + b.Property("EncryptedPlaidSecret") + .HasColumnType("text"); + + b.Property("PlaidEnvironment") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("bank_sync_settings", (string)null); + }); + modelBuilder.Entity("Purrse.Core.Models.Budget", b => { b.Property("Id") @@ -974,6 +1005,17 @@ namespace Purrse.Data.Migrations b.Navigation("LoanDetail"); }); + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("BankSyncSettings") + .HasForeignKey("Purrse.Core.Models.BankSyncSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Purrse.Core.Models.Budget", b => { b.HasOne("Purrse.Core.Models.User", "User") @@ -1354,6 +1396,8 @@ namespace Purrse.Data.Migrations { b.Navigation("Accounts"); + b.Navigation("BankSyncSettings"); + b.Navigation("SyncConnections"); }); #pragma warning restore 612, 618 diff --git a/src/Purrse.Data/PurrseDbContext.cs b/src/Purrse.Data/PurrseDbContext.cs index 436c2d2..e7c925a 100644 --- a/src/Purrse.Data/PurrseDbContext.cs +++ b/src/Purrse.Data/PurrseDbContext.cs @@ -29,6 +29,7 @@ public class PurrseDbContext : DbContext public DbSet SyncConnections => Set(); public DbSet LinkedAccounts => Set(); public DbSet SyncLogs => Set(); + public DbSet BankSyncSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs b/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs index 15895f4..ed7db71 100644 --- a/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs +++ b/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs @@ -2,7 +2,6 @@ using Going.Plaid; using Going.Plaid.Entity; using Going.Plaid.Item; using Going.Plaid.Link; -using Microsoft.Extensions.Configuration; using Purrse.Plugins.Abstractions; using Purrse.Plugins.Abstractions.Models; using Purrse.Plugins.BankSync.Models; @@ -11,46 +10,37 @@ namespace Purrse.Plugins.BankSync; public class PlaidSyncProvider : IAccountSyncProvider { - private readonly PlaidClient _client; - private readonly PlaidConfig _config; - public string Id => "plaid-sync"; public string Name => "Plaid Bank Sync"; public string Version => "1.0.0"; public string Description => "Sync transactions from banks via Plaid"; - public PlaidSyncProvider(IConfiguration configuration) - { - _config = new PlaidConfig - { - ClientId = configuration["BankSync:Plaid:ClientId"] ?? string.Empty, - Secret = configuration["BankSync:Plaid:Secret"] ?? string.Empty, - Environment = configuration["BankSync:Plaid:Environment"] ?? "sandbox" - }; + public Task InitializeAsync(IPluginContext context) => Task.CompletedTask; + public Task ShutdownAsync() => Task.CompletedTask; - var env = _config.Environment.ToLowerInvariant() switch + private static PlaidClient CreateClient(string environment) + { + var env = environment.ToLowerInvariant() switch { "production" => Going.Plaid.Environment.Production, "development" => Going.Plaid.Environment.Development, _ => Going.Plaid.Environment.Sandbox }; - - _client = new PlaidClient(env); + return new PlaidClient(env); } - public Task InitializeAsync(IPluginContext context) => Task.CompletedTask; - public Task ShutdownAsync() => Task.CompletedTask; - - public async Task CreateLinkTokenAsync(string userId) + public async Task CreateLinkTokenAsync(string userId, PlaidConfig credentials) { - if (string.IsNullOrEmpty(_config.ClientId) || string.IsNullOrEmpty(_config.Secret)) + if (string.IsNullOrEmpty(credentials.ClientId) || string.IsNullOrEmpty(credentials.Secret)) throw new InvalidOperationException( - "Plaid credentials are not configured. Set BankSync:Plaid:ClientId and BankSync:Plaid:Secret in appsettings.json."); + "Plaid credentials are not configured. Go to Settings to enter your Plaid API keys."); - var response = await _client.LinkTokenCreateAsync(new LinkTokenCreateRequest + var client = CreateClient(credentials.Environment); + + var response = await client.LinkTokenCreateAsync(new LinkTokenCreateRequest { - ClientId = _config.ClientId, - Secret = _config.Secret, + ClientId = credentials.ClientId, + Secret = credentials.Secret, User = new LinkTokenCreateRequestUser { ClientUserId = userId }, ClientName = "Purrse", Products = new[] { Products.Transactions }, @@ -65,12 +55,14 @@ public class PlaidSyncProvider : IAccountSyncProvider }; } - public async Task ExchangePublicTokenAsync(string publicToken) + public async Task ExchangePublicTokenAsync(string publicToken, PlaidConfig credentials) { - var response = await _client.ItemPublicTokenExchangeAsync(new ItemPublicTokenExchangeRequest + var client = CreateClient(credentials.Environment); + + var response = await client.ItemPublicTokenExchangeAsync(new ItemPublicTokenExchangeRequest { - ClientId = _config.ClientId, - Secret = _config.Secret, + ClientId = credentials.ClientId, + Secret = credentials.Secret, PublicToken = publicToken }); @@ -79,10 +71,10 @@ public class PlaidSyncProvider : IAccountSyncProvider try { - var itemResponse = await _client.ItemGetAsync(new ItemGetRequest + var itemResponse = await client.ItemGetAsync(new ItemGetRequest { - ClientId = _config.ClientId, - Secret = _config.Secret, + ClientId = credentials.ClientId, + Secret = credentials.Secret, AccessToken = response.AccessToken }); @@ -90,11 +82,11 @@ public class PlaidSyncProvider : IAccountSyncProvider if (institutionId != null) { - var instResponse = await _client.InstitutionsGetByIdAsync( + var instResponse = await client.InstitutionsGetByIdAsync( new Going.Plaid.Institutions.InstitutionsGetByIdRequest { - ClientId = _config.ClientId, - Secret = _config.Secret, + ClientId = credentials.ClientId, + Secret = credentials.Secret, InstitutionId = institutionId, CountryCodes = new[] { CountryCode.Us } }); @@ -117,12 +109,14 @@ public class PlaidSyncProvider : IAccountSyncProvider public async Task> GetAccountsAsync(Dictionary configuration) { + var credentials = ExtractCredentials(configuration); + var client = CreateClient(credentials.Environment); var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty; - var response = await _client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest + var response = await client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest { - ClientId = _config.ClientId, - Secret = _config.Secret, + ClientId = credentials.ClientId, + Secret = credentials.Secret, AccessToken = accessToken }); @@ -138,6 +132,8 @@ public class PlaidSyncProvider : IAccountSyncProvider public async Task SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary configuration) { + var credentials = ExtractCredentials(configuration); + var client = CreateClient(credentials.Environment); var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty; var allTransactions = new List(); var offset = 0; @@ -145,10 +141,10 @@ public class PlaidSyncProvider : IAccountSyncProvider while (true) { - var response = await _client.TransactionsGetAsync(new Going.Plaid.Transactions.TransactionsGetRequest + var response = await client.TransactionsGetAsync(new Going.Plaid.Transactions.TransactionsGetRequest { - ClientId = _config.ClientId, - Secret = _config.Secret, + ClientId = credentials.ClientId, + Secret = credentials.Secret, AccessToken = accessToken, StartDate = DateOnly.FromDateTime(startDate), EndDate = DateOnly.FromDateTime(endDate), @@ -184,6 +180,16 @@ public class PlaidSyncProvider : IAccountSyncProvider return result; } + private static PlaidConfig ExtractCredentials(Dictionary configuration) + { + return new PlaidConfig + { + ClientId = configuration.GetValueOrDefault("PlaidClientId") ?? string.Empty, + Secret = configuration.GetValueOrDefault("PlaidSecret") ?? string.Empty, + Environment = configuration.GetValueOrDefault("PlaidEnvironment") ?? "sandbox" + }; + } + private static string MapAccountType(AccountType? type, AccountSubtype? subtype) { return type switch