From a9f1ced0d59f3533e794f15e486fd4ca39bc20aa 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