Compare commits

...
Author SHA1 Message Date
Stevan Freeborn 4b030651b3 chore: add coverage directory to .gitignore 2026-03-08 16:22:39 -05:00
Stevan Freeborn d293c0d75a refactor: remove unused using directive for Going.Plaid.Transactions in PlaidTransactionProcessorTests 2026-03-08 16:16:07 -05:00
Stevan Freeborn 1c0fdc205e tests(infra.tests): enhance AccountBuilder with metadata handling and expand PlaidTransactionSyncerTests 2026-03-08 16:14:51 -05:00
Stevan Freeborn 1a4f7fac8d tests(infra,api): refactor to allow further config of TestApi 2026-03-08 09:15:45 -05:00
Stevan Freeborn 870baf7b6c tests(infra.tests): added builder classes for creating test entities 2026-03-08 07:18:27 -05:00
Stevan Freeborn 27d2ccbd45 tests(infra.tests): wip on builder apis for test data 2026-03-06 07:14:57 -06:00
Stevan Freeborn 66f4046d2e feat: implement transaction handling and add integration tests for Plaid transaction handlers 2026-03-05 13:58:00 -06:00
Stevan Freeborn 0f0ed561c3 feat: update account handling in add endpoint and tests 2026-03-03 07:40:19 -06:00
Stevan Freeborn 74793b5800 feat: continued wip on supporting transactions 2026-03-03 07:36:09 -06:00
Stevan Freeborn 562a708c74 fix(infra): don't advance account cursor unless all transactions proceed successfully 2026-03-03 05:09:03 -06:00
Stevan Freeborn 19a4520a7b refactor(core): make parameters non-nullable 2026-03-03 05:08:35 -06:00
Stevan Freeborn 33f74ee018 tests(api.tests): fix methods broken in refactor 2026-03-03 05:08:12 -06:00
Stevan Freeborn 2bc9cab43d feat: rename transaction table to transactions and update related references 2026-03-01 20:35:33 -06:00
Stevan Freeborn b9d0869ae3 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.
2026-03-01 19:56:29 -06:00
102 changed files with 6823 additions and 162 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_accessors = true:silent csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:suggestion csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_style_expression_bodied_local_functions = false:silent csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_operators = false:silent
+3
View File
@@ -3,6 +3,9 @@
## ##
## Get latest from `dotnet new gitignore` ## Get latest from `dotnet new gitignore`
# coverage
coverage/
# appsettings # appsettings
appsettings*.json appsettings*.json
!appsettings.Example.json !appsettings.Example.json
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "aspire",
"request": "launch",
"name": "Aspire: Launch default apphost",
"program": "${workspaceFolder}"
}
]
}
+6 -1
View File
@@ -10,5 +10,10 @@
"source.fixAll": "explicit" "source.fixAll": "explicit"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["Encryptor"],
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
}
} }
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
dotnet_code_quality.ca1711.allowed_suffixes = Queue
dotnet_diagnostic.CA1031.severity = none
+1 -1
View File
@@ -3,7 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.1.1" /> <PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.1.2" />
<PackageVersion Include="Going.Plaid" Version="6.56.0" /> <PackageVersion Include="Going.Plaid" Version="6.56.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2"> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
+22 -5
View File
@@ -1,3 +1,5 @@
using Account = FiscalOS.Core.Accounts.Account;
namespace FiscalOS.API.Accounts.Add; namespace FiscalOS.API.Accounts.Add;
internal static class Endpoint internal static class Endpoint
@@ -13,15 +15,20 @@ internal static class Endpoint
HttpContext httpContext, HttpContext httpContext,
[FromBody] Request request, [FromBody] Request request,
[FromServices] AppDbContext appDbContext, [FromServices] AppDbContext appDbContext,
[FromServices] IEncryptor encryptor,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> queue,
CancellationToken ct CancellationToken ct
) )
{ {
var userId = httpContext.GetUserId(); var userId = httpContext.GetUserId();
var user = await appDbContext.Users 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)) .Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
.ThenInclude(a => a.Metadata) .ThenInclude(a => a.Metadata)
.AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == userId, ct); .SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null) if (user is null)
@@ -37,19 +44,29 @@ internal static class Endpoint
}); });
} }
var plaidInstitutionMetadata = (PlaidInstitutionMetadata?)user.Institutions.First().Metadata;
if (plaidInstitutionMetadata is null)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"],
});
}
if (user.Accounts.Any()) if (user.Accounts.Any())
{ {
return Results.Conflict(); return Results.Conflict();
} }
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName); var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
var account = Account.From(user.Institutions.First().Id, request.PlaidAccountName, accountMetadata); var account = Account.From(request.PlaidAccountName, accountMetadata);
var balance = Balance.From(request.AccountCurrentBalance, request.AccountAvailableBalance, request.AccountCurrencyCode);
account.AddBalance(balance);
user.AddAccount(account); user.AddAccount(account);
user.Institutions.First().AddAccount(account);
await appDbContext.SaveChangesAsync(ct); await appDbContext.SaveChangesAsync(ct);
await queue.EnqueueAsync(new(plaidInstitutionMetadata.ItemId), ct);
return Results.Ok(); return Results.Ok();
} }
-9
View File
@@ -5,9 +5,6 @@ public record Request : IValidatableObject
public string PlaidInstitutionId { get; init; } = string.Empty; public string PlaidInstitutionId { get; init; } = string.Empty;
public string PlaidAccountId { get; init; } = string.Empty; public string PlaidAccountId { get; init; } = string.Empty;
public string PlaidAccountName { 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<ValidationResult> Validate(ValidationContext validationContext) public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {
@@ -28,11 +25,5 @@ public record Request : IValidatableObject
var fieldName = nameof(PlaidAccountName); var fieldName = nameof(PlaidAccountName);
yield return new($"The {fieldName} field is required.", [fieldName]); 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]);
}
} }
} }
@@ -1,3 +1,5 @@
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Institutions.Connect; namespace FiscalOS.API.Institutions.Connect;
internal static class Endpoint internal static class Endpoint
@@ -13,7 +15,7 @@ internal static class Endpoint
HttpContext httpContext, HttpContext httpContext,
[FromBody] Request request, [FromBody] Request request,
[FromServices] AppDbContext appDbContext, [FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService, [FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IEncryptor encryptor, [FromServices] IEncryptor encryptor,
CancellationToken ct CancellationToken ct
) )
@@ -23,8 +25,8 @@ internal static class Endpoint
var user = await appDbContext.Users var user = await appDbContext.Users
.Include(u => u.Institutions .Include(u => u.Institutions
.Where( .Where(
i => i.Metadata is PlaidMetadata && i => i.Metadata is PlaidInstitutionMetadata &&
((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId
) )
) )
.ThenInclude(i => i.Metadata) .ThenInclude(i => i.Metadata)
@@ -40,12 +42,11 @@ internal static class Endpoint
return Results.Conflict(); return Results.Conflict();
} }
var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken); var (itemId, accessToken) = await plaidAccountService.ExchangeTokenAsync(request.PublicToken);
var item = await plaidService.GetItemAsync(accessToken); var item = await plaidAccountService.GetItemAsync(accessToken);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct); 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); var institution = Institution.From(item.InstitutionName, plaidMetadata);
user.AddInstitution(institution); user.AddInstitution(institution);
@@ -1,3 +1,6 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Institutions.Get; namespace FiscalOS.API.Institutions.Get;
internal sealed record Response internal sealed record Response
@@ -13,7 +13,7 @@ internal static class Endpoint
HttpContext httpContext, HttpContext httpContext,
[FromRoute] Guid id, [FromRoute] Guid id,
[FromServices] AppDbContext appDbContext, [FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService, [FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IEncryptor encryptor, [FromServices] IEncryptor encryptor,
CancellationToken ct CancellationToken ct
) )
@@ -37,13 +37,13 @@ internal static class Endpoint
var institution = user.Institutions.First(); var institution = user.Institutions.First();
if (institution.Metadata is not PlaidMetadata plaidMetadata) if (institution.Metadata is not PlaidInstitutionMetadata plaidMetadata)
{ {
return Results.BadRequest(); return Results.BadRequest();
} }
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct); 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)); var accountsDtos = accounts.Select(a => AvailableAccountDto.From(plaidMetadata, a));
return Results.Ok(Response.From(accountsDtos)); return Results.Ok(Response.From(accountsDtos));
@@ -33,7 +33,7 @@ internal sealed record AvailableAccountDto
} }
public static AvailableAccountDto From( public static AvailableAccountDto From(
PlaidMetadata plaidMetadata, PlaidInstitutionMetadata plaidMetadata,
Going.Plaid.Entity.Account plaidAccount Going.Plaid.Entity.Account plaidAccount
) )
{ {
@@ -12,7 +12,7 @@ internal static class Endpoint
private static async Task<IResult> HandleAsync( private static async Task<IResult> HandleAsync(
HttpContext httpContext, HttpContext httpContext,
[FromServices] AppDbContext appDbContext, [FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService [FromServices] IPlaidAccountService plaidAccountService
) )
{ {
var userId = httpContext.GetUserId(); var userId = httpContext.GetUserId();
@@ -24,7 +24,7 @@ internal static class Endpoint
return Results.Unauthorized(); 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)); return Results.Ok(Response.From(linkToken));
} }
+7 -2
View File
@@ -1,5 +1,3 @@
using FiscalOS.ServiceDefaults;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); builder.AddServiceDefaults();
@@ -7,6 +5,12 @@ builder.AddServiceDefaults();
builder.Services.AddValidation(); builder.Services.AddValidation();
builder.Services.AddProblemDetails(); 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.AddInfrastructure();
builder.Services.AddAuthentication(static o => builder.Services.AddAuthentication(static o =>
@@ -41,5 +45,6 @@ app.MapDefaultEndpoints();
app.MapAuthEndpoints(); app.MapAuthEndpoints();
app.MapAccountsEndpoints(); app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints(); app.MapInstitutionsEndpoints();
app.MapTransactionsGroup();
app.Run(); app.Run();
@@ -0,0 +1,62 @@
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<IResult> 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 });
}
}
@@ -0,0 +1,7 @@
namespace FiscalOS.API.Transactions.FireWebhook;
public sealed record Request(
string AccountId,
string WebhookType,
string WebhookCode
);
@@ -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;
}
}
@@ -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<IResult> HandleAsync(
[FromBody] WebhookBase request,
[FromServices] ILogger<Program> logger,
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> 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();
}
}
+12 -2
View File
@@ -2,7 +2,6 @@ global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims; global using System.Security.Claims;
global using System.Text.Json.Serialization; global using System.Text.Json.Serialization;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Accounts; global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth; global using FiscalOS.API.Auth;
@@ -11,16 +10,27 @@ global using FiscalOS.API.Auth.Refresh;
global using FiscalOS.API.Http; global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions; global using FiscalOS.API.Institutions;
global using FiscalOS.API.Institutions.Connect; global using FiscalOS.API.Institutions.Connect;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Institutions.GetAvailable; global using FiscalOS.API.Institutions.GetAvailable;
global using FiscalOS.API.Institutions.Link; global using FiscalOS.API.Institutions.Link;
global using FiscalOS.Core.Accounts; global using FiscalOS.API.Transactions;
global using FiscalOS.API.Transactions.FireWebhook;
global using FiscalOS.API.Transactions.Webhook;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection; global using FiscalOS.Infra.DependencyInjection;
global using FiscalOS.Infra.Transactions.Plaid;
global using FiscalOS.ServiceDefaults;
global using Going.Plaid;
global using Going.Plaid.Converters;
global using Going.Plaid.Entity;
global using Going.Plaid.Webhook;
global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore;
+2 -1
View File
@@ -21,6 +21,7 @@
}, },
"PlaidClientOptions": { "PlaidClientOptions": {
"ClientId": "ClientId", "ClientId": "ClientId",
"Secret": "Secret" "Secret": "Secret",
"Webhook": "Webhook"
} }
} }
+46
View File
@@ -47,6 +47,52 @@ internal sealed class App(
var keyRingEntry = await keyRing.SaveKeyAsync(key); var keyRingEntry = await keyRing.SaveKeyAsync(key);
_console.MarkupLine($"Key generated with id [green]{keyRingEntry.KeyId}[/] generated successfully."); _console.MarkupLine($"Key generated with id [green]{keyRingEntry.KeyId}[/] generated successfully.");
break; break;
case Commands.RemoveInstitution:
var institutionId = await _console.AskAsync<string>("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<PlaidClient>();
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: case Commands.Exit:
default: default:
break; break;
+2
View File
@@ -4,11 +4,13 @@ internal static class Commands
{ {
public const string CreateUser = "Create User"; public const string CreateUser = "Create User";
public const string GenerateKey = "Generate Key"; public const string GenerateKey = "Generate Key";
public const string RemoveInstitution = "Remove Institution";
public const string Exit = "Exit"; public const string Exit = "Exit";
public static readonly string[] All = [ public static readonly string[] All = [
CreateUser, CreateUser,
GenerateKey, GenerateKey,
RemoveInstitution,
Exit Exit
]; ];
} }
+4
View File
@@ -2,9 +2,13 @@ global using FiscalOS.AdminCLI;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection; global using FiscalOS.Infra.DependencyInjection;
global using Going.Plaid;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Hosting;
+1 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.1.1"> <Project Sdk="Aspire.AppHost.Sdk/13.1.2">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" /> <ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" />
+19 -3
View File
@@ -11,11 +11,20 @@ public sealed class Account : Entity
private readonly List<Balance> _balances = []; private readonly List<Balance> _balances = [];
public IEnumerable<Balance> Balances => _balances; public IEnumerable<Balance> Balances => _balances;
public static Account From(Guid institutionId, string name, AccountMetadata accountMetadata) private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
private Account()
{
}
public static Account From(
string name,
AccountMetadata accountMetadata
)
{ {
return new() return new()
{ {
InstitutionId = institutionId,
Name = name, Name = name,
Metadata = accountMetadata, Metadata = accountMetadata,
}; };
@@ -23,8 +32,15 @@ public sealed class Account : Entity
public void AddBalance(Balance balance) public void AddBalance(Balance balance)
{ {
ArgumentNullException.ThrowIfNull(balance); ArgumentNullException.ThrowIfNull(balance, nameof(balance));
_balances.Add(balance); _balances.Add(balance);
} }
public void AddTransaction(Transaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction, nameof(transaction));
_transactions.Add(transaction);
}
} }
+2
View File
@@ -17,6 +17,8 @@ public sealed class Balance : Entity
string currencyCode string currencyCode
) )
{ {
ArgumentNullException.ThrowIfNull(currencyCode, nameof(currencyCode));
return new() return new()
{ {
Current = current, Current = current,
@@ -3,6 +3,7 @@ namespace FiscalOS.Core.Accounts;
public sealed class Institution : Entity public sealed class Institution : Entity
{ {
public Guid UserId { get; init; } public Guid UserId { get; init; }
public User? User { get; init; }
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
public InstitutionMetadata? Metadata { get; init; } public InstitutionMetadata? Metadata { get; init; }
@@ -24,4 +25,11 @@ public sealed class Institution : Entity
Metadata = metadata Metadata = metadata
}; };
} }
public void AddAccount(Account account)
{
ArgumentNullException.ThrowIfNull(account, nameof(account));
_accounts.Add(account);
}
} }
+10
View File
@@ -16,6 +16,9 @@ public sealed class User : Entity
private readonly List<Account> _accounts = []; private readonly List<Account> _accounts = [];
public IEnumerable<Account> Accounts => _accounts; public IEnumerable<Account> Accounts => _accounts;
private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
private User() private User()
{ {
} }
@@ -60,4 +63,11 @@ public sealed class User : Entity
_accounts.Add(account); _accounts.Add(account);
} }
public void AddTransaction(Transaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction, nameof(transaction));
_transactions.Add(transaction);
}
} }
+8
View File
@@ -0,0 +1,8 @@
namespace FiscalOS.Core.Queuing;
public interface IAsyncQueue<T>
{
Task EnqueueAsync(T item, CancellationToken cancellationToken = default);
Task<T> DequeueAsync(CancellationToken cancellationToken = default);
ValueTask DisposeAsync();
}
@@ -0,0 +1,6 @@
namespace FiscalOS.Core.Queuing;
public interface IAsyncQueueProcessor<T>
{
Task ProcessAsync(T item, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,82 @@
namespace FiscalOS.Core.Transactions;
public sealed class Transaction : Entity
{
public Guid UserId { get; private set; }
public User? User { get; init; }
public Guid AccountId { get; init; }
public Account? Account { get; init; }
public string MerchantName { get; private set; } = 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(
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, Guid.Empty, Guid.Empty, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid userId,
Guid accountId,
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, userId, accountId, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid id,
Guid userId,
Guid accountId,
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
ArgumentNullException.ThrowIfNull(merchantName, nameof(merchantName));
ArgumentNullException.ThrowIfNull(description, nameof(description));
if (amount.HasValue is false)
{
throw new ArgumentNullException(nameof(amount));
}
if (date.HasValue is false)
{
throw new ArgumentNullException(nameof(date));
}
return new()
{
Id = id,
UserId = userId,
AccountId = accountId,
MerchantName = merchantName,
Description = description,
Amount = amount.Value,
Date = date.Value,
Metadata = metadata,
};
}
}
@@ -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;
}
}
+1
View File
@@ -2,3 +2,4 @@ global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data; global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
@@ -5,12 +5,16 @@ public sealed class PlaidAccountMetadata : AccountMetadata
public const string TypeValue = Providers.Plaid; public const string TypeValue = Providers.Plaid;
public string PlaidId { get; init; } = string.Empty; public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty; public string PlaidName { get; init; } = string.Empty;
public string? Cursor { get; private set; }
private PlaidAccountMetadata() : base(TypeValue) private PlaidAccountMetadata() : base(TypeValue)
{ {
} }
public static PlaidAccountMetadata From(string plaidId, string plaidName) public static PlaidAccountMetadata From(
string plaidId,
string plaidName
)
{ {
return new() return new()
{ {
@@ -18,4 +22,11 @@ public sealed class PlaidAccountMetadata : AccountMetadata
PlaidName = plaidName, PlaidName = plaidName,
}; };
} }
public void SetCursor(string cursor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cursor, nameof(cursor));
Cursor = cursor;
}
} }
@@ -1,22 +1,29 @@
using System.Reflection;
namespace FiscalOS.Infra.Accounts.Plaid; namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidService public interface IPlaidAccountService
{ {
Task<string> CreateLinkTokenAsync(string id);
Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken);
Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken);
Task<ItemWithConsentFields> GetItemAsync(string accessToken);
}
internal sealed class PlaidAccountService : IPlaidAccountService
{
private readonly IOptions<PlaidClientOptions> _options;
private readonly PlaidClient _client; private readonly PlaidClient _client;
private PlaidService(PlaidClient client) private PlaidAccountService(PlaidClient client, IOptions<PlaidClientOptions> options)
{ {
_client = client; _client = client;
_options = options;
} }
public static PlaidService From(IServiceProvider sp) public static PlaidAccountService From(IServiceProvider sp)
{ {
var client = sp.GetRequiredService<PlaidClient>(); var client = sp.GetRequiredService<PlaidClient>();
return new(client); var options = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
return new(client, options);
} }
public async Task<string> CreateLinkTokenAsync(string id) public async Task<string> CreateLinkTokenAsync(string id)
@@ -34,6 +41,7 @@ public sealed class PlaidService
{ {
ClientUserId = id, ClientUserId = id,
}, },
Webhook = _options.Value.Webhook,
}).ConfigureAwait(false); }).ConfigureAwait(false);
if (ltr.IsSuccessStatusCode is false) if (ltr.IsSuccessStatusCode is false)
@@ -4,6 +4,7 @@ public sealed record PlaidClientOptions
{ {
public string ClientId { get; init; } = string.Empty; public string ClientId { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty; public string Secret { get; init; } = string.Empty;
public string Webhook { get; init; } = string.Empty;
public IOptions<PlaidOptions> ToPlaidOptions() public IOptions<PlaidOptions> ToPlaidOptions()
{ {
@@ -1,31 +1,35 @@
namespace FiscalOS.Infra.Accounts.Plaid; namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidMetadata : InstitutionMetadata public sealed class PlaidInstitutionMetadata : InstitutionMetadata
{ {
public const string TypeValue = Providers.Plaid; public const string TypeValue = Providers.Plaid;
public string PlaidId { get; init; } = string.Empty; public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty; public string PlaidName { get; init; } = string.Empty;
public string EncryptedAccessToken { 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? plaidId,
string? plaidName, string? plaidName,
string encryptedAccessToken string encryptedAccessToken,
string itemId
) )
{ {
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId)); ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName)); ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName));
ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken)); ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken));
ArgumentNullException.ThrowIfNull(itemId, nameof(itemId));
return new PlaidMetadata return new()
{ {
PlaidId = plaidId, PlaidId = plaidId,
PlaidName = plaidName, PlaidName = plaidName,
EncryptedAccessToken = encryptedAccessToken EncryptedAccessToken = encryptedAccessToken,
ItemId = itemId,
}; };
} }
} }
@@ -1,4 +1,4 @@
namespace FiscalOS.Infra.Accounts.Plaid; namespace FiscalOS.Infra.Common;
public class PlaidException : Exception public class PlaidException : Exception
{ {
@@ -1,4 +1,4 @@
namespace FiscalOS.Infra.Accounts; namespace FiscalOS.Infra.Common;
public static class Providers public static class Providers
{ {
+43 -3
View File
@@ -1,5 +1,6 @@
using Account = FiscalOS.Core.Accounts.Account; using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution; using Institution = FiscalOS.Core.Accounts.Institution;
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.Infra.Data; namespace FiscalOS.Infra.Data;
@@ -14,6 +15,8 @@ public sealed class AppDbContext(
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Institution> Institutions => Set<Institution>();
public DbSet<Transaction> Transactions => Set<Transaction>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -63,7 +66,7 @@ public sealed class AppDbContext(
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
eb.HasMany(static u => u.Institutions) eb.HasMany(static u => u.Institutions)
.WithOne() .WithOne(static i => i.User)
.HasForeignKey(static i => i.UserId) .HasForeignKey(static i => i.UserId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
@@ -96,19 +99,20 @@ public sealed class AppDbContext(
modelBuilder.Entity<InstitutionMetadata>(static eb => modelBuilder.Entity<InstitutionMetadata>(static eb =>
{ {
eb.HasDiscriminator(static m => m.Type) eb.HasDiscriminator(static m => m.Type)
.HasValue<PlaidMetadata>(PlaidMetadata.TypeValue); .HasValue<PlaidInstitutionMetadata>(PlaidInstitutionMetadata.TypeValue);
eb.Property(static m => m.InstitutionId); eb.Property(static m => m.InstitutionId);
eb.Property(static m => m.Type); eb.Property(static m => m.Type);
}); });
modelBuilder.Entity<PlaidMetadata>(static eb => modelBuilder.Entity<PlaidInstitutionMetadata>(static eb =>
{ {
eb.HasBaseType<InstitutionMetadata>(); eb.HasBaseType<InstitutionMetadata>();
eb.Property(static m => m.PlaidId); eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName); eb.Property(static m => m.PlaidName);
eb.Property(static m => m.EncryptedAccessToken); eb.Property(static m => m.EncryptedAccessToken);
eb.Property(static m => m.ItemId);
}); });
modelBuilder.Entity<Account>(static eb => modelBuilder.Entity<Account>(static eb =>
@@ -146,6 +150,7 @@ public sealed class AppDbContext(
eb.Property(static m => m.PlaidId); eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName); eb.Property(static m => m.PlaidName);
eb.Property(static m => m.Cursor);
}); });
modelBuilder.Entity<Balance>(static eb => modelBuilder.Entity<Balance>(static eb =>
@@ -155,5 +160,40 @@ public sealed class AppDbContext(
eb.Property(static b => b.Available); eb.Property(static b => b.Available);
eb.Property(static b => b.CurrencyCode); eb.Property(static b => b.CurrencyCode);
}); });
modelBuilder.Entity<Transaction>(static eb =>
{
eb.HasOne(static t => t.User)
.WithMany(static u => u.Transactions)
.HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasOne(static t => t.Account)
.WithMany(static a => a.Transactions)
.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<TransactionMetadata>(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<PlaidTransactionMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue(PlaidTransactionMetadata.TypeValue);
eb.Property(static m => m.PlaidId);
});
} }
} }
@@ -33,7 +33,18 @@ public static class ServiceCollectionExtensions
var options = clientOptions.Value.ToPlaidOptions(); var options = clientOptions.Value.ToPlaidOptions();
return new PlaidClient(options, factory, logger); return new PlaidClient(options, factory, logger);
}); });
services.AddSingleton(PlaidService.From); services.AddSingleton<IPlaidAccountService>(PlaidAccountService.From);
services.AddSingleton<IPlaidTransactionService>(PlaidTransactionService.From);
services.AddScoped<IPlaidAddedTransactionHandler>(PlaidAddedTransactionHandler.From);
services.AddScoped<IPlaidModifiedTransactionHandler>(PlaidModifiedTransactionHandler.From);
services.AddScoped<IPlaidRemovedTransactionHandler>(PlaidRemovedTransactionHandler.From);
services.AddScoped<IPlaidTransactionProcessor>(PlaidTransactionProcessor.From);
services.AddScoped<IPlaidTransactionSyncer>(PlaidTransactionSyncer.From);
services.AddSingleton<IAsyncQueue<SyncUpdatesQueueItem>>(ChannelAsyncQueue<SyncUpdatesQueueItem>.From);
services.AddScoped<IAsyncQueueProcessor<SyncUpdatesQueueItem>>(SyncUpdatesProcessor.From);
services.AddHostedService(AsyncQueueHostedService<SyncUpdatesQueueItem>.From);
return services; return services;
} }
@@ -1,6 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -1,6 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -1,6 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -0,0 +1,374 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institution");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddItemIdToPlaidMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ItemId",
table: "InstitutionMetadata",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ItemId",
table: "InstitutionMetadata");
}
}
}
@@ -0,0 +1,376 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,126 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddNavigationPropertyToInstitutionForUser : Migration
{
/// <inheritdoc />
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);
}
/// <inheritdoc />
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);
}
}
}
@@ -0,0 +1,376 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class RenamePlaidMetadataEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,504 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<Guid?>("AccountId1")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("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
}
}
}
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddTransactionAndTransactionMetadataModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Transaction",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
MerchantName = table.Column<string>(type: "TEXT", nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: false),
Amount = table.Column<decimal>(type: "TEXT", nullable: false),
Date = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
AccountId1 = table.Column<Guid>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 5, nullable: false),
TransactionId = table.Column<Guid>(type: "TEXT", nullable: false),
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TransactionMetadata");
migrationBuilder.DropTable(
name: "Transaction");
}
}
}
@@ -0,0 +1,508 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<Guid?>("AccountId1")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddCursorToAccountModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Cursor",
table: "AccountMetadata",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Cursor",
table: "AccountMetadata");
}
}
}
@@ -0,0 +1,507 @@
// <auto-generated />
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("20260302015827_AddInstitutionsDbSet")]
partial class AddInstitutionsDbSet
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<Guid?>("AccountId1")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("AccountId1");
b.HasIndex("UserId");
b.ToTable("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("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
}
}
}
@@ -0,0 +1,168 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddInstitutionsDbSet : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Transaction_Account_AccountId",
table: "Transaction");
migrationBuilder.DropForeignKey(
name: "FK_Transaction_Account_AccountId1",
table: "Transaction");
migrationBuilder.DropForeignKey(
name: "FK_Transaction_Users_UserId",
table: "Transaction");
migrationBuilder.DropForeignKey(
name: "FK_TransactionMetadata_Transaction_TransactionId",
table: "TransactionMetadata");
migrationBuilder.DropPrimaryKey(
name: "PK_Transaction",
table: "Transaction");
migrationBuilder.RenameTable(
name: "Transaction",
newName: "Transactions");
migrationBuilder.RenameIndex(
name: "IX_Transaction_UserId",
table: "Transactions",
newName: "IX_Transactions_UserId");
migrationBuilder.RenameIndex(
name: "IX_Transaction_AccountId1",
table: "Transactions",
newName: "IX_Transactions_AccountId1");
migrationBuilder.RenameIndex(
name: "IX_Transaction_AccountId",
table: "Transactions",
newName: "IX_Transactions_AccountId");
migrationBuilder.AddPrimaryKey(
name: "PK_Transactions",
table: "Transactions",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_TransactionMetadata_Transactions_TransactionId",
table: "TransactionMetadata",
column: "TransactionId",
principalTable: "Transactions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Account_AccountId",
table: "Transactions",
column: "AccountId",
principalTable: "Account",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Account_AccountId1",
table: "Transactions",
column: "AccountId1",
principalTable: "Account",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Users_UserId",
table: "Transactions",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TransactionMetadata_Transactions_TransactionId",
table: "TransactionMetadata");
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Account_AccountId",
table: "Transactions");
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Account_AccountId1",
table: "Transactions");
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Users_UserId",
table: "Transactions");
migrationBuilder.DropPrimaryKey(
name: "PK_Transactions",
table: "Transactions");
migrationBuilder.RenameTable(
name: "Transactions",
newName: "Transaction");
migrationBuilder.RenameIndex(
name: "IX_Transactions_UserId",
table: "Transaction",
newName: "IX_Transaction_UserId");
migrationBuilder.RenameIndex(
name: "IX_Transactions_AccountId1",
table: "Transaction",
newName: "IX_Transaction_AccountId1");
migrationBuilder.RenameIndex(
name: "IX_Transactions_AccountId",
table: "Transaction",
newName: "IX_Transaction_AccountId");
migrationBuilder.AddPrimaryKey(
name: "PK_Transaction",
table: "Transaction",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Transaction_Account_AccountId",
table: "Transaction",
column: "AccountId",
principalTable: "Account",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Transaction_Account_AccountId1",
table: "Transaction",
column: "AccountId1",
principalTable: "Account",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Transaction_Users_UserId",
table: "Transaction",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_TransactionMetadata_Transaction_TransactionId",
table: "TransactionMetadata",
column: "TransactionId",
principalTable: "Transaction",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,518 @@
// <auto-generated />
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("20260303124406_AddTransactionsNavigationPropertyToUser")]
partial class AddTransactionsNavigationPropertyToUser
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<Guid?>("AccountId1")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid?>("UserId1")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("AccountId1");
b.HasIndex("UserId");
b.HasIndex("UserId1");
b.ToTable("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("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.HasOne("FiscalOS.Core.Identity.User", null)
.WithMany("Transactions")
.HasForeignKey("UserId1");
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");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Navigation("Metadata");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddTransactionsNavigationPropertyToUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "UserId1",
table: "Transactions",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Transactions_UserId1",
table: "Transactions",
column: "UserId1");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions",
column: "UserId1",
principalTable: "Users",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_Transactions_UserId1",
table: "Transactions");
migrationBuilder.DropColumn(
name: "UserId1",
table: "Transactions");
}
}
}
@@ -0,0 +1,500 @@
// <auto-generated />
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("20260303130637_UpdateAppDbContextToIncludeTransactionNavigationProperties")]
partial class UpdateAppDbContextToIncludeTransactionNavigationProperties
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("UserId");
b.ToTable("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("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("Transactions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Transactions")
.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");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Navigation("Metadata");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class UpdateAppDbContextToIncludeTransactionNavigationProperties : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Account_AccountId1",
table: "Transactions");
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_Transactions_AccountId1",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_Transactions_UserId1",
table: "Transactions");
migrationBuilder.DropColumn(
name: "AccountId1",
table: "Transactions");
migrationBuilder.DropColumn(
name: "UserId1",
table: "Transactions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "AccountId1",
table: "Transactions",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "UserId1",
table: "Transactions",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Transactions_AccountId1",
table: "Transactions",
column: "AccountId1");
migrationBuilder.CreateIndex(
name: "IX_Transactions_UserId1",
table: "Transactions",
column: "UserId1");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Account_AccountId1",
table: "Transactions",
column: "AccountId1",
principalTable: "Account",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions",
column: "UserId1",
principalTable: "Users",
principalColumn: "Id");
}
}
}
@@ -135,7 +135,7 @@ namespace FiscalOS.Infra.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("Institution"); b.ToTable("Institutions");
}); });
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
@@ -241,10 +241,86 @@ namespace FiscalOS.Infra.Migrations
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("UserId");
b.ToTable("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{ {
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.HasColumnType("TEXT");
b.Property<string>("PlaidId") b.Property<string>("PlaidId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -256,7 +332,7 @@ namespace FiscalOS.Infra.Migrations
b.HasDiscriminator().HasValue("Plaid"); 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"); b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
@@ -264,6 +340,10 @@ namespace FiscalOS.Infra.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId") b.Property<string>("PlaidId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -275,10 +355,21 @@ namespace FiscalOS.Infra.Migrations
b.HasDiscriminator().HasValue("Plaid"); b.HasDiscriminator().HasValue("Plaid");
}); });
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b => modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{ {
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution") b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
.WithMany() .WithMany("Accounts")
.HasForeignKey("InstitutionId") .HasForeignKey("InstitutionId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -312,11 +403,13 @@ namespace FiscalOS.Infra.Migrations
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{ {
b.HasOne("FiscalOS.Core.Identity.User", null) b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Institutions") .WithMany("Institutions")
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("User");
}); });
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
@@ -339,15 +432,47 @@ namespace FiscalOS.Infra.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", "Account")
.WithMany("Transactions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Transactions")
.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 => modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{ {
b.Navigation("Balances"); b.Navigation("Balances");
b.Navigation("Metadata"); b.Navigation("Metadata");
b.Navigation("Transactions");
}); });
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{ {
b.Navigation("Accounts");
b.Navigation("Metadata"); b.Navigation("Metadata");
}); });
@@ -358,6 +483,13 @@ namespace FiscalOS.Infra.Migrations
b.Navigation("Institutions"); b.Navigation("Institutions");
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Navigation("Metadata");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
@@ -0,0 +1,55 @@
namespace FiscalOS.Infra.Queuing;
internal class AsyncQueueHostedService<T> : BackgroundService where T : notnull
{
private readonly IAsyncQueue<T> _queue;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AsyncQueueHostedService<T>> _logger;
private AsyncQueueHostedService(
IAsyncQueue<T> queue,
IServiceProvider serviceProvider,
ILogger<AsyncQueueHostedService<T>> logger
)
{
_queue = queue;
_serviceProvider = serviceProvider;
_logger = logger;
}
public static AsyncQueueHostedService<T> From(IServiceProvider serviceProvider)
{
var queue = serviceProvider.GetRequiredService<IAsyncQueue<T>>();
var logger = serviceProvider.GetRequiredService<ILogger<AsyncQueueHostedService<T>>>();
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<IAsyncQueueProcessor<T>>();
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);
}
}
}
@@ -0,0 +1,31 @@
namespace FiscalOS.Infra.Queuing;
internal class ChannelAsyncQueue<T> : IAsyncQueue<T>
{
private readonly Channel<T> _channel = Channel.CreateUnbounded<T>();
private ChannelAsyncQueue()
{
}
public static ChannelAsyncQueue<T> 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<T> 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);
}
}
@@ -0,0 +1,114 @@
using Account = FiscalOS.Core.Accounts.Account;
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.Infra.Transactions.Plaid;
internal interface IPlaidAddedTransactionHandler
{
Task<int> HandleAsync(
Account account,
IEnumerable<Going.Plaid.Entity.Transaction> added,
CancellationToken ct
);
}
internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandler
{
private readonly ILogger<PlaidAddedTransactionHandler> _logger;
private readonly AppDbContext _appDbContext;
private PlaidAddedTransactionHandler(
ILogger<PlaidAddedTransactionHandler> logger,
AppDbContext appDbContext
)
{
_logger = logger;
_appDbContext = appDbContext;
}
public static PlaidAddedTransactionHandler From(
ILogger<PlaidAddedTransactionHandler> logger,
AppDbContext appDbContext
)
{
return new(logger, appDbContext);
}
public static PlaidAddedTransactionHandler From(IServiceProvider serviceProvider)
{
return new(
serviceProvider.GetRequiredService<ILogger<PlaidAddedTransactionHandler>>(),
serviceProvider.GetRequiredService<AppDbContext>()
);
}
public async Task<int> HandleAsync(Account account, IEnumerable<Going.Plaid.Entity.Transaction> added, CancellationToken ct)
{
var existingTransactionIds = added.Select(t => t.TransactionId);
var existingTransactions = await _appDbContext.Transactions
.Where(t => t.Metadata is PlaidTransactionMetadata && existingTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ToListAsync(ct)
.ConfigureAwait(false);
var addedCount = 0;
foreach (var addedTransaction in added)
{
try
{
if (addedTransaction.Pending.GetValueOrDefault())
{
_logger.LogInformation(
"Skipping pending transaction {TransactionId} for account {AccountId} as it has not been posted yet",
addedTransaction.TransactionId,
account.Id
);
addedCount++;
continue;
}
var existingTransaction = existingTransactions.FirstOrDefault(
t => t.Metadata is PlaidTransactionMetadata metadata && metadata.PlaidId == addedTransaction.TransactionId
);
if (existingTransaction is not null)
{
_logger.LogInformation(
"Skipping added transaction {PlaidTransactionId} for account {AccountId} as it has already been added as transaction {TransactionId}",
addedTransaction.TransactionId,
account.Id,
existingTransaction.Id
);
addedCount++;
continue;
}
var transactionMetadata = PlaidTransactionMetadata.From(addedTransaction.TransactionId);
var transaction = Transaction.From(
account.UserId,
account.Id,
addedTransaction.Merchant,
addedTransaction.Description,
addedTransaction.Amount,
addedTransaction.PostedDate,
transactionMetadata
);
account.AddTransaction(transaction);
addedCount++;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to added transaction {PlaidTransactionId} to account {AccountId}",
account.Id,
addedTransaction.TransactionId
);
}
}
_logger.LogInformation("Added {AddedCount} transactions to account {AccountId}", addedCount, account.Id);
return addedCount;
}
}
@@ -0,0 +1,24 @@
namespace FiscalOS.Infra.Transactions.Plaid;
public static class PlaidExtensions
{
#pragma warning disable CA1034
extension(Going.Plaid.Entity.Transaction transaction)
{
public string Merchant => transaction.MerchantName ?? "Unknown merchant";
public string Description => transaction.OriginalDescription ?? "";
public DateTimeOffset PostedDate => transaction.Datetime ?? (
transaction.Date.HasValue
? new DateTimeOffset(
transaction.Date.Value.Year,
transaction.Date.Value.Month,
transaction.Date.Value.Day,
0,
0,
0,
TimeSpan.Zero
)
: throw new InvalidOperationException($"Transaction {transaction.TransactionId} does not have a valid date or datetime")
);
}
}
@@ -0,0 +1,110 @@
using Account = FiscalOS.Core.Accounts.Account;
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.Infra.Transactions.Plaid;
internal interface IPlaidModifiedTransactionHandler
{
Task<int> HandleAsync(
Account account,
IEnumerable<Going.Plaid.Entity.Transaction> modified,
CancellationToken ct
);
}
internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactionHandler
{
private readonly ILogger<PlaidModifiedTransactionHandler> _logger;
private readonly AppDbContext _appDbContext;
private PlaidModifiedTransactionHandler(
ILogger<PlaidModifiedTransactionHandler> logger,
AppDbContext appDbContext
)
{
_logger = logger;
_appDbContext = appDbContext;
}
public static PlaidModifiedTransactionHandler From(
ILogger<PlaidModifiedTransactionHandler> logger,
AppDbContext appDbContext
)
{
return new(logger, appDbContext);
}
public static PlaidModifiedTransactionHandler From(IServiceProvider serviceProvider)
{
return new(
serviceProvider.GetRequiredService<ILogger<PlaidModifiedTransactionHandler>>(),
serviceProvider.GetRequiredService<AppDbContext>()
);
}
public async Task<int> HandleAsync(Account account, IEnumerable<Going.Plaid.Entity.Transaction> modified, CancellationToken ct)
{
var modifiedTransactionIds = modified.Select(t => t.TransactionId);
var existingModifiedTransactions = await _appDbContext.Transactions
.Where(t => t.Metadata is PlaidTransactionMetadata && modifiedTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ToListAsync(ct)
.ConfigureAwait(false);
var modifiedCount = 0;
foreach (var existing in existingModifiedTransactions)
{
try
{
if (existing.Metadata is not PlaidTransactionMetadata plaidMetadata)
{
_logger.LogWarning(
"Existing transaction {TransactionId} has non-Plaid metadata. Skipping update for this transaction.",
existing.Id
);
modifiedCount++;
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
);
modifiedCount++;
continue;
}
var newTransactionData = Transaction.From(
existing.Id,
existing.UserId,
existing.AccountId,
plaidModifiedTransaction.Merchant,
plaidModifiedTransaction.Description,
plaidModifiedTransaction.Amount,
plaidModifiedTransaction.PostedDate,
plaidMetadata
);
_appDbContext.Entry(existing).CurrentValues.SetValues(newTransactionData);
modifiedCount++;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to update transaction {TransactionId} for account {AccountId}",
account.Id,
existing.Id
);
}
}
_logger.LogInformation("Modified {ModifiedCount} transactions for account {AccountId}", modifiedCount, account.Id);
return modifiedCount;
}
}
@@ -0,0 +1,63 @@
namespace FiscalOS.Infra.Transactions.Plaid;
internal interface IPlaidRemovedTransactionHandler
{
Task<int> HandleAsync(IEnumerable<RemovedTransaction> removed, CancellationToken cancellationToken);
}
internal sealed class PlaidRemovedTransactionHandler : IPlaidRemovedTransactionHandler
{
private readonly AppDbContext _appDbContext;
private readonly ILogger<PlaidRemovedTransactionHandler> _logger;
private PlaidRemovedTransactionHandler(
AppDbContext appDbContext,
ILogger<PlaidRemovedTransactionHandler> logger
)
{
_appDbContext = appDbContext;
_logger = logger;
}
public static PlaidRemovedTransactionHandler From(
AppDbContext appDbContext,
ILogger<PlaidRemovedTransactionHandler> logger
)
{
return new(appDbContext, logger);
}
public static PlaidRemovedTransactionHandler From(IServiceProvider serviceProvider)
{
return new(
serviceProvider.GetRequiredService<AppDbContext>(),
serviceProvider.GetRequiredService<ILogger<PlaidRemovedTransactionHandler>>()
);
}
public async Task<int> HandleAsync(IEnumerable<RemovedTransaction> removed, CancellationToken cancellationToken)
{
var removedCount = 0;
var removedIdsList = removed.Select(t => t.TransactionId);
try
{
removedCount = await _appDbContext.Transactions
.Where(t => t.Metadata is PlaidTransactionMetadata && removedIdsList.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to removed transactions {TransactionIds}",
removedIdsList
);
}
_logger.LogInformation("Removed {RemovedCount} transactions", removedCount);
return removedCount;
}
}
@@ -0,0 +1,21 @@
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, nameof(plaidId));
return new()
{
PlaidId = plaidId,
};
}
}
@@ -0,0 +1,76 @@
using Account = FiscalOS.Core.Accounts.Account;
namespace FiscalOS.Infra.Transactions.Plaid;
internal interface IPlaidTransactionProcessor
{
Task<bool> 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(
IPlaidAddedTransactionHandler addedHandler,
IPlaidModifiedTransactionHandler modifiedHandler,
IPlaidRemovedTransactionHandler removedHandler
)
{
return new(addedHandler, modifiedHandler, removedHandler);
}
internal static PlaidTransactionProcessor From(IServiceProvider provider)
{
return new(
provider.GetRequiredService<IPlaidAddedTransactionHandler>(),
provider.GetRequiredService<IPlaidModifiedTransactionHandler>(),
provider.GetRequiredService<IPlaidRemovedTransactionHandler>()
);
}
public async Task<bool> ProcessAsync(
Account account,
TransactionsSyncResponse syncResponse,
CancellationToken cancellationToken
)
{
var numAdded = await _addedHandler.HandleAsync(
account,
syncResponse.Added,
cancellationToken
).ConfigureAwait(false);
var numModified = await _modifiedHandler.HandleAsync(
account,
syncResponse.Modified,
cancellationToken
).ConfigureAwait(false);
var numRemoved = await _removedHandler.HandleAsync(
syncResponse.Removed,
cancellationToken
).ConfigureAwait(false);
return numAdded == syncResponse.Added.Count &&
numModified == syncResponse.Modified.Count &&
numRemoved == syncResponse.Removed.Count;
}
}
@@ -0,0 +1,58 @@
namespace FiscalOS.Infra.Transactions.Plaid;
internal interface IPlaidTransactionService
{
IAsyncEnumerable<TransactionsSyncResponse> 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<PlaidClient>();
return new(client);
}
public async IAsyncEnumerable<TransactionsSyncResponse> 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);
}
}
@@ -0,0 +1,113 @@
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<PlaidTransactionSyncer> _logger;
private readonly IPlaidTransactionService _plaidTransactionService;
private readonly IPlaidTransactionProcessor _transactionProcessor;
private PlaidTransactionSyncer(
ILogger<PlaidTransactionSyncer> logger,
IPlaidTransactionService plaidTransactionService,
IPlaidTransactionProcessor plaidTransactionProcessor
)
{
_logger = logger;
_plaidTransactionService = plaidTransactionService;
_transactionProcessor = plaidTransactionProcessor;
}
public static PlaidTransactionSyncer From(
ILogger<PlaidTransactionSyncer> logger,
IPlaidTransactionService plaidTransactionService,
IPlaidTransactionProcessor plaidTransactionProcessor
)
{
return new(logger, plaidTransactionService, plaidTransactionProcessor);
}
public static PlaidTransactionSyncer From(IServiceProvider serviceProvider)
{
return new(
serviceProvider.GetRequiredService<ILogger<PlaidTransactionSyncer>>(),
serviceProvider.GetRequiredService<IPlaidTransactionService>(),
serviceProvider.GetRequiredService<IPlaidTransactionProcessor>()
);
}
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;
}
var balance = Balance.From(plaidAccount.Current, plaidAccount.Available, plaidAccount.CurrencyCode);
account.AddBalance(balance);
var isProcessedSuccessfully = await _transactionProcessor.ProcessAsync(account, response, cancellationToken).ConfigureAwait(false);
if (isProcessedSuccessfully is false)
{
_logger.LogWarning(
"Unable to process transactions for request {RequestId} for account {AccountId} successfully",
response.RequestId,
account.Id
);
break;
}
plaidAccountMetadata.SetCursor(response.NextCursor);
}
}
}
@@ -0,0 +1,100 @@
namespace FiscalOS.Infra.Transactions.Plaid;
internal sealed class SyncUpdatesProcessor : IAsyncQueueProcessor<SyncUpdatesQueueItem>
{
private readonly ILogger<SyncUpdatesProcessor> _logger;
private readonly AppDbContext _appDbContext;
private readonly IEncryptor _encryptor;
private readonly IPlaidTransactionSyncer _plaidTransactionSyncer;
private SyncUpdatesProcessor(
ILogger<SyncUpdatesProcessor> 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<ILogger<SyncUpdatesProcessor>>(),
serviceProvider.GetRequiredService<AppDbContext>(),
serviceProvider.GetRequiredService<IEncryptor>(),
serviceProvider.GetRequiredService<IPlaidTransactionSyncer>()
);
}
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)
{
try
{
await _plaidTransactionSyncer.SyncTransactionsForAccountAsync(account, decryptedAccessToken, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync transactions for account {AccountId}", account.Id);
}
}
await _appDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Successfully processed sync updates for item {ItemId}", item.InstitutionItemId);
}
}
@@ -0,0 +1,3 @@
namespace FiscalOS.Infra.Transactions.Plaid;
public sealed record SyncUpdatesQueueItem(string InstitutionItemId);
+8
View File
@@ -1,23 +1,31 @@
global using System.IdentityModel.Tokens.Jwt; global using System.IdentityModel.Tokens.Jwt;
global using System.IO.Abstractions; global using System.IO.Abstractions;
global using System.Reflection;
global using System.Security.Claims; global using System.Security.Claims;
global using System.Security.Cryptography; global using System.Security.Cryptography;
global using System.Text; global using System.Text;
global using System.Text.Json; global using System.Text.Json;
global using System.Threading.Channels;
global using FiscalOS.Core.Accounts; global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data; global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization; global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Common;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Queuing;
global using FiscalOS.Infra.Security; global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Transactions.Plaid;
global using Going.Plaid; global using Going.Plaid;
global using Going.Plaid.Entity; global using Going.Plaid.Entity;
global using Going.Plaid.Transactions;
global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Authorization;
@@ -59,7 +59,7 @@ public static class ServiceDefaultsExtensions
public static WebApplication MapDefaultEndpoints(this WebApplication app) public static WebApplication MapDefaultEndpoints(this WebApplication app)
{ {
ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(app, nameof(app));
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
@@ -13,7 +13,10 @@ internal sealed class HttpRequestBuilder
{ {
} }
public static HttpRequestBuilder New() => new(); public static HttpRequestBuilder New()
{
return new();
}
public HttpRequestBuilder WithMethod(HttpMethod method) public HttpRequestBuilder WithMethod(HttpMethod method)
{ {
+68
View File
@@ -2,6 +2,8 @@ namespace FiscalOS.API.Tests.Infra;
public class TestApi : WebApplicationFactory<Program> public class TestApi : WebApplicationFactory<Program>
{ {
private readonly List<Action<IWebHostBuilder>> _additionalConfigs = [];
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
base.ConfigureWebHost(builder); base.ConfigureWebHost(builder);
@@ -25,5 +27,71 @@ public class TestApi : WebApplicationFactory<Program>
c.AddSingleton<IKeyRing>(TestKeyRing.From); c.AddSingleton<IKeyRing>(TestKeyRing.From);
}); });
foreach (var config in _additionalConfigs)
{
config(builder);
}
}
public TestApi WithAdditionalConfig(Action<IWebHostBuilder> configuration)
{
var newApi = new TestApi();
newApi._additionalConfigs.AddRange(_additionalConfigs);
newApi._additionalConfigs.Add(configuration);
return newApi;
}
public async Task ExecuteAsync(Func<DbContext, CancellationToken, Task> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct);
}
public async Task ExecuteAsync(Func<DbContext, CancellationToken, IServiceProvider, Task> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct, scope.ServiceProvider);
}
public async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, Task<T>> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct);
}
public async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, IServiceProvider, Task<T>> action, CancellationToken ct)
{
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct, scope.ServiceProvider);
}
public async Task EnsureDbCreatedAsync()
{
await ExecuteAsync(static async (context, ct) =>
{
await context.Database.EnsureCreatedAsync(ct);
}, TestContext.Current.CancellationToken);
}
public async Task EnsureDbDeletedAsync()
{
await ExecuteAsync(static async (context, ct) =>
{
await context.Database.EnsureDeletedAsync(ct);
}, TestContext.Current.CancellationToken);
}
public override async ValueTask DisposeAsync()
{
await EnsureDbDeletedAsync();
await base.DisposeAsync();
GC.SuppressFinalize(this);
} }
} }
@@ -1,3 +1,6 @@
using FiscalOS.Core.Queuing;
using FiscalOS.Infra.Transactions.Plaid;
using Account = FiscalOS.Core.Accounts.Account; using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution; using Institution = FiscalOS.Core.Accounts.Institution;
@@ -31,7 +34,6 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."], ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
["PlaidAccountId"] = ["The PlaidAccountId field is required."], ["PlaidAccountId"] = ["The PlaidAccountId field is required."],
["PlaidAccountName"] = ["The PlaidAccountName field is required."], ["PlaidAccountName"] = ["The PlaidAccountName field is required."],
["AccountCurrencyCode"] = ["The AccountCurrencyCode field is required."],
}); });
} }
@@ -124,7 +126,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Add_WhenCalledWithPlaidInstitutionIdThatHasNotBeenAdded_ItShouldReturn400WithProblemDetails() public async Task Add_WhenCalledWithPlaidInstitutionIdThatHasNotBeenAdded_ItShouldReturn400WithProblemDetails()
{ {
var user = await ExecuteAsync(static async (context, ct, sp) => var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -161,7 +163,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Add_WhenCalledWithPlaidAccountIdThatHasAlreadyBeenAdded_ItShouldReturn409WithProblemDetails() public async Task Add_WhenCalledWithPlaidAccountIdThatHasAlreadyBeenAdded_ItShouldReturn409WithProblemDetails()
{ {
var (user, institution, account) = await ExecuteAsync(static async (context, ct, sp) => var (user, institution, account) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -169,20 +171,25 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
await context.AddAsync(user, ct);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", 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,
"itemId"
);
var institution = Institution.From("Some Bank", plaidMetadata); var institution = Institution.From("Some Bank", plaidMetadata);
await context.AddAsync(institution, ct); await context.AddAsync(institution, ct);
var plaidAccountMetadata = PlaidAccountMetadata.From("accountId", "Some Account"); var plaidAccountMetadata = PlaidAccountMetadata.From("accountId", "Some Account");
var account = Account.From(institution.Id, "Some Account", plaidAccountMetadata); var account = Account.From("Some Account", plaidAccountMetadata);
institution.AddAccount(account);
user.AddInstitution(institution); user.AddInstitution(institution);
user.AddAccount(account); user.AddAccount(account);
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct); await context.SaveChangesAsync(ct);
return (user, institution, account); return (user, institution, account);
}, TestContext.Current.CancellationToken); }, TestContext.Current.CancellationToken);
@@ -192,7 +199,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id) .WithUserId(user.Id)
.WithBody(new .WithBody(new
{ {
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId, plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName, plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
accountCurrencyCode = "USD", accountCurrencyCode = "USD",
@@ -207,7 +214,18 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Add_WhenCalledWithNewAccount_ItShouldReturn200() public async Task Add_WhenCalledWithNewAccount_ItShouldReturn200()
{ {
var (user, institution) = await ExecuteAsync(async (context, ct, sp) => var mockQueue = new Mock<IAsyncQueue<SyncUpdatesQueueItem>>();
await using var testApi = Api
.WithAdditionalConfig(whb =>
{
whb.ConfigureTestServices(s =>
{
s.AddSingleton(mockQueue.Object);
});
});
var (user, institution) = await testApi.ExecuteAsync(async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -217,7 +235,12 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
var plaidMetadata = PlaidMetadata.From("id", "Some Bank", encryptedAccessToken); var plaidMetadata = PlaidInstitutionMetadata.From(
"id",
"Some Bank",
encryptedAccessToken,
"itemId"
);
var institution = Institution.From("Some Bank", plaidMetadata); var institution = Institution.From("Some Bank", plaidMetadata);
user.AddInstitution(institution); user.AddInstitution(institution);
@@ -237,7 +260,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id) .WithUserId(user.Id)
.WithBody(new .WithBody(new
{ {
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
plaidAccountId = newAccountId, plaidAccountId = newAccountId,
plaidAccountName = newAccountName, plaidAccountName = newAccountName,
accountCurrentBalance = expectedBalance, accountCurrentBalance = expectedBalance,
@@ -246,11 +269,13 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
}) })
.Build(); .Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); var client = testApi.CreateClient();
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedUser = await ExecuteAsync( var updatedUser = await testApi.ExecuteAsync(
async (context, ct) => await context.Set<User>() async (context, ct) => await context.Set<User>()
.Include(u => u.Accounts) .Include(u => u.Accounts)
.ThenInclude(a => a.Metadata) .ThenInclude(a => a.Metadata)
@@ -269,12 +294,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
((PlaidAccountMetadata)a.Metadata).PlaidName == newAccountName ((PlaidAccountMetadata)a.Metadata).PlaidName == newAccountName
); );
updatedUser.Accounts.First() mockQueue.Verify(m => m.EnqueueAsync(
.Balances.Should().ContainSingle( It.IsAny<SyncUpdatesQueueItem>(),
b => b.AccountId == updatedUser.Accounts.First().Id && It.IsAny<CancellationToken>()
b.Current == expectedBalance && ), Times.Once());
b.Available == expectedBalance &&
b.CurrencyCode == expectedCurrencyCode
);
} }
} }
@@ -42,7 +42,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails() public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails()
{ {
await ExecuteAsync(static async (context, ct, sp) => await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -70,7 +70,7 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie() public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
{ {
await ExecuteAsync(static async (context, ct, sp) => await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -29,7 +29,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails() public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
{ {
var (users, refreshToken) = await ExecuteAsync(static async (context, ct, sp) => var (users, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -61,7 +61,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden); await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
var unrevokedTokensCountForUser2 = await ExecuteAsync( var unrevokedTokensCountForUser2 = await Api.ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>() async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User) .Include(t => t.User)
.Where(t => t.UserId == users[1].Id && t.Revoked == false) .Where(t => t.UserId == users[1].Id && t.Revoked == false)
@@ -75,7 +75,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails() public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
{ {
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) => var (user, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -108,7 +108,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails() public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
{ {
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) => var (user, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -142,7 +142,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[InlineData(-5)] [InlineData(-5)]
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset) public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
{ {
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) => var (user, refreshToken) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
@@ -174,7 +174,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie"); response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await response.Should().BeJsonContentOfType<API.Auth.Refresh.Response>(HttpStatusCode.OK); await response.Should().BeJsonContentOfType<API.Auth.Refresh.Response>(HttpStatusCode.OK);
var oldRefreshTokenInDb = await ExecuteAsync( var oldRefreshTokenInDb = await Api.ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>() async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User) .Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true) .Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
@@ -184,7 +184,7 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
oldRefreshTokenInDb.Should().NotBeNull(); oldRefreshTokenInDb.Should().NotBeNull();
var newRefreshTokenInDb = await ExecuteAsync( var newRefreshTokenInDb = await Api.ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>() async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User) .Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token) .Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
@@ -92,7 +92,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Connect_WhenCalledWithPlaidInstitutionIdThatIsAlreadyConnected_ItShouldReturn409WithProblemDetails() public async Task Connect_WhenCalledWithPlaidInstitutionIdThatIsAlreadyConnected_ItShouldReturn409WithProblemDetails()
{ {
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) => var (user, institution) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -101,7 +101,12 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", 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,
"itemId"
);
var institution = Institution.From("Some Bank", plaidMetadata); var institution = Institution.From("Some Bank", plaidMetadata);
user.AddInstitution(institution); user.AddInstitution(institution);
@@ -117,7 +122,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
.WithBody(new .WithBody(new
{ {
publicToken = "token", publicToken = "token",
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
}) })
.Build(); .Build();
@@ -131,7 +136,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
{ {
var plaidInstitutionId = "ins_109508"; var plaidInstitutionId = "ins_109508";
var (user, publicToken) = await ExecuteAsync(async (context, ct, sp) => var (user, publicToken) = await Api.ExecuteAsync(async (context, ct, sp) =>
{ {
var plaidClient = sp.GetRequiredService<PlaidClient>(); var plaidClient = sp.GetRequiredService<PlaidClient>();
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
@@ -166,7 +171,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedUser = await ExecuteAsync( var updatedUser = await Api.ExecuteAsync(
async (context, ct) => await context.Set<User>() async (context, ct) => await context.Set<User>()
.Include(u => u.Institutions) .Include(u => u.Institutions)
.ThenInclude(i => i.Metadata) .ThenInclude(i => i.Metadata)
@@ -179,7 +184,7 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
var institution = updatedUser.Institutions.First(); var institution = updatedUser.Institutions.First();
institution.Name.Should().NotBeNullOrEmpty(); institution.Name.Should().NotBeNullOrEmpty();
var metadata = institution.Metadata.As<PlaidMetadata>(); var metadata = institution.Metadata.As<PlaidInstitutionMetadata>();
metadata.PlaidId.Should().Be(plaidInstitutionId); metadata.PlaidId.Should().Be(plaidInstitutionId);
metadata.PlaidName.Should().Be(institution.Name); metadata.PlaidName.Should().Be(institution.Name);
metadata.EncryptedAccessToken.Should().NotBeNullOrEmpty(); metadata.EncryptedAccessToken.Should().NotBeNullOrEmpty();
@@ -39,7 +39,7 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task GetAvailable_WhenCalledWithNonExistentInstitutionId_ItShouldReturn404WithProblemDetails() public async Task GetAvailable_WhenCalledWithNonExistentInstitutionId_ItShouldReturn404WithProblemDetails()
{ {
var user = await ExecuteAsync(static async (context, ct, sp) => var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -65,7 +65,7 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task GetAvailable_WhenCalledWithConnectedInstitution_ItShouldReturn200WithAvailableAccounts() public async Task GetAvailable_WhenCalledWithConnectedInstitution_ItShouldReturn200WithAvailableAccounts()
{ {
var (user, institution, expectedAccounts) = await ExecuteAsync(static async (context, ct, sp) => var (user, institution, expectedAccounts) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -92,7 +92,12 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
AccessToken = exchangeTokenResponse.AccessToken, AccessToken = exchangeTokenResponse.AccessToken,
}); });
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, exchangeTokenResponse.AccessToken, ct); 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,
exchangeTokenResponse.ItemId
);
var institution = Institution.From("Some Bank", institutionMetadata); var institution = Institution.From("Some Bank", institutionMetadata);
user.AddInstitution(institution); user.AddInstitution(institution);
@@ -110,7 +115,8 @@ public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
var expectedResponseAccounts = expectedAccounts.Select(a => AvailableAccountDto.From((PlaidInstitutionMetadata)institution.Metadata!, a));
(await response.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK)) (await response.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK))
.Which.Accounts.Should().BeEquivalentTo(expectedAccounts.Select(AvailableAccountDto.FromPlaidAccount)); .Which.Accounts.Should().BeEquivalentTo(expectedResponseAccounts);
} }
} }
@@ -36,7 +36,7 @@ public class GetTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Get_WhenCalledByUser_ItShouldReturn200WithListOfInstitutions() public async Task Get_WhenCalledByUser_ItShouldReturn200WithListOfInstitutions()
{ {
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) => var (user, institution) = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -45,7 +45,12 @@ public class GetTests(TestApi testApi) : IntegrationTest(testApi)
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", 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,
"itemId"
);
var institution = Institution.From("Some Bank", plaidMetadata); var institution = Institution.From("Some Bank", plaidMetadata);
user.AddInstitution(institution); user.AddInstitution(institution);
@@ -32,7 +32,7 @@ public class LinkTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Link_WhenCalledWithUserWhoExists_ItShouldReturnLinkToken() public async Task Link_WhenCalledWithUserWhoExists_ItShouldReturnLinkToken()
{ {
var user = await ExecuteAsync(static async (context, ct, sp) => var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
@@ -2,51 +2,17 @@ namespace FiscalOS.API.Tests.Integration;
public abstract class IntegrationTest(TestApi testApi) : IClassFixture<TestApi>, IAsyncLifetime public abstract class IntegrationTest(TestApi testApi) : IClassFixture<TestApi>, IAsyncLifetime
{ {
public HttpClient Client => testApi.CreateClient(); protected TestApi Api => testApi;
protected HttpClient Client => testApi.CreateClient();
public async ValueTask InitializeAsync() public async ValueTask InitializeAsync()
{ {
await ExecuteAsync(static async (context, ct) => await Api.EnsureDbCreatedAsync();
{
await context.Database.EnsureCreatedAsync(ct);
}, TestContext.Current.CancellationToken);
}
protected async Task ExecuteAsync(Func<DbContext, CancellationToken, Task> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct);
}
protected async Task ExecuteAsync(Func<DbContext, CancellationToken, IServiceProvider, Task> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, ct, scope.ServiceProvider);
}
protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, Task<T>> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct);
}
protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, IServiceProvider, Task<T>> action, CancellationToken ct)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, ct, scope.ServiceProvider);
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await ExecuteAsync(static async (context, ct) => await Api.EnsureDbDeletedAsync();
{
await context.Database.EnsureDeletedAsync(ct);
}, TestContext.Current.CancellationToken);
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
@@ -1,6 +1,3 @@
using Aspire.Hosting;
using Aspire.Hosting.Testing;
namespace FiscalOS.AppHost.Tests.Infra; namespace FiscalOS.AppHost.Tests.Infra;
public sealed class AspireFixture : IAsyncLifetime public sealed class AspireFixture : IAsyncLifetime
+3 -1
View File
@@ -1,4 +1,6 @@
global using Aspire.Hosting;
global using Aspire.Hosting.Testing;
global using FiscalOS.AppHost.Tests.Infra; global using FiscalOS.AppHost.Tests.Infra;
global using Microsoft.Playwright; global using Microsoft.Playwright;
global using Microsoft.Playwright.Xunit.v3;
@@ -0,0 +1,62 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class AccountBuilder
{
private string _name = "accountName";
private AccountMetadata? _metadata;
private readonly List<Transaction> _transactions = [];
private AccountBuilder()
{
}
public static AccountBuilder Create()
{
return new();
}
public AccountBuilder WithName(string name)
{
_name = name;
return this;
}
public AccountBuilder WithMetadata(Action<AccountMetadataBuilder>? action = null)
{
var imb = AccountMetadataBuilder.Create();
action?.Invoke(imb);
_metadata = imb.Build();
return this;
}
public AccountBuilder WithMetadata(AccountMetadata metadata)
{
_metadata = metadata;
return this;
}
public AccountBuilder WithTransaction(Action<TransactionBuilder>? action = null)
{
var tb = TransactionBuilder.Create();
action?.Invoke(tb);
_transactions.Add(tb.Build());
return this;
}
public Account Build()
{
if (_metadata is null)
{
throw new InvalidOperationException($"You must call {nameof(WithMetadata)} prior to building");
}
var account = Account.From(_name, _metadata);
foreach (var transaction in _transactions)
{
account.AddTransaction(transaction);
}
return account;
}
}
@@ -0,0 +1,33 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class AccountMetadataBuilder
{
private string _plaidId = "plaidId";
private string _plaidName = "plaidName";
private AccountMetadataBuilder()
{
}
public static AccountMetadataBuilder Create()
{
return new();
}
public AccountMetadataBuilder WithPlaidId(string plaidId)
{
_plaidId = plaidId;
return this;
}
public AccountMetadataBuilder WithPlaidName(string plaidName)
{
_plaidName = plaidName;
return this;
}
public PlaidAccountMetadata Build()
{
return PlaidAccountMetadata.From(_plaidId, _plaidName);
}
}
@@ -0,0 +1,59 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class InstitutionBuilder
{
private string _name = "institutionName";
private InstitutionMetadata? _metadata;
private readonly List<Account> _accounts = [];
private InstitutionBuilder()
{
}
public static InstitutionBuilder Create()
{
return new();
}
public InstitutionBuilder WithName(string name)
{
_name = name;
return this;
}
public InstitutionBuilder WithMetadata(Action<InstitutionMetadataBuilder>? action = null)
{
var imb = InstitutionMetadataBuilder.Create();
action?.Invoke(imb);
_metadata = imb.Build();
return this;
}
public InstitutionBuilder WithAccount(Action<AccountBuilder>? action = null)
{
var ab = AccountBuilder.Create();
action?.Invoke(ab);
_accounts.Add(ab.Build());
return this;
}
public Institution Build()
{
if (_metadata is null)
{
throw new InvalidOperationException($"You must call {nameof(WithMetadata)} prior to building");
}
var institution = Institution.From(
_name,
_metadata
);
foreach (var account in _accounts)
{
institution.AddAccount(account);
}
return institution;
}
}
@@ -0,0 +1,52 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class InstitutionMetadataBuilder
{
private string _plaidId = "plaidId";
private string _plaidName = "plaidName";
private string _accessToken = "encryptedAccessToken";
private string _itemId = "itemId";
private InstitutionMetadataBuilder()
{
}
public static InstitutionMetadataBuilder Create()
{
return new();
}
public InstitutionMetadataBuilder WithPlaidId(string plaidId)
{
_plaidId = plaidId;
return this;
}
public InstitutionMetadataBuilder WithPlaidName(string plaidName)
{
_plaidName = plaidName;
return this;
}
public InstitutionMetadataBuilder WithAccessToken(string accessToken)
{
_accessToken = accessToken;
return this;
}
public InstitutionMetadataBuilder WithItemId(string itemId)
{
_itemId = itemId;
return this;
}
public PlaidInstitutionMetadata Build()
{
return PlaidInstitutionMetadata.From(
_plaidId,
_plaidName,
_accessToken,
_itemId
);
}
}
@@ -0,0 +1,67 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class TransactionBuilder
{
private string _merchantName = "merchantName";
private string _description = "description";
private decimal _amount = 100;
private DateTimeOffset _date = DateTimeOffset.UtcNow;
private TransactionMetadata? _metadata;
private TransactionBuilder()
{
}
public static TransactionBuilder Create()
{
return new();
}
public TransactionBuilder WithMerchantName(string merchantName)
{
_merchantName = merchantName;
return this;
}
public TransactionBuilder WithDescription(string description)
{
_description = description;
return this;
}
public TransactionBuilder WithAmount(decimal amount)
{
_amount = amount;
return this;
}
public TransactionBuilder WithDate(DateTimeOffset date)
{
_date = date;
return this;
}
public TransactionBuilder WithMetadata(Action<TransactionMetadataBuilder>? action = null)
{
var tmb = TransactionMetadataBuilder.Create();
action?.Invoke(tmb);
_metadata = tmb.Build();
return this;
}
public Transaction Build()
{
if (_metadata is null)
{
throw new InvalidOperationException($"You must call {nameof(WithMetadata)} prior to building");
}
return Transaction.From(
_merchantName,
_description,
_amount,
_date,
_metadata
);
}
}
@@ -0,0 +1,26 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class TransactionMetadataBuilder
{
private string _plaidId = "plaidId";
private TransactionMetadataBuilder()
{
}
public static TransactionMetadataBuilder Create()
{
return new();
}
public TransactionMetadataBuilder WithPlaidId(string plaidId)
{
_plaidId = plaidId;
return this;
}
public PlaidTransactionMetadata Build()
{
return PlaidTransactionMetadata.From(_plaidId);
}
}
@@ -0,0 +1,73 @@
namespace FiscalOS.Infra.Tests.Data;
internal sealed class UserBuilder
{
private string _username = "username";
private string _password = "hashedPassword";
private EncryptedDataKey _dataKey = EncryptedDataKey.From(
"keyUsed",
"encryptedKey"
);
private readonly List<Institution> _instituions = [];
private UserBuilder()
{
}
public static UserBuilder Create()
{
return new();
}
public UserBuilder WithUsername(string username)
{
_username = username;
return this;
}
public UserBuilder WithPassword(string password)
{
_password = password;
return this;
}
public UserBuilder WithDataKey(EncryptedDataKey key)
{
_dataKey = key;
return this;
}
public UserBuilder WithInstitution(Action<InstitutionBuilder>? action = null)
{
var ib = InstitutionBuilder.Create();
action?.Invoke(ib);
_instituions.Add(ib.Build());
return this;
}
public User Build()
{
var user = User.From(
_username,
_password,
_dataKey
);
foreach (var institution in _instituions)
{
user.AddInstitution(institution);
foreach (var account in institution.Accounts)
{
user.AddAccount(account);
foreach (var transaction in account.Transactions)
{
user.AddTransaction(transaction);
}
}
}
return user;
}
}
@@ -0,0 +1,69 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Integration;
public abstract class IntegrationTest : IAsyncLifetime
{
private bool _isDisposed;
private readonly FileSystem _fileSystem = new();
protected AppDbContext AppDbContext { get; }
protected IntegrationTest()
{
var options = Options.Create(new AppDbContextOptions()
{
DatabaseFilePath = $"{Guid.NewGuid()}.db",
});
AppDbContext = new(options, _fileSystem);
}
protected async Task<User> CreateTestUserAsync()
{
var user = UserBuilder.Create()
.WithInstitution(static ib =>
{
ib.WithMetadata();
ib.WithAccount(static ab =>
{
ab.WithMetadata();
ab.WithTransaction(static tb =>
{
tb.WithMetadata();
});
});
})
.Build();
await AppDbContext.AddAsync(user, TestContext.Current.CancellationToken);
await AppDbContext.SaveChangesAsync(TestContext.Current.CancellationToken);
return user;
}
public async ValueTask InitializeAsync()
{
await AppDbContext.Database.MigrateAsync();
}
public async ValueTask DisposeAsync()
{
await DisposeAsync(true);
GC.SuppressFinalize(this);
}
protected virtual async Task DisposeAsync(bool disposing)
{
if (_isDisposed)
{
return;
}
if (disposing)
{
await AppDbContext.DisposeAsync();
}
_isDisposed = true;
}
}
@@ -0,0 +1,79 @@
namespace FiscalOS.Infra.Tests.Integration;
public class PlaidAddedTransactionHandlerTests : IntegrationTest
{
private readonly Mock<ILogger<PlaidAddedTransactionHandler>> _mockLogger = new();
private readonly PlaidAddedTransactionHandler _sut;
public PlaidAddedTransactionHandlerTests()
{
_sut = PlaidAddedTransactionHandler.From(_mockLogger.Object, AppDbContext);
}
[Fact]
public async Task HandleAsync_WhenCalledWithNewTransaction_ItShouldAddTheTransaction()
{
var user = await CreateTestUserAsync();
var account = user.Accounts.First();
var transaction = new Going.Plaid.Entity.Transaction()
{
TransactionId = Guid.NewGuid().ToString(),
MerchantName = "Test Merchant",
OriginalDescription = "Test transaction",
Amount = 100,
Datetime = DateTimeOffset.UtcNow,
Pending = false,
};
var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken);
result.Should().Be(1);
account.Transactions.Should().HaveCount(2);
}
[Fact]
public async Task HandleAsync_WhenCalledWithPendingTransaction_ItShouldSkipTheTransaction()
{
var user = await CreateTestUserAsync();
var account = user.Accounts.First();
var transaction = new Going.Plaid.Entity.Transaction()
{
TransactionId = Guid.NewGuid().ToString(),
MerchantName = "Test Merchant",
OriginalDescription = "Test transaction",
Amount = 100,
Datetime = DateTimeOffset.UtcNow,
Pending = true,
};
var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken);
result.Should().Be(1);
account.Transactions.Should().HaveCount(1);
}
[Fact]
public async Task HandleAsync_WhenCalledWithExistingTransaction_ItShouldNotAddTheTransactionToTheDatabase()
{
var user = await CreateTestUserAsync();
var account = user.Accounts.First();
var existingTransaction = user.Transactions.First();
var transaction = new Going.Plaid.Entity.Transaction()
{
TransactionId = ((PlaidTransactionMetadata)existingTransaction.Metadata!).PlaidId,
MerchantName = "Test Merchant",
OriginalDescription = "Test transaction",
Amount = 100,
Datetime = DateTimeOffset.UtcNow,
Pending = false,
};
var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken);
result.Should().Be(1);
account.Transactions.Should().HaveCount(1);
}
}
@@ -0,0 +1,35 @@
namespace FiscalOS.Infra.Tests.Integration;
public class PlaidModifiedTransactionHandlerTests : IntegrationTest
{
private readonly Mock<ILogger<PlaidModifiedTransactionHandler>> _mockLogger = new();
private readonly PlaidModifiedTransactionHandler _sut;
public PlaidModifiedTransactionHandlerTests()
{
_sut = PlaidModifiedTransactionHandler.From(_mockLogger.Object, AppDbContext);
}
[Fact]
public async Task HandleAsync_WhenCalledWithExistingTransaction_ItShouldUpdateTheTransaction()
{
var user = await CreateTestUserAsync();
var account = user.Accounts.First();
var existingTransaction = account.Transactions.First();
var transaction = new Going.Plaid.Entity.Transaction()
{
TransactionId = ((PlaidTransactionMetadata)existingTransaction.Metadata!).PlaidId,
MerchantName = Guid.NewGuid().ToString(),
OriginalDescription = "Test transaction",
Amount = 100,
Datetime = DateTimeOffset.UtcNow,
Pending = false,
};
var result = await _sut.HandleAsync(account, [transaction], TestContext.Current.CancellationToken);
result.Should().Be(1);
existingTransaction.MerchantName.Should().Be(transaction.MerchantName);
}
}
@@ -0,0 +1,28 @@
namespace FiscalOS.Infra.Tests.Integration;
public class PlaidRemovedTransactionHandlerTests : IntegrationTest
{
private readonly Mock<ILogger<PlaidRemovedTransactionHandler>> _mockLogger = new();
private readonly PlaidRemovedTransactionHandler _sut;
public PlaidRemovedTransactionHandlerTests()
{
_sut = PlaidRemovedTransactionHandler.From(AppDbContext, _mockLogger.Object);
}
[Fact]
public async Task HandleAsync_WhenCalled_ItShouldRemoveTheCorrectTransactions()
{
var user = await CreateTestUserAsync();
var testTransaction = user.Transactions.First();
var transactionToRemove = new Going.Plaid.Entity.RemovedTransaction()
{
TransactionId = ((PlaidTransactionMetadata)testTransaction.Metadata!).PlaidId,
};
var result = await _sut.HandleAsync([transactionToRemove], TestContext.Current.CancellationToken);
result.Should().Be(1);
}
}
@@ -0,0 +1,74 @@
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionProcessorTests
{
private readonly Mock<IPlaidAddedTransactionHandler> _mockAddedHandler = new();
private readonly Mock<IPlaidModifiedTransactionHandler> _mockModifiedHandler = new();
private readonly Mock<IPlaidRemovedTransactionHandler> _mockRemovedHandler = new();
private readonly PlaidTransactionProcessor _sut;
public PlaidTransactionProcessorTests()
{
_sut = PlaidTransactionProcessor.From(
_mockAddedHandler.Object,
_mockModifiedHandler.Object,
_mockRemovedHandler.Object
);
}
[Theory]
[InlineData(0, 0, 0, false)]
[InlineData(1, 1, 1, true)]
[InlineData(0, 1, 1, false)]
[InlineData(1, 0, 1, false)]
[InlineData(1, 1, 0, false)]
public async Task ProcessAsync_WhenCalledAndTransactionsAreHandled_ItShouldReturnCorrectResult(
int numAdded,
int numModified,
int numRemoved,
bool expectedResult
)
{
_mockAddedHandler
.Setup(
static m => m.HandleAsync(
It.IsAny<Account>(),
It.IsAny<IEnumerable<Going.Plaid.Entity.Transaction>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(numAdded);
_mockModifiedHandler
.Setup(
static m => m.HandleAsync(
It.IsAny<Account>(),
It.IsAny<IEnumerable<Going.Plaid.Entity.Transaction>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(numModified);
_mockRemovedHandler
.Setup(
static m => m.HandleAsync(
It.IsAny<IEnumerable<Going.Plaid.Entity.RemovedTransaction>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(numRemoved);
var metadata = PlaidAccountMetadata.From("plaidId", "plaidName");
var account = Account.From("name", metadata);
var syncResponse = new TransactionsSyncResponse()
{
Added = [new()],
Modified = [new()],
Removed = [new()],
};
var result = await _sut.ProcessAsync(account, syncResponse, TestContext.Current.CancellationToken);
result.Should().Be(expectedResult);
}
}
@@ -0,0 +1,295 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionSyncerTests
{
private readonly Mock<ILogger<PlaidTransactionSyncer>> _mockLogger = new();
private readonly Mock<IPlaidTransactionService> _mockPlaidTransactionService = new();
private readonly Mock<IPlaidTransactionProcessor> _mockPlaidTransactionProcessor = new();
private readonly PlaidTransactionSyncer _sut;
public PlaidTransactionSyncerTests()
{
_sut = PlaidTransactionSyncer.From(
_mockLogger.Object,
_mockPlaidTransactionService.Object,
_mockPlaidTransactionProcessor.Object
);
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenAccountHasNoPlaidMetadata_ItShouldNotCallService()
{
var account = AccountBuilder.Create()
.WithName("no-plaid")
.WithMetadata(new DummyMetadata())
.Build();
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionService.Verify(
s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()),
Times.Never()
);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
Times.Never()
);
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenResponseIsUnsuccessful_ItShouldStopProcessing()
{
var account = AccountBuilder.Create()
.WithName("acct")
.WithMetadata(amb =>
{
amb.WithPlaidId("plaid-1")
.WithPlaidName("Plaid One");
})
.Build();
var response = new TransactionsSyncResponse()
{
RequestId = "req-1",
Error = new() { ErrorMessage = "boom" }
};
SetStatusCode(response, HttpStatusCode.InternalServerError);
_mockPlaidTransactionService
.Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenResponseHasNoAccounts_ItShouldStopProcessing()
{
var account = AccountBuilder.Create()
.WithName("acct")
.WithMetadata(amb =>
{
amb.WithPlaidId("plaid-2")
.WithPlaidName("Plaid Two");
})
.Build();
var response = new TransactionsSyncResponse()
{
RequestId = "req-2",
Accounts = []
};
SetStatusCode(response, HttpStatusCode.OK);
_mockPlaidTransactionService
.Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenResponseMissingMatchingAccount_ItShouldStopProcessing()
{
var account = AccountBuilder.Create()
.WithName("acct")
.WithMetadata(amb =>
{
amb.WithPlaidId("plaid-3")
.WithPlaidName("Plaid Three");
})
.Build();
var response = new TransactionsSyncResponse()
{
RequestId = "req-3",
Accounts = [new() { AccountId = "other" }]
};
SetStatusCode(response, HttpStatusCode.OK);
_mockPlaidTransactionService
.Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
_mockPlaidTransactionProcessor.Verify(
p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenProcessorReturnsFalse_ItShouldLeaveCursorUnchangedButAddsBalance()
{
var account = AccountBuilder.Create()
.WithName("acct")
.WithMetadata(amb =>
{
amb.WithPlaidId("plaid-4")
.WithPlaidName("Plaid Four");
})
.Build();
var response = new TransactionsSyncResponse()
{
RequestId = "req-4",
Accounts = [
new()
{
AccountId = "plaid-4",
Balances = new() { Current = 123.45m, Available = 100.00m, IsoCurrencyCode = "USD" }
}
],
NextCursor = "next-1"
};
SetStatusCode(response, HttpStatusCode.OK);
_mockPlaidTransactionService
.Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
_mockPlaidTransactionProcessor
.Setup(p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
account.Balances.Should().HaveCount(1);
((PlaidAccountMetadata)account.Metadata!).Cursor.Should().BeNull();
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenProcessorSucceeds_ItShouldUpdateCursorAndAddsBalance()
{
var account = AccountBuilder.Create()
.WithName("acct")
.WithMetadata(amb =>
{
amb.WithPlaidId("plaid-5")
.WithPlaidName("Plaid Five");
})
.Build();
var response = new TransactionsSyncResponse()
{
RequestId = "req-5",
Accounts = [
new()
{
AccountId = "plaid-5",
Balances = new() { Current = 200m, Available = 150m, IsoCurrencyCode = "EUR" }
}
],
NextCursor = "cursor-5"
};
SetStatusCode(response, HttpStatusCode.OK);
_mockPlaidTransactionService
.Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(response));
_mockPlaidTransactionProcessor
.Setup(p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
account.Balances.Should().HaveCount(1);
var balance = account.Balances.Should().ContainSingle().Which;
balance.Current.Should().Be(200m);
balance.Available.Should().Be(150m);
balance.CurrencyCode.Should().Be("EUR");
((PlaidAccountMetadata)account.Metadata!).Cursor.Should().Be("cursor-5");
}
[Fact]
public async Task SyncTransactionsForAccountAsync_WhenMultipleSuccessfulResponses_ItShouldProcessAllAndUseLastCursor()
{
var account = AccountBuilder.Create()
.WithName("acct")
.WithMetadata(amb =>
{
amb.WithPlaidId("plaid-6")
.WithPlaidName("Plaid Six");
})
.Build();
var r1 = new TransactionsSyncResponse()
{
RequestId = "req-6-1",
Accounts = [
new()
{
AccountId = "plaid-6",
Balances = new() { Current = 10m, Available = 10m, IsoCurrencyCode = "USD" }
}
],
NextCursor = "cursor-6-1"
};
SetStatusCode(r1, HttpStatusCode.OK);
var r2 = new TransactionsSyncResponse()
{
RequestId = "req-6-2",
Accounts = [
new()
{
AccountId = "plaid-6",
Balances = new() { Current = 20m, Available = 20m, IsoCurrencyCode = "USD" }
}
],
NextCursor = "cursor-6-2"
};
SetStatusCode(r2, HttpStatusCode.OK);
_mockPlaidTransactionService
.Setup(s => s.SyncTransactionsForAccountAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(AsyncResponses(r1, r2));
_mockPlaidTransactionProcessor
.Setup(p => p.ProcessAsync(It.IsAny<Account>(), It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
await _sut.SyncTransactionsForAccountAsync(account, "token", CancellationToken.None);
account.Balances.Should().HaveCount(2);
((PlaidAccountMetadata)account.Metadata!).Cursor.Should().Be("cursor-6-2");
_mockPlaidTransactionProcessor.Verify(p => p.ProcessAsync(account, It.IsAny<TransactionsSyncResponse>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
private static void SetStatusCode(ResponseBase response, HttpStatusCode statusCode) =>
typeof(ResponseBase)
.GetProperty(nameof(ResponseBase.StatusCode))!
.GetSetMethod(nonPublic: true)!
.Invoke(response, [statusCode]);
private static async IAsyncEnumerable<TransactionsSyncResponse> AsyncResponses(params TransactionsSyncResponse[] responses)
{
foreach (var r in responses)
{
yield return r;
}
}
private sealed class DummyMetadata : AccountMetadata
{
public DummyMetadata() : base("dummy")
{
}
}
}

Some files were not shown because too many files have changed in this diff Show More