From b9d0869ae38d8cdd0aeca59f0eb17c806eef92a5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:56:29 -0600 Subject: [PATCH] feat: add Plaid transaction handling and async queue processing - introduced new migration to add Cursor column to AccountMetadata. - updated AppDbContext model snapshot to reflect changes in the database schema. - implemented async queue processing with ChannelAsyncQueue and AsyncQueueHostedService. - created Plaid transaction handlers for added, modified, and removed transactions. - developed PlaidTransactionService and PlaidTransactionSyncer for syncing transactions. - added SyncUpdatesProcessor to handle sync updates from Plaid. - updated tests to accommodate changes in institution and account metadata handling. --- .vscode/settings.json | 4 +- src/.editorconfig | 4 + src/Directory.Packages.props | 2 +- src/FiscalOS.API/Accounts/Add/Endpoint.cs | 22 +- src/FiscalOS.API/Accounts/Add/Request.cs | 9 - .../Institutions/Connect/Endpoint.cs | 13 +- .../Institutions/GetAvailable/Endpoint.cs | 6 +- .../Institutions/GetAvailable/Response.cs | 2 +- .../Institutions/Link/Endpoint.cs | 4 +- src/FiscalOS.API/Program.cs | 9 +- .../Transactions/FireWebhook/Endpoint.cs | 65 +++ .../Transactions/FireWebhook/Request.cs | 7 + .../Transactions/TransactionsExtensions.cs | 21 + .../Transactions/Webhook/Endpoint.cs | 41 ++ src/FiscalOS.API/Usings.cs | 12 +- src/FiscalOS.API/appsettings.Example.json | 3 +- src/FiscalOS.AdminCLI/App.cs | 46 ++ src/FiscalOS.AdminCLI/Commands.cs | 2 + src/FiscalOS.AdminCLI/Usings.cs | 4 + src/FiscalOS.AppHost/FiscalOS.AppHost.csproj | 2 +- src/FiscalOS.Core/Accounts/Account.cs | 12 + src/FiscalOS.Core/Accounts/Institution.cs | 1 + src/FiscalOS.Core/Queuing/IAsyncQueue.cs | 8 + .../Queuing/IAsyncQueueProcessor.cs | 6 + src/FiscalOS.Core/Transactions/Transaction.cs | 56 ++ .../Transactions/TransactionMetadata.cs | 15 + src/FiscalOS.Infra/.editorconfig | 2 +- .../Accounts/Plaid/PlaidAccountMetadata.cs | 16 +- ...PlaidService.cs => PlaidAccountService.cs} | 26 +- .../Accounts/Plaid/PlaidClientOptions.cs | 1 + ...etadata.cs => PlaidInstitutionMetadata.cs} | 18 +- .../Plaid => Common}/PlaidException.cs | 2 +- .../{Accounts => Common}/Providers.cs | 2 +- src/FiscalOS.Infra/Data/AppDbContext.cs | 49 +- .../ServiceCollectionExtensions.cs | 18 +- ...23411_AddItemIdToPlaidMetadata.Designer.cs | 374 +++++++++++++ ...20260226123411_AddItemIdToPlaidMetadata.cs | 28 + ...onPropertyToInstitutionForUser.Designer.cs | 376 +++++++++++++ ...dNavigationPropertyToInstitutionForUser.cs | 126 +++++ ...4648_RenamePlaidMetadataEntity.Designer.cs | 376 +++++++++++++ ...0260226224648_RenamePlaidMetadataEntity.cs | 22 + ...onAndTransactionMetadataModels.Designer.cs | 504 +++++++++++++++++ ...TransactionAndTransactionMetadataModels.cs | 105 ++++ ...104650_AddCursorToAccountModel.Designer.cs | 508 ++++++++++++++++++ .../20260301104650_AddCursorToAccountModel.cs | 28 + .../Migrations/AppDbContextModelSnapshot.cs | 148 ++++- .../Queuing/AsyncQueueHostedService.cs | 55 ++ .../Queuing/ChannelAsyncQueue.cs | 31 ++ .../Plaid/PlaidAddedTransactionHandler.cs | 54 ++ .../Plaid/PlaidModifiedTransactionHandler.cs | 75 +++ .../Plaid/PlaidRemovedTransactionHandler.cs | 33 ++ .../Plaid/PlaidTransactionMetadata.cs | 24 + .../Plaid/PlaidTransactionProcessor.cs | 46 ++ .../Plaid/PlaidTransactionService.cs | 58 ++ .../Plaid/PlaidTransactionSyncer.cs | 93 ++++ .../Plaid/SyncUpdatesProcessor.cs | 93 ++++ .../Plaid/SyncUpdatesQueueItem.cs | 3 + src/FiscalOS.Infra/Usings.cs | 5 + .../Integration/Accounts/AddTests.cs | 8 +- .../Integration/Institutions/ConnectTests.cs | 6 +- .../Institutions/GetAvailableTests.cs | 2 +- .../Integration/Institutions/GetTests.cs | 2 +- 62 files changed, 3625 insertions(+), 68 deletions(-) create mode 100644 src/.editorconfig create mode 100644 src/FiscalOS.API/Transactions/FireWebhook/Endpoint.cs create mode 100644 src/FiscalOS.API/Transactions/FireWebhook/Request.cs create mode 100644 src/FiscalOS.API/Transactions/TransactionsExtensions.cs create mode 100644 src/FiscalOS.API/Transactions/Webhook/Endpoint.cs create mode 100644 src/FiscalOS.Core/Queuing/IAsyncQueue.cs create mode 100644 src/FiscalOS.Core/Queuing/IAsyncQueueProcessor.cs create mode 100644 src/FiscalOS.Core/Transactions/Transaction.cs create mode 100644 src/FiscalOS.Core/Transactions/TransactionMetadata.cs rename src/FiscalOS.Infra/Accounts/Plaid/{PlaidService.cs => PlaidAccountService.cs} (72%) rename src/FiscalOS.Infra/Accounts/Plaid/{PlaidMetadata.cs => PlaidInstitutionMetadata.cs} (58%) rename src/FiscalOS.Infra/{Accounts/Plaid => Common}/PlaidException.cs (85%) rename src/FiscalOS.Infra/{Accounts => Common}/Providers.cs (69%) create mode 100644 src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.cs create mode 100644 src/FiscalOS.Infra/Queuing/AsyncQueueHostedService.cs create mode 100644 src/FiscalOS.Infra/Queuing/ChannelAsyncQueue.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidRemovedTransactionHandler.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionMetadata.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionService.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesProcessor.cs create mode 100644 src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesQueueItem.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b15ac2..dd8e581 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,7 @@ "source.fixAll": "explicit" }, "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "cSpell.words": ["Encryptor"], + "python-envs.defaultEnvManager": "ms-python.python:system" } diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..de2acc2 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +dotnet_code_quality.ca1711.allowed_suffixes = Queue +dotnet_diagnostic.CA1031.severity = none \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ad22655..b7293ee 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,7 +3,7 @@ true - + diff --git a/src/FiscalOS.API/Accounts/Add/Endpoint.cs b/src/FiscalOS.API/Accounts/Add/Endpoint.cs index 06f3e4e..8aa1cb6 100644 --- a/src/FiscalOS.API/Accounts/Add/Endpoint.cs +++ b/src/FiscalOS.API/Accounts/Add/Endpoint.cs @@ -13,15 +13,20 @@ internal static class Endpoint HttpContext httpContext, [FromBody] Request request, [FromServices] AppDbContext appDbContext, + [FromServices] IEncryptor encryptor, + [FromServices] IPlaidAccountService plaidAccountService, + [FromServices] IAsyncQueue queue, CancellationToken ct ) { var userId = httpContext.GetUserId(); var user = await appDbContext.Users - .Include(u => u.Institutions.Where(i => i.Metadata is PlaidMetadata && ((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId)) + .Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId)) + .ThenInclude(i => i.Metadata) .Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId)) .ThenInclude(a => a.Metadata) + .AsSplitQuery() .SingleOrDefaultAsync(u => u.Id == userId, ct); if (user is null) @@ -37,19 +42,28 @@ internal static class Endpoint }); } + var plaidInstitutionMetadata = (PlaidInstitutionMetadata?)user.Institutions.First().Metadata; + + if (plaidInstitutionMetadata is null) + { + return Results.ValidationProblem(new Dictionary + { + ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"], + }); + } + if (user.Accounts.Any()) { return Results.Conflict(); } + var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct); var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName); var account = Account.From(user.Institutions.First().Id, request.PlaidAccountName, accountMetadata); - var balance = Balance.From(request.AccountCurrentBalance, request.AccountAvailableBalance, request.AccountCurrencyCode); - - account.AddBalance(balance); user.AddAccount(account); await appDbContext.SaveChangesAsync(ct); + await queue.EnqueueAsync(new(plaidInstitutionMetadata.ItemId), ct); return Results.Ok(); } diff --git a/src/FiscalOS.API/Accounts/Add/Request.cs b/src/FiscalOS.API/Accounts/Add/Request.cs index fdc4923..e78fb3d 100644 --- a/src/FiscalOS.API/Accounts/Add/Request.cs +++ b/src/FiscalOS.API/Accounts/Add/Request.cs @@ -5,9 +5,6 @@ public record Request : IValidatableObject public string PlaidInstitutionId { get; init; } = string.Empty; public string PlaidAccountId { get; init; } = string.Empty; public string PlaidAccountName { get; init; } = string.Empty; - public decimal AccountCurrentBalance { get; init; } - public decimal AccountAvailableBalance { get; init; } - public string AccountCurrencyCode { get; init; } = string.Empty; public IEnumerable Validate(ValidationContext validationContext) { @@ -28,11 +25,5 @@ public record Request : IValidatableObject var fieldName = nameof(PlaidAccountName); yield return new($"The {fieldName} field is required.", [fieldName]); } - - if (string.IsNullOrWhiteSpace(AccountCurrencyCode)) - { - var fieldName = nameof(AccountCurrencyCode); - yield return new($"The {fieldName} field is required.", [fieldName]); - } } } \ No newline at end of file diff --git a/src/FiscalOS.API/Institutions/Connect/Endpoint.cs b/src/FiscalOS.API/Institutions/Connect/Endpoint.cs index 8eecb10..55c20b9 100644 --- a/src/FiscalOS.API/Institutions/Connect/Endpoint.cs +++ b/src/FiscalOS.API/Institutions/Connect/Endpoint.cs @@ -13,7 +13,7 @@ internal static class Endpoint HttpContext httpContext, [FromBody] Request request, [FromServices] AppDbContext appDbContext, - [FromServices] PlaidService plaidService, + [FromServices] IPlaidAccountService plaidAccountService, [FromServices] IEncryptor encryptor, CancellationToken ct ) @@ -23,8 +23,8 @@ internal static class Endpoint var user = await appDbContext.Users .Include(u => u.Institutions .Where( - i => i.Metadata is PlaidMetadata && - ((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId + i => i.Metadata is PlaidInstitutionMetadata && + ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId ) ) .ThenInclude(i => i.Metadata) @@ -40,12 +40,11 @@ internal static class Endpoint return Results.Conflict(); } - var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken); - var item = await plaidService.GetItemAsync(accessToken); - + var (itemId, accessToken) = await plaidAccountService.ExchangeTokenAsync(request.PublicToken); + var item = await plaidAccountService.GetItemAsync(accessToken); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct); - var plaidMetadata = PlaidMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken); + var plaidMetadata = PlaidInstitutionMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken, itemId); var institution = Institution.From(item.InstitutionName, plaidMetadata); user.AddInstitution(institution); diff --git a/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs b/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs index 5845067..2d1d58c 100644 --- a/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs +++ b/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs @@ -13,7 +13,7 @@ internal static class Endpoint HttpContext httpContext, [FromRoute] Guid id, [FromServices] AppDbContext appDbContext, - [FromServices] PlaidService plaidService, + [FromServices] IPlaidAccountService plaidAccountService, [FromServices] IEncryptor encryptor, CancellationToken ct ) @@ -37,13 +37,13 @@ internal static class Endpoint var institution = user.Institutions.First(); - if (institution.Metadata is not PlaidMetadata plaidMetadata) + if (institution.Metadata is not PlaidInstitutionMetadata plaidMetadata) { return Results.BadRequest(); } var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct); - var accounts = await plaidService.GetAccountsAsync(accessToken); + var accounts = await plaidAccountService.GetAccountsAsync(accessToken); var accountsDtos = accounts.Select(a => AvailableAccountDto.From(plaidMetadata, a)); return Results.Ok(Response.From(accountsDtos)); diff --git a/src/FiscalOS.API/Institutions/GetAvailable/Response.cs b/src/FiscalOS.API/Institutions/GetAvailable/Response.cs index ba81b16..2881416 100644 --- a/src/FiscalOS.API/Institutions/GetAvailable/Response.cs +++ b/src/FiscalOS.API/Institutions/GetAvailable/Response.cs @@ -33,7 +33,7 @@ internal sealed record AvailableAccountDto } public static AvailableAccountDto From( - PlaidMetadata plaidMetadata, + PlaidInstitutionMetadata plaidMetadata, Going.Plaid.Entity.Account plaidAccount ) { diff --git a/src/FiscalOS.API/Institutions/Link/Endpoint.cs b/src/FiscalOS.API/Institutions/Link/Endpoint.cs index 9afb473..32069f1 100644 --- a/src/FiscalOS.API/Institutions/Link/Endpoint.cs +++ b/src/FiscalOS.API/Institutions/Link/Endpoint.cs @@ -12,7 +12,7 @@ internal static class Endpoint private static async Task HandleAsync( HttpContext httpContext, [FromServices] AppDbContext appDbContext, - [FromServices] PlaidService plaidService + [FromServices] IPlaidAccountService plaidAccountService ) { var userId = httpContext.GetUserId(); @@ -24,7 +24,7 @@ internal static class Endpoint return Results.Unauthorized(); } - var linkToken = await plaidService.CreateLinkTokenAsync(user.Id.ToString()); + var linkToken = await plaidAccountService.CreateLinkTokenAsync(user.Id.ToString()); return Results.Ok(Response.From(linkToken)); } diff --git a/src/FiscalOS.API/Program.cs b/src/FiscalOS.API/Program.cs index 409bb9b..10f6b96 100644 --- a/src/FiscalOS.API/Program.cs +++ b/src/FiscalOS.API/Program.cs @@ -1,4 +1,4 @@ -using FiscalOS.ServiceDefaults; +using Going.Plaid.Converters; var builder = WebApplication.CreateBuilder(args); @@ -7,6 +7,12 @@ builder.AddServiceDefaults(); builder.Services.AddValidation(); builder.Services.AddProblemDetails(); +builder.Services.ConfigureHttpJsonOptions(static options => +{ + options.SerializerOptions.Converters.Add(new EnumConverterFactory()); + options.SerializerOptions.Converters.Add(new WebhookBaseConverter()); +}); + builder.Services.AddInfrastructure(); builder.Services.AddAuthentication(static o => @@ -41,5 +47,6 @@ app.MapDefaultEndpoints(); app.MapAuthEndpoints(); app.MapAccountsEndpoints(); app.MapInstitutionsEndpoints(); +app.MapTransactionsGroup(); app.Run(); \ No newline at end of file diff --git a/src/FiscalOS.API/Transactions/FireWebhook/Endpoint.cs b/src/FiscalOS.API/Transactions/FireWebhook/Endpoint.cs new file mode 100644 index 0000000..fcc431d --- /dev/null +++ b/src/FiscalOS.API/Transactions/FireWebhook/Endpoint.cs @@ -0,0 +1,65 @@ +using Going.Plaid; +using Going.Plaid.Entity; + +namespace FiscalOS.API.Transactions.FireWebhook; + +internal static class Endpoint +{ + private const string Route = "/fire-webhook"; + + public static RouteHandlerBuilder MapFireWebhookEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapPost(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + [FromBody] Request request, + [FromServices] AppDbContext appDbContext, + [FromServices] IEncryptor encryptor, + [FromServices] PlaidClient plaidClient, + CancellationToken ct + ) + { + var userId = httpContext.GetUserId(); + + var user = await appDbContext.Users + .Include(u => u.Accounts.Where(a => a.Id.ToString() == request.AccountId)) + .ThenInclude(static a => a.Institution!) + .ThenInclude(static i => i.Metadata) + .FirstOrDefaultAsync(u => u.Id == userId, ct); + + if (user is null) + { + return Results.Unauthorized(); + } + + if (user.Accounts.Any() is false) + { + return Results.NotFound(); + } + + var plaidMetadata = (PlaidInstitutionMetadata?)user.Accounts.First().Institution?.Metadata; + + if (plaidMetadata is null) + { + return Results.InternalServerError("No metadata associated with account"); + } + + var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct); + + var fireEventResponse = await plaidClient.SandboxItemFireWebhookAsync(new() + { + AccessToken = accessToken, + WebhookType = SandboxItemFireWebhookRequestWebhookTypeEnum.Transactions, + WebhookCode = SandboxItemFireWebhookRequestWebhookCodeEnum.SyncUpdatesAvailable, + }); + + if (fireEventResponse.IsSuccessStatusCode is false) + { + return Results.InternalServerError(fireEventResponse.Error?.ErrorMessage); + } + + return Results.Ok(new { fireEventResponse.RequestId, fireEventResponse.WebhookFired }); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Transactions/FireWebhook/Request.cs b/src/FiscalOS.API/Transactions/FireWebhook/Request.cs new file mode 100644 index 0000000..1ad6b46 --- /dev/null +++ b/src/FiscalOS.API/Transactions/FireWebhook/Request.cs @@ -0,0 +1,7 @@ +namespace FiscalOS.API.Transactions.FireWebhook; + +public sealed record Request( + string AccountId, + string WebhookType, + string WebhookCode +); \ No newline at end of file diff --git a/src/FiscalOS.API/Transactions/TransactionsExtensions.cs b/src/FiscalOS.API/Transactions/TransactionsExtensions.cs new file mode 100644 index 0000000..881961a --- /dev/null +++ b/src/FiscalOS.API/Transactions/TransactionsExtensions.cs @@ -0,0 +1,21 @@ +namespace FiscalOS.API.Transactions; + +internal static class TransactionsExtensions +{ + private const string RouteGroupPrefix = "/transactions"; + + public static RouteGroupBuilder MapTransactionsGroup(this WebApplication app) + { + var transactionsGroup = app.MapGroup(RouteGroupPrefix) + .RequireAuthorization(); + + if (app.Environment.IsProduction() is false) + { + transactionsGroup.MapFireWebhookEndpoint(); + } + + transactionsGroup.MapWebhookEndpoint().AllowAnonymous(); + + return transactionsGroup; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Transactions/Webhook/Endpoint.cs b/src/FiscalOS.API/Transactions/Webhook/Endpoint.cs new file mode 100644 index 0000000..e0906e9 --- /dev/null +++ b/src/FiscalOS.API/Transactions/Webhook/Endpoint.cs @@ -0,0 +1,41 @@ +namespace FiscalOS.API.Transactions.Webhook; + +internal static class Endpoint +{ + private const string Route = "/webhook"; + + public static RouteHandlerBuilder MapWebhookEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapPost(Route, HandleAsync); + } + + private static async Task HandleAsync( + [FromBody] WebhookBase request, + [FromServices] ILogger logger, + [FromServices] IAsyncQueue queue, + CancellationToken ct + ) + { + logger.LogInformation( + "Received {WebhookType} webhook with {WebhookCode} payload", + request.WebhookType, + request.WebhookCode + ); + + switch (request) + { + case SyncUpdatesAvailableWebhook webhook: + await queue.EnqueueAsync(new(webhook.ItemId), ct); + logger.LogInformation( + "Enqueued {WebhookType} webhook with {WebhookCode} payload", + request.WebhookType, + request.WebhookCode + ); + break; + default: + break; + } + + return Results.Ok(); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index 80d5a1a..a2e3208 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -2,7 +2,6 @@ global using System.ComponentModel.DataAnnotations; global using System.Security.Claims; global using System.Text.Json.Serialization; -global using FiscalOS.API.Institutions.Get; global using FiscalOS.API.Accounts; global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Auth; @@ -11,16 +10,27 @@ global using FiscalOS.API.Auth.Refresh; global using FiscalOS.API.Http; global using FiscalOS.API.Institutions; global using FiscalOS.API.Institutions.Connect; +global using FiscalOS.API.Institutions.Get; global using FiscalOS.API.Institutions.GetAvailable; global using FiscalOS.API.Institutions.Link; +global using FiscalOS.API.Transactions; +global using FiscalOS.API.Transactions.FireWebhook; +global using FiscalOS.API.Transactions.Webhook; global using FiscalOS.Core.Accounts; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Identity; +global using FiscalOS.Core.Queuing; global using FiscalOS.Core.Security; +global using FiscalOS.Core.Transactions; global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Data; global using FiscalOS.Infra.DependencyInjection; +global using FiscalOS.Infra.Transactions.Plaid; +global using FiscalOS.ServiceDefaults; + +global using Going.Plaid; +global using Going.Plaid.Webhook; global using Microsoft.AspNetCore.Mvc; global using Microsoft.EntityFrameworkCore; \ No newline at end of file diff --git a/src/FiscalOS.API/appsettings.Example.json b/src/FiscalOS.API/appsettings.Example.json index e552a7a..585eaf8 100644 --- a/src/FiscalOS.API/appsettings.Example.json +++ b/src/FiscalOS.API/appsettings.Example.json @@ -21,6 +21,7 @@ }, "PlaidClientOptions": { "ClientId": "ClientId", - "Secret": "Secret" + "Secret": "Secret", + "Webhook": "Webhook" } } diff --git a/src/FiscalOS.AdminCLI/App.cs b/src/FiscalOS.AdminCLI/App.cs index 448ab31..05b2ac2 100644 --- a/src/FiscalOS.AdminCLI/App.cs +++ b/src/FiscalOS.AdminCLI/App.cs @@ -47,6 +47,52 @@ internal sealed class App( var keyRingEntry = await keyRing.SaveKeyAsync(key); _console.MarkupLine($"Key generated with id [green]{keyRingEntry.KeyId}[/] generated successfully."); break; + case Commands.RemoveInstitution: + var institutionId = await _console.AskAsync("Enter the [green]institution id[/] of the item you want to remove:", cancellationToken); + + var institution = await appDbContext.Institutions + .Include(i => i.User) + .Include(i => i.Metadata) + .FirstOrDefaultAsync(cancellationToken); + + if (institution is null) + { + _console.MarkupLine($"Institution with id [green]{institutionId}[/] not found."); + break; + } + + if (institution.User is null) + { + _console.MarkupLine($"Institution with id [green]{institutionId}[/] has no user."); + break; + } + + if (institution.Metadata is not PlaidInstitutionMetadata plaidInstitutionMetadata) + { + _console.MarkupLine($"Institution with id [green]{institutionId}[/] has no plaid metadata."); + break; + } + + var accessToken = await encryptor.DecryptAsyncFor(institution.User, plaidInstitutionMetadata.EncryptedAccessToken, cancellationToken); + + var plaidClient = scope.ServiceProvider.GetRequiredService(); + + var removeItemResponse = await plaidClient.ItemRemoveAsync(new() + { + AccessToken = accessToken, + }); + + if (removeItemResponse.IsSuccessStatusCode is false) + { + _console.MarkupLine($"Failed to remove item for institution id [green]{institutionId}[/]. Plaid error: {removeItemResponse.Error?.ErrorMessage}"); + break; + } + + appDbContext.Institutions.Remove(institution); + await appDbContext.SaveChangesAsync(cancellationToken); + + _console.MarkupLine($"Institution with id [green]{institutionId}[/] removed successfully."); + break; case Commands.Exit: default: break; diff --git a/src/FiscalOS.AdminCLI/Commands.cs b/src/FiscalOS.AdminCLI/Commands.cs index c06b1fe..be16986 100644 --- a/src/FiscalOS.AdminCLI/Commands.cs +++ b/src/FiscalOS.AdminCLI/Commands.cs @@ -4,11 +4,13 @@ internal static class Commands { public const string CreateUser = "Create User"; public const string GenerateKey = "Generate Key"; + public const string RemoveInstitution = "Remove Institution"; public const string Exit = "Exit"; public static readonly string[] All = [ CreateUser, GenerateKey, + RemoveInstitution, Exit ]; } \ No newline at end of file diff --git a/src/FiscalOS.AdminCLI/Usings.cs b/src/FiscalOS.AdminCLI/Usings.cs index 919b9e0..310c49d 100644 --- a/src/FiscalOS.AdminCLI/Usings.cs +++ b/src/FiscalOS.AdminCLI/Usings.cs @@ -2,9 +2,13 @@ global using FiscalOS.AdminCLI; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Identity; global using FiscalOS.Core.Security; +global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Data; global using FiscalOS.Infra.DependencyInjection; +global using Going.Plaid; + +global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/src/FiscalOS.AppHost/FiscalOS.AppHost.csproj b/src/FiscalOS.AppHost/FiscalOS.AppHost.csproj index 195fcc2..3013b6d 100644 --- a/src/FiscalOS.AppHost/FiscalOS.AppHost.csproj +++ b/src/FiscalOS.AppHost/FiscalOS.AppHost.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/FiscalOS.Core/Accounts/Account.cs b/src/FiscalOS.Core/Accounts/Account.cs index 4193a45..ed7f585 100644 --- a/src/FiscalOS.Core/Accounts/Account.cs +++ b/src/FiscalOS.Core/Accounts/Account.cs @@ -1,3 +1,5 @@ +using FiscalOS.Core.Transactions; + namespace FiscalOS.Core.Accounts; public sealed class Account : Entity @@ -11,6 +13,9 @@ public sealed class Account : Entity private readonly List _balances = []; public IEnumerable Balances => _balances; + private readonly List _transactions = []; + public IEnumerable Transactions => _transactions; + public static Account From(Guid institutionId, string name, AccountMetadata accountMetadata) { return new() @@ -27,4 +32,11 @@ public sealed class Account : Entity _balances.Add(balance); } + + public void AddTransaction(Transaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + _transactions.Add(transaction); + } } \ No newline at end of file diff --git a/src/FiscalOS.Core/Accounts/Institution.cs b/src/FiscalOS.Core/Accounts/Institution.cs index 1f9f0c9..0bdc031 100644 --- a/src/FiscalOS.Core/Accounts/Institution.cs +++ b/src/FiscalOS.Core/Accounts/Institution.cs @@ -3,6 +3,7 @@ namespace FiscalOS.Core.Accounts; public sealed class Institution : Entity { public Guid UserId { get; init; } + public User? User { get; init; } public string Name { get; init; } = string.Empty; public InstitutionMetadata? Metadata { get; init; } diff --git a/src/FiscalOS.Core/Queuing/IAsyncQueue.cs b/src/FiscalOS.Core/Queuing/IAsyncQueue.cs new file mode 100644 index 0000000..6bb82bd --- /dev/null +++ b/src/FiscalOS.Core/Queuing/IAsyncQueue.cs @@ -0,0 +1,8 @@ +namespace FiscalOS.Core.Queuing; + +public interface IAsyncQueue +{ + Task EnqueueAsync(T item, CancellationToken cancellationToken = default); + Task DequeueAsync(CancellationToken cancellationToken = default); + ValueTask DisposeAsync(); +} diff --git a/src/FiscalOS.Core/Queuing/IAsyncQueueProcessor.cs b/src/FiscalOS.Core/Queuing/IAsyncQueueProcessor.cs new file mode 100644 index 0000000..870347e --- /dev/null +++ b/src/FiscalOS.Core/Queuing/IAsyncQueueProcessor.cs @@ -0,0 +1,6 @@ +namespace FiscalOS.Core.Queuing; + +public interface IAsyncQueueProcessor +{ + Task ProcessAsync(T item, CancellationToken cancellationToken = default); +} diff --git a/src/FiscalOS.Core/Transactions/Transaction.cs b/src/FiscalOS.Core/Transactions/Transaction.cs new file mode 100644 index 0000000..fd5656f --- /dev/null +++ b/src/FiscalOS.Core/Transactions/Transaction.cs @@ -0,0 +1,56 @@ +namespace FiscalOS.Core.Transactions; + +public sealed class Transaction : Entity +{ + public Guid UserId { get; init; } + public User? User { get; init; } + + public Guid AccountId { get; init; } + public Account? Account { get; init; } + + public string MerchantName { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public decimal Amount { get; init; } + public DateTimeOffset Date { get; init; } + + public TransactionMetadata? Metadata { get; init; } + + private Transaction() + { + } + + public static Transaction From( + Guid userId, + Guid accountId, + string? merchantName, + string? description, + decimal? amount, + DateTimeOffset? date, + TransactionMetadata metadata + ) + { + ArgumentNullException.ThrowIfNull(merchantName); + ArgumentNullException.ThrowIfNull(description); + + if (amount.HasValue is false) + { + throw new ArgumentNullException(nameof(amount)); + } + + if (date.HasValue is false) + { + throw new ArgumentNullException(nameof(date)); + } + + return new() + { + UserId = userId, + AccountId = accountId, + MerchantName = merchantName, + Description = description, + Amount = amount.Value, + Date = date.Value, + Metadata = metadata, + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.Core/Transactions/TransactionMetadata.cs b/src/FiscalOS.Core/Transactions/TransactionMetadata.cs new file mode 100644 index 0000000..baf93e4 --- /dev/null +++ b/src/FiscalOS.Core/Transactions/TransactionMetadata.cs @@ -0,0 +1,15 @@ +namespace FiscalOS.Core.Transactions; + +public abstract class TransactionMetadata : Entity +{ + private readonly string _type; + + public string Type => _type; + + public Guid TransactionId { get; init; } + + protected TransactionMetadata(string type) + { + _type = type; + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/.editorconfig b/src/FiscalOS.Infra/.editorconfig index 91dc7c3..1788515 100644 --- a/src/FiscalOS.Infra/.editorconfig +++ b/src/FiscalOS.Infra/.editorconfig @@ -1,4 +1,4 @@ [*.cs] dotnet_diagnostic.CA1062.severity = none -dotnet_diagnostic.CA5404.severity = none +dotnet_diagnostic.CA5404.severity = none \ No newline at end of file diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountMetadata.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountMetadata.cs index bd1d7ff..2c1bd4b 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountMetadata.cs +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountMetadata.cs @@ -1,3 +1,6 @@ + +using FiscalOS.Infra.Common; + namespace FiscalOS.Infra.Accounts.Plaid; public sealed class PlaidAccountMetadata : AccountMetadata @@ -5,12 +8,16 @@ public sealed class PlaidAccountMetadata : AccountMetadata public const string TypeValue = Providers.Plaid; public string PlaidId { get; init; } = string.Empty; public string PlaidName { get; init; } = string.Empty; + public string? Cursor { get; private set; } private PlaidAccountMetadata() : base(TypeValue) { } - public static PlaidAccountMetadata From(string plaidId, string plaidName) + public static PlaidAccountMetadata From( + string plaidId, + string plaidName + ) { return new() { @@ -18,4 +25,11 @@ public sealed class PlaidAccountMetadata : AccountMetadata PlaidName = plaidName, }; } + + public void SetCursor(string cursor) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cursor, nameof(cursor)); + + Cursor = cursor; + } } \ No newline at end of file diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs similarity index 72% rename from src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs rename to src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs index 5264d67..3c8bf8c 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs @@ -1,22 +1,29 @@ - - -using System.Reflection; - namespace FiscalOS.Infra.Accounts.Plaid; -public sealed class PlaidService +public interface IPlaidAccountService { + Task CreateLinkTokenAsync(string id); + Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken); + Task> GetAccountsAsync(string accessToken); + Task GetItemAsync(string accessToken); +} + +internal sealed class PlaidAccountService : IPlaidAccountService +{ + private readonly IOptions _options; private readonly PlaidClient _client; - private PlaidService(PlaidClient client) + private PlaidAccountService(PlaidClient client, IOptions options) { _client = client; + _options = options; } - public static PlaidService From(IServiceProvider sp) + public static PlaidAccountService From(IServiceProvider sp) { var client = sp.GetRequiredService(); - return new(client); + var options = sp.GetRequiredService>(); + return new(client, options); } public async Task CreateLinkTokenAsync(string id) @@ -34,6 +41,7 @@ public sealed class PlaidService { ClientUserId = id, }, + Webhook = _options.Value.Webhook, }).ConfigureAwait(false); if (ltr.IsSuccessStatusCode is false) @@ -88,4 +96,4 @@ public sealed class PlaidService return ar.Item; } -} \ No newline at end of file +} diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidClientOptions.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidClientOptions.cs index b137e63..2da22a0 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidClientOptions.cs +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidClientOptions.cs @@ -4,6 +4,7 @@ public sealed record PlaidClientOptions { public string ClientId { get; init; } = string.Empty; public string Secret { get; init; } = string.Empty; + public string Webhook { get; init; } = string.Empty; public IOptions ToPlaidOptions() { diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidInstitutionMetadata.cs similarity index 58% rename from src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs rename to src/FiscalOS.Infra/Accounts/Plaid/PlaidInstitutionMetadata.cs index 0237570..b40f639 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidInstitutionMetadata.cs @@ -1,31 +1,37 @@ +using FiscalOS.Infra.Common; + namespace FiscalOS.Infra.Accounts.Plaid; -public sealed class PlaidMetadata : InstitutionMetadata +public sealed class PlaidInstitutionMetadata : InstitutionMetadata { public const string TypeValue = Providers.Plaid; public string PlaidId { get; init; } = string.Empty; public string PlaidName { get; init; } = string.Empty; public string EncryptedAccessToken { get; init; } = string.Empty; + public string ItemId { get; init; } = string.Empty; - private PlaidMetadata() : base(TypeValue) + private PlaidInstitutionMetadata() : base(TypeValue) { } - public static PlaidMetadata From( + public static PlaidInstitutionMetadata From( string? plaidId, string? plaidName, - string encryptedAccessToken + string encryptedAccessToken, + string itemId ) { ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId)); ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName)); ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken)); + ArgumentNullException.ThrowIfNull(itemId, nameof(itemId)); - return new PlaidMetadata + return new() { PlaidId = plaidId, PlaidName = plaidName, - EncryptedAccessToken = encryptedAccessToken + EncryptedAccessToken = encryptedAccessToken, + ItemId = itemId, }; } } \ No newline at end of file diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidException.cs b/src/FiscalOS.Infra/Common/PlaidException.cs similarity index 85% rename from src/FiscalOS.Infra/Accounts/Plaid/PlaidException.cs rename to src/FiscalOS.Infra/Common/PlaidException.cs index 668bc34..7ee6971 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidException.cs +++ b/src/FiscalOS.Infra/Common/PlaidException.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.Infra.Accounts.Plaid; +namespace FiscalOS.Infra.Common; public class PlaidException : Exception { diff --git a/src/FiscalOS.Infra/Accounts/Providers.cs b/src/FiscalOS.Infra/Common/Providers.cs similarity index 69% rename from src/FiscalOS.Infra/Accounts/Providers.cs rename to src/FiscalOS.Infra/Common/Providers.cs index 8cc1a2d..dc585da 100644 --- a/src/FiscalOS.Infra/Accounts/Providers.cs +++ b/src/FiscalOS.Infra/Common/Providers.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.Infra.Accounts; +namespace FiscalOS.Infra.Common; public static class Providers { diff --git a/src/FiscalOS.Infra/Data/AppDbContext.cs b/src/FiscalOS.Infra/Data/AppDbContext.cs index 445ccb9..3db167d 100644 --- a/src/FiscalOS.Infra/Data/AppDbContext.cs +++ b/src/FiscalOS.Infra/Data/AppDbContext.cs @@ -1,5 +1,9 @@ +using FiscalOS.Core.Transactions; +using FiscalOS.Infra.Transactions.Plaid; + using Account = FiscalOS.Core.Accounts.Account; using Institution = FiscalOS.Core.Accounts.Institution; +using Transaction = FiscalOS.Core.Transactions.Transaction; namespace FiscalOS.Infra.Data; @@ -14,6 +18,8 @@ public sealed class AppDbContext( public DbSet Users => Set(); public DbSet RefreshTokens => Set(); + public DbSet Institutions => Set(); + public DbSet Transactions => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -63,7 +69,7 @@ public sealed class AppDbContext( .OnDelete(DeleteBehavior.Cascade); eb.HasMany(static u => u.Institutions) - .WithOne() + .WithOne(static i => i.User) .HasForeignKey(static i => i.UserId) .OnDelete(DeleteBehavior.Cascade); @@ -96,19 +102,20 @@ public sealed class AppDbContext( modelBuilder.Entity(static eb => { eb.HasDiscriminator(static m => m.Type) - .HasValue(PlaidMetadata.TypeValue); + .HasValue(PlaidInstitutionMetadata.TypeValue); eb.Property(static m => m.InstitutionId); eb.Property(static m => m.Type); }); - modelBuilder.Entity(static eb => + modelBuilder.Entity(static eb => { eb.HasBaseType(); eb.Property(static m => m.PlaidId); eb.Property(static m => m.PlaidName); eb.Property(static m => m.EncryptedAccessToken); + eb.Property(static m => m.ItemId); }); modelBuilder.Entity(static eb => @@ -146,6 +153,7 @@ public sealed class AppDbContext( eb.Property(static m => m.PlaidId); eb.Property(static m => m.PlaidName); + eb.Property(static m => m.Cursor); }); modelBuilder.Entity(static eb => @@ -155,5 +163,40 @@ public sealed class AppDbContext( eb.Property(static b => b.Available); eb.Property(static b => b.CurrencyCode); }); + + modelBuilder.Entity(static eb => + { + eb.HasOne(static t => t.User) + .WithMany() + .HasForeignKey(static t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + eb.HasOne(static t => t.Account) + .WithMany() + .HasForeignKey(static t => t.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + eb.Property(static t => t.MerchantName); + eb.Property(static t => t.Description); + eb.Property(static t => t.Amount); + eb.Property(static t => t.Date); + }); + + modelBuilder.Entity(static eb => + { + eb.HasDiscriminator(static m => m.Type) + .HasValue(PlaidTransactionMetadata.TypeValue); + + eb.Property(static m => m.TransactionId); + eb.Property(static m => m.Type); + }); + + modelBuilder.Entity(static eb => + { + eb.HasDiscriminator(static m => m.Type) + .HasValue(PlaidTransactionMetadata.TypeValue); + + eb.Property(static m => m.PlaidId); + }); } } \ No newline at end of file diff --git a/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs b/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs index b4374ca..244cd01 100644 --- a/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/FiscalOS.Infra/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,10 @@ namespace FiscalOS.Infra.DependencyInjection; +using FiscalOS.Core.Queuing; +using FiscalOS.Infra.Accounts.Plaid; +using FiscalOS.Infra.Queuing; +using FiscalOS.Infra.Transactions.Plaid; + public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure(this IServiceCollection services) @@ -33,7 +38,18 @@ public static class ServiceCollectionExtensions var options = clientOptions.Value.ToPlaidOptions(); return new PlaidClient(options, factory, logger); }); - services.AddSingleton(PlaidService.From); + services.AddSingleton(PlaidAccountService.From); + services.AddSingleton(PlaidTransactionService.From); + + services.AddScoped(PlaidAddedTransactionHandler.From); + services.AddScoped(PlaidModifiedTransactionHandler.From); + services.AddScoped(PlaidRemovedTransactionHandler.From); + services.AddScoped(PlaidTransactionProcessor.From); + services.AddScoped(PlaidTransactionSyncer.From); + + services.AddSingleton>(ChannelAsyncQueue.From); + services.AddScoped>(SyncUpdatesProcessor.From); + services.AddHostedService(AsyncQueueHostedService.From); return services; } diff --git a/src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.Designer.cs b/src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.Designer.cs new file mode 100644 index 0000000..a7bcab2 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.Designer.cs @@ -0,0 +1,374 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226123411_AddItemIdToPlaidMetadata")] + partial class AddItemIdToPlaidMetadata + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId"); + + b.HasIndex("UserId"); + + b.ToTable("Account"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("AccountMetadata"); + + b.HasDiscriminator("Type").HasValue("AccountMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("Available") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Balance"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") + .WithMany("Accounts") + .HasForeignKey("InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Balances") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Navigation("Balances"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Accounts"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("Institutions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.cs b/src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.cs new file mode 100644 index 0000000..d9ef964 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260226123411_AddItemIdToPlaidMetadata.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class AddItemIdToPlaidMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ItemId", + table: "InstitutionMetadata", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ItemId", + table: "InstitutionMetadata"); + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.Designer.cs b/src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.Designer.cs new file mode 100644 index 0000000..7e8d43b --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.Designer.cs @@ -0,0 +1,376 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226131150_AddNavigationPropertyToInstitutionForUser")] + partial class AddNavigationPropertyToInstitutionForUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId"); + + b.HasIndex("UserId"); + + b.ToTable("Account"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("AccountMetadata"); + + b.HasDiscriminator("Type").HasValue("AccountMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("Available") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Balance"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institutions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") + .WithMany("Accounts") + .HasForeignKey("InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Balances") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Navigation("Balances"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Accounts"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("Institutions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.cs b/src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.cs new file mode 100644 index 0000000..8af6714 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260226131150_AddNavigationPropertyToInstitutionForUser.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class AddNavigationPropertyToInstitutionForUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Account_Institution_InstitutionId", + table: "Account"); + + migrationBuilder.DropForeignKey( + name: "FK_Institution_Users_UserId", + table: "Institution"); + + migrationBuilder.DropForeignKey( + name: "FK_InstitutionMetadata_Institution_InstitutionId", + table: "InstitutionMetadata"); + + migrationBuilder.DropPrimaryKey( + name: "PK_Institution", + table: "Institution"); + + migrationBuilder.RenameTable( + name: "Institution", + newName: "Institutions"); + + migrationBuilder.RenameIndex( + name: "IX_Institution_UserId", + table: "Institutions", + newName: "IX_Institutions_UserId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Institutions", + table: "Institutions", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Account_Institutions_InstitutionId", + table: "Account", + column: "InstitutionId", + principalTable: "Institutions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_InstitutionMetadata_Institutions_InstitutionId", + table: "InstitutionMetadata", + column: "InstitutionId", + principalTable: "Institutions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Institutions_Users_UserId", + table: "Institutions", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Account_Institutions_InstitutionId", + table: "Account"); + + migrationBuilder.DropForeignKey( + name: "FK_InstitutionMetadata_Institutions_InstitutionId", + table: "InstitutionMetadata"); + + migrationBuilder.DropForeignKey( + name: "FK_Institutions_Users_UserId", + table: "Institutions"); + + migrationBuilder.DropPrimaryKey( + name: "PK_Institutions", + table: "Institutions"); + + migrationBuilder.RenameTable( + name: "Institutions", + newName: "Institution"); + + migrationBuilder.RenameIndex( + name: "IX_Institutions_UserId", + table: "Institution", + newName: "IX_Institution_UserId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Institution", + table: "Institution", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Account_Institution_InstitutionId", + table: "Account", + column: "InstitutionId", + principalTable: "Institution", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Institution_Users_UserId", + table: "Institution", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_InstitutionMetadata_Institution_InstitutionId", + table: "InstitutionMetadata", + column: "InstitutionId", + principalTable: "Institution", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.Designer.cs b/src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.Designer.cs new file mode 100644 index 0000000..7fd7394 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.Designer.cs @@ -0,0 +1,376 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226224648_RenamePlaidMetadataEntity")] + partial class RenamePlaidMetadataEntity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId"); + + b.HasIndex("UserId"); + + b.ToTable("Account"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("AccountMetadata"); + + b.HasDiscriminator("Type").HasValue("AccountMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("Available") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Balance"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institutions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") + .WithMany("Accounts") + .HasForeignKey("InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Balances") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Navigation("Balances"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Accounts"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("Institutions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.cs b/src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.cs new file mode 100644 index 0000000..ba856bb --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260226224648_RenamePlaidMetadataEntity.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class RenamePlaidMetadataEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.Designer.cs b/src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.Designer.cs new file mode 100644 index 0000000..8c3c403 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.Designer.cs @@ -0,0 +1,504 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260228123108_AddTransactionAndTransactionMetadataModels")] + partial class AddTransactionAndTransactionMetadataModels + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId"); + + b.HasIndex("UserId"); + + b.ToTable("Account"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("AccountMetadata"); + + b.HasDiscriminator("Type").HasValue("AccountMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("Available") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Balance"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institutions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("AccountId1") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("AccountId1"); + + b.HasIndex("UserId"); + + b.ToTable("Transaction"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TransactionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.ToTable("TransactionMetadata"); + + b.HasDiscriminator("Type").HasValue("Plaid"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") + .WithMany("Accounts") + .HasForeignKey("InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Balances") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Transactions") + .HasForeignKey("AccountId1"); + + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.HasOne("FiscalOS.Core.Transactions.Transaction", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Transactions.TransactionMetadata", "TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Navigation("Balances"); + + b.Navigation("Metadata"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Accounts"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("Institutions"); + + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Navigation("Metadata"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.cs b/src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.cs new file mode 100644 index 0000000..331dcd8 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260228123108_AddTransactionAndTransactionMetadataModels.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class AddTransactionAndTransactionMetadataModels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Transaction", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + AccountId = table.Column(type: "TEXT", nullable: false), + MerchantName = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + AccountId1 = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Transaction", x => x.Id); + table.ForeignKey( + name: "FK_Transaction_Account_AccountId", + column: x => x.AccountId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Transaction_Account_AccountId1", + column: x => x.AccountId1, + principalTable: "Account", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Transaction_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TransactionMetadata", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", maxLength: 5, nullable: false), + TransactionId = table.Column(type: "TEXT", nullable: false), + PlaidId = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionMetadata", x => x.Id); + table.ForeignKey( + name: "FK_TransactionMetadata_Transaction_TransactionId", + column: x => x.TransactionId, + principalTable: "Transaction", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Transaction_AccountId", + table: "Transaction", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Transaction_AccountId1", + table: "Transaction", + column: "AccountId1"); + + migrationBuilder.CreateIndex( + name: "IX_Transaction_UserId", + table: "Transaction", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionMetadata_TransactionId", + table: "TransactionMetadata", + column: "TransactionId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TransactionMetadata"); + + migrationBuilder.DropTable( + name: "Transaction"); + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.Designer.cs b/src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.Designer.cs new file mode 100644 index 0000000..6a56bbe --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.Designer.cs @@ -0,0 +1,508 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260301104650_AddCursorToAccountModel")] + partial class AddCursorToAccountModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId"); + + b.HasIndex("UserId"); + + b.ToTable("Account"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("AccountMetadata"); + + b.HasDiscriminator("Type").HasValue("AccountMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("Available") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Balance"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institutions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("AccountId1") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("AccountId1"); + + b.HasIndex("UserId"); + + b.ToTable("Transaction"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TransactionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.ToTable("TransactionMetadata"); + + b.HasDiscriminator("Type").HasValue("Plaid"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + + b.Property("Cursor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") + .WithMany("Accounts") + .HasForeignKey("InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Balances") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Transactions") + .HasForeignKey("AccountId1"); + + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.HasOne("FiscalOS.Core.Transactions.Transaction", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Transactions.TransactionMetadata", "TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => + { + b.Navigation("Balances"); + + b.Navigation("Metadata"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Accounts"); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("Institutions"); + + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Navigation("Metadata"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.cs b/src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.cs new file mode 100644 index 0000000..ba5b52c --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260301104650_AddCursorToAccountModel.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class AddCursorToAccountModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Cursor", + table: "AccountMetadata", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Cursor", + table: "AccountMetadata"); + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs b/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs index db42c10..ab97ae3 100644 --- a/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs +++ b/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs @@ -135,7 +135,7 @@ namespace FiscalOS.Infra.Migrations b.HasIndex("UserId"); - b.ToTable("Institution"); + b.ToTable("Institutions"); }); modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => @@ -241,10 +241,92 @@ namespace FiscalOS.Infra.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("AccountId1") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("AccountId1"); + + b.HasIndex("UserId"); + + b.ToTable("Transaction"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TransactionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.ToTable("TransactionMetadata"); + + b.HasDiscriminator("Type").HasValue("Plaid"); + + b.UseTphMappingStrategy(); + }); + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => { b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + b.Property("Cursor") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("PlaidId") .IsRequired() .HasColumnType("TEXT"); @@ -256,7 +338,7 @@ namespace FiscalOS.Infra.Migrations b.HasDiscriminator().HasValue("Plaid"); }); - modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b => + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b => { b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); @@ -264,6 +346,10 @@ namespace FiscalOS.Infra.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("PlaidId") .IsRequired() .HasColumnType("TEXT"); @@ -275,10 +361,21 @@ namespace FiscalOS.Infra.Migrations b.HasDiscriminator().HasValue("Plaid"); }); + modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => { b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") - .WithMany() + .WithMany("Accounts") .HasForeignKey("InstitutionId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -312,11 +409,13 @@ namespace FiscalOS.Infra.Migrations modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => { - b.HasOne("FiscalOS.Core.Identity.User", null) + b.HasOne("FiscalOS.Core.Identity.User", "User") .WithMany("Institutions") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.Navigation("User"); }); modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => @@ -339,15 +438,51 @@ namespace FiscalOS.Infra.Migrations b.Navigation("User"); }); + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.HasOne("FiscalOS.Core.Accounts.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FiscalOS.Core.Accounts.Account", null) + .WithMany("Transactions") + .HasForeignKey("AccountId1"); + + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.HasOne("FiscalOS.Core.Transactions.Transaction", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Transactions.TransactionMetadata", "TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => { b.Navigation("Balances"); b.Navigation("Metadata"); + + b.Navigation("Transactions"); }); modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => { + b.Navigation("Accounts"); + b.Navigation("Metadata"); }); @@ -359,6 +494,11 @@ namespace FiscalOS.Infra.Migrations b.Navigation("RefreshTokens"); }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Navigation("Metadata"); + }); #pragma warning restore 612, 618 } } diff --git a/src/FiscalOS.Infra/Queuing/AsyncQueueHostedService.cs b/src/FiscalOS.Infra/Queuing/AsyncQueueHostedService.cs new file mode 100644 index 0000000..a15c8f8 --- /dev/null +++ b/src/FiscalOS.Infra/Queuing/AsyncQueueHostedService.cs @@ -0,0 +1,55 @@ +namespace FiscalOS.Infra.Queuing; + +internal class AsyncQueueHostedService : BackgroundService where T : notnull +{ + private readonly IAsyncQueue _queue; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger> _logger; + + private AsyncQueueHostedService( + IAsyncQueue queue, + IServiceProvider serviceProvider, + ILogger> logger + ) + { + _queue = queue; + _serviceProvider = serviceProvider; + _logger = logger; + } + + public static AsyncQueueHostedService From(IServiceProvider serviceProvider) + { + var queue = serviceProvider.GetRequiredService>(); + var logger = serviceProvider.GetRequiredService>>(); + return new(queue, serviceProvider, logger); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Queue hosted service for {ItemType} starting", typeof(T).Name); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + var item = await _queue.DequeueAsync(stoppingToken).ConfigureAwait(false); + + using var scope = _serviceProvider.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService>(); + + try + { + await processor.ProcessAsync(item, stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing queue item of type {ItemType}", typeof(T).Name); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Queue hosted service for {ItemType} stopped", typeof(T).Name); + } + } +} diff --git a/src/FiscalOS.Infra/Queuing/ChannelAsyncQueue.cs b/src/FiscalOS.Infra/Queuing/ChannelAsyncQueue.cs new file mode 100644 index 0000000..da2d583 --- /dev/null +++ b/src/FiscalOS.Infra/Queuing/ChannelAsyncQueue.cs @@ -0,0 +1,31 @@ +namespace FiscalOS.Infra.Queuing; + +internal class ChannelAsyncQueue : IAsyncQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded(); + + private ChannelAsyncQueue() + { + } + + public static ChannelAsyncQueue From(IServiceProvider serviceProvider) + { + return new(); + } + + public async Task EnqueueAsync(T item, CancellationToken cancellationToken = default) + { + await _channel.Writer.WriteAsync(item, cancellationToken).ConfigureAwait(false); + } + + public async Task DequeueAsync(CancellationToken cancellationToken = default) + { + return await _channel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + _channel.Writer.Complete(); + await _channel.Reader.Completion.ConfigureAwait(false); + } +} diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs new file mode 100644 index 0000000..4426093 --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs @@ -0,0 +1,54 @@ +using Account = FiscalOS.Core.Accounts.Account; +using Transaction = FiscalOS.Core.Transactions.Transaction; + +namespace FiscalOS.Infra.Transactions.Plaid; + +internal interface IPlaidAddedTransactionHandler +{ + void Handle(Account account, IEnumerable added); +} + +internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandler +{ + private readonly ILogger _logger; + + private PlaidAddedTransactionHandler(ILogger logger) + { + _logger = logger; + } + + public static PlaidAddedTransactionHandler From(IServiceProvider serviceProvider) + { + return new( + serviceProvider.GetRequiredService>() + ); + } + + public void Handle(Account account, IEnumerable added) + { + foreach (var addedTransaction in added) + { + if (addedTransaction.Pending.GetValueOrDefault()) + { + _logger.LogInformation( + "Skipping pending transaction {TransactionId} for account {AccountId} as it has not been posted yet", + addedTransaction.TransactionId, + account.Id + ); + continue; + } + + var transactionMetadata = PlaidTransactionMetadata.From(addedTransaction.TransactionId); + var transaction = Transaction.From( + account.UserId, + account.Id, + addedTransaction.MerchantName, + addedTransaction.OriginalDescription, + addedTransaction.Amount, + addedTransaction.Datetime, + transactionMetadata + ); + account.AddTransaction(transaction); + } + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs new file mode 100644 index 0000000..9a9a139 --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs @@ -0,0 +1,75 @@ +using Account = FiscalOS.Core.Accounts.Account; +using Transaction = FiscalOS.Core.Transactions.Transaction; + +namespace FiscalOS.Infra.Transactions.Plaid; + +internal interface IPlaidModifiedTransactionHandler +{ + void Handle(Account account, IEnumerable modified); +} + +internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactionHandler +{ + private readonly ILogger _logger; + private readonly AppDbContext _appDbContext; + + private PlaidModifiedTransactionHandler( + ILogger logger, + AppDbContext appDbContext + ) + { + _logger = logger; + _appDbContext = appDbContext; + } + + public static PlaidModifiedTransactionHandler From(IServiceProvider serviceProvider) + { + return new( + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService() + ); + } + + public void Handle(Account account, IEnumerable modified) + { + var modifiedTransactionIds = modified.Select(t => t.TransactionId); + var existingModifiedTransactions = _appDbContext.Transactions + .Where(t => t.Metadata is PlaidTransactionMetadata && modifiedTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId)) + .ToList(); + + foreach (var existing in existingModifiedTransactions) + { + if (existing.Metadata is not PlaidTransactionMetadata plaidMetadata) + { + _logger.LogWarning( + "Existing transaction {TransactionId} has non-Plaid metadata. Skipping update for this transaction.", + existing.Id + ); + continue; + } + + var plaidModifiedTransaction = modified.FirstOrDefault(t => t.TransactionId == plaidMetadata.PlaidId); + + if (plaidModifiedTransaction is null) + { + _logger.LogWarning( + "No corresponding modified transaction found in Plaid response for existing transaction {TransactionId}. Skipping update for this transaction.", + existing.Id + ); + continue; + } + + var newTransactionData = Transaction.From( + existing.UserId, + existing.AccountId, + plaidModifiedTransaction.MerchantName, + plaidModifiedTransaction.OriginalDescription, + plaidModifiedTransaction.Amount, + plaidModifiedTransaction.Datetime, + plaidMetadata + ); + + _appDbContext.Entry(existing).CurrentValues.SetValues(newTransactionData); + } + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidRemovedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidRemovedTransactionHandler.cs new file mode 100644 index 0000000..cf04d4c --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidRemovedTransactionHandler.cs @@ -0,0 +1,33 @@ +namespace FiscalOS.Infra.Transactions.Plaid; + +internal interface IPlaidRemovedTransactionHandler +{ + Task HandleAsync(IEnumerable removed, CancellationToken cancellationToken); +} + +internal sealed class PlaidRemovedTransactionHandler : IPlaidRemovedTransactionHandler +{ + private readonly AppDbContext _appDbContext; + + private PlaidRemovedTransactionHandler(AppDbContext appDbContext) + { + _appDbContext = appDbContext; + } + + public static PlaidRemovedTransactionHandler From(IServiceProvider serviceProvider) + { + return new( + serviceProvider.GetRequiredService() + ); + } + + public async Task HandleAsync(IEnumerable removed, CancellationToken cancellationToken) + { + var removedIdsList = removed.Select(t => t.TransactionId); + + await _appDbContext.Transactions + .Where(t => t.Metadata is PlaidTransactionMetadata && removedIdsList.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionMetadata.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionMetadata.cs new file mode 100644 index 0000000..a39730d --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionMetadata.cs @@ -0,0 +1,24 @@ +using FiscalOS.Core.Transactions; +using FiscalOS.Infra.Common; + +namespace FiscalOS.Infra.Transactions.Plaid; + +public sealed class PlaidTransactionMetadata : TransactionMetadata +{ + public const string TypeValue = Providers.Plaid; + public string PlaidId { get; init; } = string.Empty; + + private PlaidTransactionMetadata() : base(TypeValue) + { + } + + public static PlaidTransactionMetadata From(string? plaidId) + { + ArgumentNullException.ThrowIfNull(plaidId); + + return new() + { + PlaidId = plaidId, + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs new file mode 100644 index 0000000..7c1074d --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionProcessor.cs @@ -0,0 +1,46 @@ +using Account = FiscalOS.Core.Accounts.Account; + +namespace FiscalOS.Infra.Transactions.Plaid; + +internal interface IPlaidTransactionProcessor +{ + Task ProcessAsync( + Account account, + TransactionsSyncResponse syncResponse, + CancellationToken cancellationToken + ); +} + +internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor +{ + private readonly IPlaidAddedTransactionHandler _addedHandler; + private readonly IPlaidModifiedTransactionHandler _modifiedHandler; + private readonly IPlaidRemovedTransactionHandler _removedHandler; + + private PlaidTransactionProcessor( + IPlaidAddedTransactionHandler addedHandler, + IPlaidModifiedTransactionHandler modifiedHandler, + IPlaidRemovedTransactionHandler removedHandler + ) + { + _addedHandler = addedHandler; + _modifiedHandler = modifiedHandler; + _removedHandler = removedHandler; + } + + internal static PlaidTransactionProcessor From(IServiceProvider provider) + { + return new( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService() + ); + } + + public async Task ProcessAsync(Account account, TransactionsSyncResponse syncResponse, CancellationToken cancellationToken) + { + _addedHandler.Handle(account, syncResponse.Added); + _modifiedHandler.Handle(account, syncResponse.Modified); + await _removedHandler.HandleAsync(syncResponse.Removed, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionService.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionService.cs new file mode 100644 index 0000000..683ec1a --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionService.cs @@ -0,0 +1,58 @@ +namespace FiscalOS.Infra.Transactions.Plaid; + +internal interface IPlaidTransactionService +{ + IAsyncEnumerable SyncTransactionsForAccountAsync( + string accessToken, + string accountId, + string? initialCursor = null + ); +} + +internal sealed class PlaidTransactionService : IPlaidTransactionService +{ + private readonly PlaidClient _client; + + private PlaidTransactionService(PlaidClient client) + { + _client = client; + } + + public static PlaidTransactionService From(IServiceProvider sp) + { + var client = sp.GetRequiredService(); + return new(client); + } + + public async IAsyncEnumerable SyncTransactionsForAccountAsync( + string accessToken, + string accountId, + string? initialCursor = null + ) + { + TransactionsSyncResponse? response; + var cursor = initialCursor; + + do + { + response = await _client.TransactionsSyncAsync(new() + { + AccessToken = accessToken, + Cursor = cursor, + Count = 500, + Options = new() + { + AccountId = accountId, + } + }).ConfigureAwait(false); + + if (response.Error?.ErrorCode is "TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION") + { + continue; + } + + cursor = response.NextCursor; + yield return response; + } while (response is not null && response.HasMore); + } +} diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs new file mode 100644 index 0000000..dfa8ac4 --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidTransactionSyncer.cs @@ -0,0 +1,93 @@ +using Account = FiscalOS.Core.Accounts.Account; + +namespace FiscalOS.Infra.Transactions.Plaid; + +internal interface IPlaidTransactionSyncer +{ + Task SyncTransactionsForAccountAsync( + Account account, + string decryptedAccessToken, + CancellationToken cancellationToken + ); +} + +internal sealed class PlaidTransactionSyncer : IPlaidTransactionSyncer +{ + private readonly ILogger _logger; + private readonly IPlaidTransactionService _plaidTransactionService; + private readonly IPlaidTransactionProcessor _transactionProcessor; + + private PlaidTransactionSyncer( + ILogger logger, + IPlaidTransactionService plaidTransactionService, + IPlaidTransactionProcessor plaidTransactionProcessor + ) + { + _logger = logger; + _plaidTransactionService = plaidTransactionService; + _transactionProcessor = plaidTransactionProcessor; + } + + public static PlaidTransactionSyncer From(IServiceProvider serviceProvider) + { + return new( + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService() + ); + } + + public async Task SyncTransactionsForAccountAsync(Account account, string decryptedAccessToken, CancellationToken cancellationToken) + { + if (account.Metadata is not PlaidAccountMetadata plaidAccountMetadata) + { + _logger.LogWarning("Unable to sync transactions for account {AccountId} as it has no Plaid metadata", account.Id); + return; + } + + var responses = _plaidTransactionService.SyncTransactionsForAccountAsync( + decryptedAccessToken, + plaidAccountMetadata.PlaidId, + plaidAccountMetadata.Cursor + ).ConfigureAwait(false); + + await foreach (var response in responses) + { + if (response.IsSuccessStatusCode is false) + { + _logger.LogWarning( + "Failed to sync transactions for request {RequestId} for account {AccountId}: {ErrorMessage}", + response.RequestId, + account.Id, + response.Error?.ErrorMessage + ); + break; + } + + if (response.Accounts.Any() is false) + { + _logger.LogInformation("No transactions to sync for request {RequestId} for account {AccountId}", response.RequestId, account.Id); + break; + } + + var plaidAccount = response.Accounts + .FirstOrDefault(pa => pa.AccountId == plaidAccountMetadata.PlaidId); + + if (plaidAccount is null) + { + _logger.LogWarning( + "Failed to sync transactions for request {RequestId} for account {AccountId}: No corresponding account received in Plaid response", + response.RequestId, + account.Id + ); + break; + } + + plaidAccountMetadata.SetCursor(response.NextCursor); + var balance = Balance.From(plaidAccount.Current, plaidAccount.Available, plaidAccount.CurrencyCode); + account.AddBalance(balance); + + await _transactionProcessor.ProcessAsync(account, response, cancellationToken).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesProcessor.cs b/src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesProcessor.cs new file mode 100644 index 0000000..e0fb44c --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesProcessor.cs @@ -0,0 +1,93 @@ +namespace FiscalOS.Infra.Transactions.Plaid; + +internal sealed class SyncUpdatesProcessor : IAsyncQueueProcessor +{ + private readonly ILogger _logger; + private readonly AppDbContext _appDbContext; + private readonly IEncryptor _encryptor; + private readonly IPlaidTransactionSyncer _plaidTransactionSyncer; + + private SyncUpdatesProcessor( + ILogger logger, + AppDbContext appDbContext, + IEncryptor encryptor, + IPlaidTransactionSyncer plaidTransactionSyncer + ) + { + _logger = logger; + _appDbContext = appDbContext; + _encryptor = encryptor; + _plaidTransactionSyncer = plaidTransactionSyncer; + } + + public static SyncUpdatesProcessor From(IServiceProvider serviceProvider) + { + return new( + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService() + ); + } + + public async Task ProcessAsync(SyncUpdatesQueueItem item, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Processing sync updates for item {ItemId}", item.InstitutionItemId); + + var institution = await _appDbContext.Institutions + .Include(i => i.User) + .Include(i => i.Accounts) + .ThenInclude(a => a.Metadata) + .Include(i => i.Metadata) + .Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).ItemId == item.InstitutionItemId) + .AsSplitQuery() + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (institution is null) + { + _logger.LogWarning( + "Unable to process sync updates for item {ItemId} as no institution with matching Plaid item ID was found.", + item.InstitutionItemId + ); + return; + } + + if (institution.User is null) + { + _logger.LogWarning( + "Unable to process sync updates for item {ItemId} as the associated institution {InstitutionId} does not have a user.", + item.InstitutionItemId, + institution.Id + ); + return; + } + + if (institution.Metadata is not PlaidInstitutionMetadata plaidMetadata) + { + _logger.LogWarning( + "Unable to process sync updates for item {ItemId} as the associated institution {InstitutionId} does not have Plaid metadata.", + item.InstitutionItemId, + institution.Id + ); + return; + } + + var decryptedAccessToken = await _encryptor.DecryptAsyncFor( + institution.User!, + plaidMetadata.EncryptedAccessToken, + cancellationToken + ) + .ConfigureAwait(false); + + foreach (var account in institution.Accounts) + { + await _plaidTransactionSyncer.SyncTransactionsForAccountAsync(account, decryptedAccessToken, cancellationToken) + .ConfigureAwait(false); + } + + await _appDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Successfully processed sync updates for item {ItemId}", item.InstitutionItemId); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesQueueItem.cs b/src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesQueueItem.cs new file mode 100644 index 0000000..f218af2 --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/SyncUpdatesQueueItem.cs @@ -0,0 +1,3 @@ +namespace FiscalOS.Infra.Transactions.Plaid; + +public sealed record SyncUpdatesQueueItem(string InstitutionItemId); \ No newline at end of file diff --git a/src/FiscalOS.Infra/Usings.cs b/src/FiscalOS.Infra/Usings.cs index f939a62..3d60f10 100644 --- a/src/FiscalOS.Infra/Usings.cs +++ b/src/FiscalOS.Infra/Usings.cs @@ -1,23 +1,28 @@ global using System.IdentityModel.Tokens.Jwt; global using System.IO.Abstractions; +global using System.Reflection; global using System.Security.Claims; global using System.Security.Cryptography; global using System.Text; global using System.Text.Json; +global using System.Threading.Channels; global using FiscalOS.Core.Accounts; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Data; global using FiscalOS.Core.Identity; +global using FiscalOS.Core.Queuing; global using FiscalOS.Core.Security; global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authorization; +global using FiscalOS.Infra.Common; global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Security; global using Going.Plaid; global using Going.Plaid.Entity; +global using Going.Plaid.Transactions; global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Authorization; diff --git a/tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs b/tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs index 96a89f4..9eeb45e 100644 --- a/tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs @@ -172,7 +172,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) await context.AddAsync(user, ct); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); - var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); + var plaidMetadata = PlaidInstitutionMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); var institution = Institution.From("Some Bank", plaidMetadata); await context.AddAsync(institution, ct); @@ -192,7 +192,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) .WithUserId(user.Id) .WithBody(new { - plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, + plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId, plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId, plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName, accountCurrencyCode = "USD", @@ -217,7 +217,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); - var plaidMetadata = PlaidMetadata.From("id", "Some Bank", encryptedAccessToken); + var plaidMetadata = PlaidInstitutionMetadata.From("id", "Some Bank", encryptedAccessToken); var institution = Institution.From("Some Bank", plaidMetadata); user.AddInstitution(institution); @@ -237,7 +237,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) .WithUserId(user.Id) .WithBody(new { - plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, + plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId, plaidAccountId = newAccountId, plaidAccountName = newAccountName, accountCurrentBalance = expectedBalance, diff --git a/tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs index 55f71e4..765e256 100644 --- a/tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs @@ -101,7 +101,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); - var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); + var plaidMetadata = PlaidInstitutionMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); var institution = Institution.From("Some Bank", plaidMetadata); user.AddInstitution(institution); @@ -117,7 +117,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) .WithBody(new { publicToken = "token", - plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, + plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId, }) .Build(); @@ -179,7 +179,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) var institution = updatedUser.Institutions.First(); institution.Name.Should().NotBeNullOrEmpty(); - var metadata = institution.Metadata.As(); + var metadata = institution.Metadata.As(); metadata.PlaidId.Should().Be(plaidInstitutionId); metadata.PlaidName.Should().Be(institution.Name); metadata.EncryptedAccessToken.Should().NotBeNullOrEmpty(); diff --git a/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs index f017f6a..083fccc 100644 --- a/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs @@ -92,7 +92,7 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi) AccessToken = exchangeTokenResponse.AccessToken, }); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, exchangeTokenResponse.AccessToken, ct); - var institutionMetadata = PlaidMetadata.From(institutionId, "Some Bank", encryptedAccessToken); + var institutionMetadata = PlaidInstitutionMetadata.From(institutionId, "Some Bank", encryptedAccessToken); var institution = Institution.From("Some Bank", institutionMetadata); user.AddInstitution(institution); diff --git a/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs index 4b8017d..e277341 100644 --- a/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/GetTests.cs @@ -45,7 +45,7 @@ public class GetTests(TestApi testApi) : IntegrationTest(testApi) var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); - var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); + var plaidMetadata = PlaidInstitutionMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); var institution = Institution.From("Some Bank", plaidMetadata); user.AddInstitution(institution);