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
Stevan Freeborn 48e95e5242 refactor(api.tests): updated model names 2026-02-24 06:55:37 -06:00
Stevan Freeborn d0b0097032 feat(web): handle adding accounts to institution 2026-02-24 06:55:37 -06:00
Stevan Freeborn 81c45350e8 feat(api): include accounts in institution dto 2026-02-24 06:55:37 -06:00
Stevan Freeborn 2fa34fe5ab feat(infra): updated modeling to reflect new navigation property 2026-02-24 06:55:37 -06:00
Stevan Freeborn 0815620028 feat(core): added navigation property to institution for accounts 2026-02-24 06:55:37 -06:00
Stevan Freeborn d5b6fb3a38 tests(apphost.tests): added support for end to end testing using playwright 2026-02-23 05:56:34 -06:00
Stevan Freeborn fd2cd68d73 feat(web,api): added ability to add and then display list of institutions 2026-02-22 06:53:13 -06:00
Stevan Freeborn b4fbf16869 feat(web): finish implementing login 2026-02-21 07:32:28 -06:00
Stevan Freeborn d9130e75a4 feat(web): make things work
- setup basic login form
- setup basic add institution functionality
2026-02-21 07:32:28 -06:00
Stevan Freeborn e2537d77c4 feat(web): working on login style 2026-02-21 07:32:28 -06:00
Stevan Freeborn fcbc9e78c2 style(web): add fonts 2026-02-21 07:32:28 -06:00
Stevan Freeborn 9042cd4ead feat(web): use user store and auth service to handle logging in and persistenting user state 2026-02-21 07:32:28 -06:00
Stevan Freeborn 1c3de212ff chore(web): configure prettier to my preferences 2026-02-21 07:32:28 -06:00
Stevan Freeborn 72a534a8e0 feat(web): add composable for creating auth service 2026-02-21 07:32:28 -06:00
Stevan Freeborn 74e55b2f48 feat(web): implement user store 2026-02-21 07:32:28 -06:00
Stevan Freeborn a3dc58d19b feat(web): implement auth service 2026-02-21 07:32:28 -06:00
Stevan Freeborn 5a878bf4b3 feat(web): add small http client implementation around fetch 2026-02-21 07:32:28 -06:00
Stevan Freeborn dee331fb0d feat(web): implement initial logic to handle protected vs public routing 2026-02-21 07:32:28 -06:00
142 changed files with 54203 additions and 285 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_constructors = false: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_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
+3
View File
@@ -3,6 +3,9 @@
##
## Get latest from `dotnet new gitignore`
# coverage
coverage/
# appsettings
appsettings*.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}"
}
]
}
+7 -1
View File
@@ -1,4 +1,5 @@
{
"dotnet.defaultSolution": "FiscalOS.slnx",
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
@@ -9,5 +10,10 @@
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["Encryptor"],
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
}
}
+1
View File
@@ -12,6 +12,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj" />
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.Tests.csproj" />
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
</Folder>
+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>
</PropertyGroup>
<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="Microsoft.AspNetCore.Authentication.JwtBearer" 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;
internal static class Endpoint
@@ -13,15 +15,20 @@ internal static class Endpoint
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] IEncryptor encryptor,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> queue,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidMetadata && ((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
.ThenInclude(i => i.Metadata)
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
.ThenInclude(a => a.Metadata)
.AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
@@ -37,19 +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())
{
return Results.Conflict();
}
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
var account = Account.From(user.Institutions.First().Id, request.PlaidAccountName, accountMetadata);
var balance = Balance.From(request.AccountCurrentBalance, request.AccountAvailableBalance, request.AccountCurrencyCode);
account.AddBalance(balance);
var account = Account.From(request.PlaidAccountName, accountMetadata);
user.AddAccount(account);
user.Institutions.First().AddAccount(account);
await appDbContext.SaveChangesAsync(ct);
await queue.EnqueueAsync(new(plaidInstitutionMetadata.ItemId), ct);
return Results.Ok();
}
-9
View File
@@ -5,9 +5,6 @@ public record Request : IValidatableObject
public string PlaidInstitutionId { get; init; } = string.Empty;
public string PlaidAccountId { get; init; } = string.Empty;
public string PlaidAccountName { get; init; } = string.Empty;
public decimal AccountCurrentBalance { get; init; }
public decimal AccountAvailableBalance { get; init; }
public string AccountCurrencyCode { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
@@ -28,11 +25,5 @@ public record Request : IValidatableObject
var fieldName = nameof(PlaidAccountName);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(AccountCurrencyCode))
{
var fieldName = nameof(AccountCurrencyCode);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
}
@@ -1,3 +1,5 @@
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Institutions.Connect;
internal static class Endpoint
@@ -13,7 +15,7 @@ internal static class Endpoint
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IEncryptor encryptor,
CancellationToken ct
)
@@ -23,8 +25,8 @@ internal static class Endpoint
var user = await appDbContext.Users
.Include(u => u.Institutions
.Where(
i => i.Metadata is PlaidMetadata &&
((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId
i => i.Metadata is PlaidInstitutionMetadata &&
((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId
)
)
.ThenInclude(i => i.Metadata)
@@ -40,12 +42,11 @@ internal static class Endpoint
return Results.Conflict();
}
var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken);
var item = await plaidService.GetItemAsync(accessToken);
var (itemId, accessToken) = await plaidAccountService.ExchangeTokenAsync(request.PublicToken);
var item = await plaidAccountService.GetItemAsync(accessToken);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct);
var plaidMetadata = PlaidMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken);
var plaidMetadata = PlaidInstitutionMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken, itemId);
var institution = Institution.From(item.InstitutionName, plaidMetadata);
user.AddInstitution(institution);
@@ -0,0 +1,35 @@
namespace FiscalOS.API.Institutions.Get;
internal static class Endpoint
{
private const string Route = "/";
public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapGet(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions)
.ThenInclude(i => i.Accounts)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
{
return Results.Unauthorized();
}
var institutionDtos = user.Institutions
.Select(InstitutionDto.From)
.OrderBy(dto => dto.Name);
return Results.Ok(Response.From(institutionDtos));
}
}
@@ -0,0 +1,64 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Institutions.Get;
internal sealed record Response
{
public IEnumerable<InstitutionDto> Institutions { get; init; } = [];
[JsonConstructor]
private Response()
{
}
public static Response From(IEnumerable<InstitutionDto> institutions)
{
return new Response
{
Institutions = institutions,
};
}
}
internal sealed record AccountDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
private AccountDto()
{
}
public static AccountDto From(Account account)
{
return new()
{
Id = account.Id.ToString(),
Name = account.Name,
};
}
}
internal sealed record InstitutionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public IEnumerable<AccountDto> Accounts { get; init; } = [];
[JsonConstructor]
private InstitutionDto()
{
}
public static InstitutionDto From(Institution institution)
{
return new()
{
Id = institution.Id.ToString(),
Name = institution.Name,
Accounts = institution.Accounts.Select(AccountDto.From),
};
}
}
@@ -13,7 +13,7 @@ internal static class Endpoint
HttpContext httpContext,
[FromRoute] Guid id,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IEncryptor encryptor,
CancellationToken ct
)
@@ -37,14 +37,14 @@ internal static class Endpoint
var institution = user.Institutions.First();
if (institution.Metadata is not PlaidMetadata plaidMetadata)
if (institution.Metadata is not PlaidInstitutionMetadata plaidMetadata)
{
return Results.BadRequest();
}
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
var accounts = await plaidService.GetAccountsAsync(accessToken);
var accountsDtos = accounts.Select(AccountDto.FromPlaidAccount);
var accounts = await plaidAccountService.GetAccountsAsync(accessToken);
var accountsDtos = accounts.Select(a => AvailableAccountDto.From(plaidMetadata, a));
return Results.Ok(Response.From(accountsDtos));
}
@@ -2,14 +2,14 @@ namespace FiscalOS.API.Institutions.GetAvailable;
internal sealed record Response
{
public IEnumerable<AccountDto> Accounts { get; init; } = [];
public IEnumerable<AvailableAccountDto> Accounts { get; init; } = [];
[JsonConstructor]
private Response()
{
}
public static Response From(IEnumerable<AccountDto> accounts)
public static Response From(IEnumerable<AvailableAccountDto> accounts)
{
return new Response
{
@@ -18,8 +18,9 @@ internal sealed record Response
}
}
internal sealed record AccountDto
internal sealed record AvailableAccountDto
{
public string ProviderInstitutionId { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string ProviderName { get; init; } = string.Empty;
public decimal CurrentBalance { get; init; }
@@ -27,14 +28,18 @@ internal sealed record AccountDto
public string CurrencyCode { get; init; } = string.Empty;
[JsonConstructor]
private AccountDto()
private AvailableAccountDto()
{
}
public static AccountDto FromPlaidAccount(Going.Plaid.Entity.Account plaidAccount)
public static AvailableAccountDto From(
PlaidInstitutionMetadata plaidMetadata,
Going.Plaid.Entity.Account plaidAccount
)
{
return new AccountDto
return new AvailableAccountDto
{
ProviderInstitutionId = plaidMetadata.PlaidId,
ProviderId = plaidAccount.AccountId,
ProviderName = plaidAccount.Name,
AvailableBalance = plaidAccount.Available,
@@ -12,6 +12,7 @@ internal static class InstitutionsExtensions
institutionsGroup.MapConnectEndpoint();
institutionsGroup.MapGetAvailableEndpoint();
institutionsGroup.MapLinkEndpoint();
institutionsGroup.MapGetEndpoint();
return institutionsGroup;
}
@@ -12,7 +12,7 @@ internal static class Endpoint
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService
[FromServices] IPlaidAccountService plaidAccountService
)
{
var userId = httpContext.GetUserId();
@@ -24,7 +24,7 @@ internal static class Endpoint
return Results.Unauthorized();
}
var linkToken = await plaidService.CreateLinkTokenAsync(user.Id.ToString());
var linkToken = await plaidAccountService.CreateLinkTokenAsync(user.Id.ToString());
return Results.Ok(Response.From(linkToken));
}
+7 -2
View File
@@ -1,5 +1,3 @@
using FiscalOS.ServiceDefaults;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
@@ -7,6 +5,12 @@ builder.AddServiceDefaults();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(static options =>
{
options.SerializerOptions.Converters.Add(new EnumConverterFactory());
options.SerializerOptions.Converters.Add(new WebhookBaseConverter());
});
builder.Services.AddInfrastructure();
builder.Services.AddAuthentication(static o =>
@@ -41,5 +45,6 @@ app.MapDefaultEndpoints();
app.MapAuthEndpoints();
app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints();
app.MapTransactionsGroup();
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 -1
View File
@@ -10,16 +10,27 @@ global using FiscalOS.API.Auth.Refresh;
global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions;
global using FiscalOS.API.Institutions.Connect;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Institutions.GetAvailable;
global using FiscalOS.API.Institutions.Link;
global using FiscalOS.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.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
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.EntityFrameworkCore;
+2 -1
View File
@@ -21,6 +21,7 @@
},
"PlaidClientOptions": {
"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);
_console.MarkupLine($"Key generated with id [green]{keyRingEntry.KeyId}[/] generated successfully.");
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:
default:
break;
+2
View File
@@ -4,11 +4,13 @@ internal static class Commands
{
public const string CreateUser = "Create User";
public const string GenerateKey = "Generate Key";
public const string RemoveInstitution = "Remove Institution";
public const string Exit = "Exit";
public static readonly string[] All = [
CreateUser,
GenerateKey,
RemoveInstitution,
Exit
];
}
+4
View File
@@ -2,9 +2,13 @@ global using FiscalOS.AdminCLI;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
global using Going.Plaid;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
+5 -3
View File
@@ -1,10 +1,12 @@
using FiscalOS.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.FiscalOS_API>("API")
var api = builder.AddProject<Projects.FiscalOS_API>(ProjectNames.API)
.WithHttpHealthCheck("/health");
builder.AddViteApp("web", "../FiscalOS.Web")
builder.AddViteApp(ProjectNames.Web, "../FiscalOS.Web")
.WithReference(api)
.WithHttpsEndpoint(port: 7061, env: "PORT");
builder.Build().Run();
builder.Build().Run();
+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>
<ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" />
+7
View File
@@ -0,0 +1,7 @@
namespace FiscalOS.AppHost;
public static class ProjectNames
{
public const string API = "api";
public const string Web = "web";
}
+19 -3
View File
@@ -11,11 +11,20 @@ public sealed class Account : Entity
private readonly List<Balance> _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()
{
InstitutionId = institutionId,
Name = name,
Metadata = accountMetadata,
};
@@ -23,8 +32,15 @@ public sealed class Account : Entity
public void AddBalance(Balance balance)
{
ArgumentNullException.ThrowIfNull(balance);
ArgumentNullException.ThrowIfNull(balance, nameof(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
)
{
ArgumentNullException.ThrowIfNull(currencyCode, nameof(currencyCode));
return new()
{
Current = current,
+11
View File
@@ -3,9 +3,13 @@ namespace FiscalOS.Core.Accounts;
public sealed class Institution : Entity
{
public Guid UserId { get; init; }
public User? User { get; init; }
public string Name { get; init; } = string.Empty;
public InstitutionMetadata? Metadata { get; init; }
private readonly List<Account> _accounts = [];
public IEnumerable<Account> Accounts => _accounts;
private Institution()
{
}
@@ -21,4 +25,11 @@ public sealed class Institution : Entity
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 = [];
public IEnumerable<Account> Accounts => _accounts;
private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
private User()
{
}
@@ -60,4 +63,11 @@ public sealed class User : Entity
_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;
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
+1 -1
View File
@@ -1,4 +1,4 @@
[*.cs]
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA5404.severity = none
dotnet_diagnostic.CA5404.severity = none
@@ -5,12 +5,16 @@ public sealed class PlaidAccountMetadata : AccountMetadata
public const string TypeValue = Providers.Plaid;
public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty;
public string? Cursor { get; private set; }
private PlaidAccountMetadata() : base(TypeValue)
{
}
public static PlaidAccountMetadata From(string plaidId, string plaidName)
public static PlaidAccountMetadata From(
string plaidId,
string plaidName
)
{
return new()
{
@@ -18,4 +22,11 @@ public sealed class PlaidAccountMetadata : AccountMetadata
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;
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 PlaidService(PlaidClient client)
private PlaidAccountService(PlaidClient client, IOptions<PlaidClientOptions> options)
{
_client = client;
_options = options;
}
public static PlaidService From(IServiceProvider sp)
public static PlaidAccountService From(IServiceProvider sp)
{
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)
@@ -34,6 +41,7 @@ public sealed class PlaidService
{
ClientUserId = id,
},
Webhook = _options.Value.Webhook,
}).ConfigureAwait(false);
if (ltr.IsSuccessStatusCode is false)
@@ -88,4 +96,4 @@ public sealed class PlaidService
return ar.Item;
}
}
}
@@ -4,6 +4,7 @@ public sealed record PlaidClientOptions
{
public string ClientId { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public string Webhook { get; init; } = string.Empty;
public IOptions<PlaidOptions> ToPlaidOptions()
{
@@ -1,31 +1,35 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidMetadata : InstitutionMetadata
public sealed class PlaidInstitutionMetadata : InstitutionMetadata
{
public const string TypeValue = Providers.Plaid;
public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty;
public string EncryptedAccessToken { get; init; } = string.Empty;
public string ItemId { get; init; } = string.Empty;
private PlaidMetadata() : base(TypeValue)
private PlaidInstitutionMetadata() : base(TypeValue)
{
}
public static PlaidMetadata From(
public static PlaidInstitutionMetadata From(
string? plaidId,
string? plaidName,
string encryptedAccessToken
string encryptedAccessToken,
string itemId
)
{
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName));
ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken));
ArgumentNullException.ThrowIfNull(itemId, nameof(itemId));
return new PlaidMetadata
return new()
{
PlaidId = plaidId,
PlaidName = plaidName,
EncryptedAccessToken = encryptedAccessToken
EncryptedAccessToken = encryptedAccessToken,
ItemId = itemId,
};
}
}
@@ -1,4 +1,4 @@
namespace FiscalOS.Infra.Accounts.Plaid;
namespace FiscalOS.Infra.Common;
public class PlaidException : Exception
{
@@ -1,4 +1,4 @@
namespace FiscalOS.Infra.Accounts;
namespace FiscalOS.Infra.Common;
public static class Providers
{
+44 -4
View File
@@ -1,5 +1,6 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.Infra.Data;
@@ -14,6 +15,8 @@ public sealed class AppDbContext(
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Institution> Institutions => Set<Institution>();
public DbSet<Transaction> Transactions => Set<Transaction>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -63,7 +66,7 @@ public sealed class AppDbContext(
.OnDelete(DeleteBehavior.Cascade);
eb.HasMany(static u => u.Institutions)
.WithOne()
.WithOne(static i => i.User)
.HasForeignKey(static i => i.UserId)
.OnDelete(DeleteBehavior.Cascade);
@@ -96,19 +99,20 @@ public sealed class AppDbContext(
modelBuilder.Entity<InstitutionMetadata>(static eb =>
{
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.Type);
});
modelBuilder.Entity<PlaidMetadata>(static eb =>
modelBuilder.Entity<PlaidInstitutionMetadata>(static eb =>
{
eb.HasBaseType<InstitutionMetadata>();
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
eb.Property(static m => m.EncryptedAccessToken);
eb.Property(static m => m.ItemId);
});
modelBuilder.Entity<Account>(static eb =>
@@ -116,7 +120,7 @@ public sealed class AppDbContext(
eb.Property(static a => a.Name);
eb.HasOne(static a => a.Institution)
.WithMany()
.WithMany(static i => i.Accounts)
.HasForeignKey(static a => a.InstitutionId)
.OnDelete(DeleteBehavior.Cascade);
@@ -146,6 +150,7 @@ public sealed class AppDbContext(
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
eb.Property(static m => m.Cursor);
});
modelBuilder.Entity<Balance>(static eb =>
@@ -155,5 +160,40 @@ public sealed class AppDbContext(
eb.Property(static b => b.Available);
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();
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;
}
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#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.ToTable("Institution");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
@@ -241,10 +241,86 @@ namespace FiscalOS.Infra.Migrations
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");
@@ -256,7 +332,7 @@ namespace FiscalOS.Infra.Migrations
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
@@ -264,6 +340,10 @@ namespace FiscalOS.Infra.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
@@ -275,10 +355,21 @@ namespace FiscalOS.Infra.Migrations
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
.WithMany()
.WithMany("Accounts")
.HasForeignKey("InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -312,11 +403,13 @@ namespace FiscalOS.Infra.Migrations
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", null)
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Institutions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
@@ -339,15 +432,47 @@ namespace FiscalOS.Infra.Migrations
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");
});
@@ -358,6 +483,13 @@ namespace FiscalOS.Infra.Migrations
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,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.IO.Abstractions;
global using System.Reflection;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Threading.Channels;
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Common;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Queuing;
global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Transactions.Plaid;
global using Going.Plaid;
global using Going.Plaid.Entity;
global using Going.Plaid.Transactions;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Authorization;
@@ -59,7 +59,7 @@ public static class ServiceDefaultsExtensions
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(app, nameof(app));
if (app.Environment.IsDevelopment())
{
+12
View File
@@ -1,3 +1,15 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "es5",
"arrowParens": "avoid",
"bracketSameLine": false,
"jsxSingleQuote": true,
"embeddedLanguageFormatting": "auto",
"endOfLine": "auto",
"vueIndentScriptAndStyle": true,
"singleAttributePerLine": true
}
+205 -106
View File
@@ -8,7 +8,10 @@
"name": "fiscalos-web",
"version": "0.0.0",
"dependencies": {
"@jcss/vue-plaid-link": "^1.1.3",
"jwt-decode": "^4.0.0",
"pinia": "^3.0.4",
"ts-results": "^3.3.0",
"vue": "beta",
"vue-router": "^5.0.2"
},
@@ -1414,6 +1417,16 @@
"node": ">=12"
}
},
"node_modules/@jcss/vue-plaid-link": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@jcss/vue-plaid-link/-/vue-plaid-link-1.1.3.tgz",
"integrity": "sha512-h6uV2Muh0MdVKeOp/fY2/OYMgy9kQw7JA4HoE/PkfNutVsrO/foXxczLlReUhtPq2erdOepri3Du5W1CKxFc/g==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^10.7.2",
"vue": "^3.4.19"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2293,18 +2306,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/type-utils": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/type-utils": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
@@ -2317,8 +2336,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^8.57.0 || ^9.0.0",
"@typescript-eslint/parser": "^8.56.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
@@ -2333,16 +2352,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3"
},
"engines": {
@@ -2353,19 +2372,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.55.0",
"@typescript-eslint/types": "^8.55.0",
"@typescript-eslint/tsconfig-utils": "^8.56.0",
"@typescript-eslint/types": "^8.56.0",
"debug": "^4.4.3"
},
"engines": {
@@ -2380,14 +2399,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0"
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2398,9 +2417,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2415,15 +2434,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
@@ -2435,14 +2454,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2454,16 +2473,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.55.0",
"@typescript-eslint/tsconfig-utils": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/visitor-keys": "8.55.0",
"@typescript-eslint/project-service": "8.56.0",
"@typescript-eslint/tsconfig-utils": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3",
"minimatch": "^9.0.5",
"semver": "^7.7.3",
@@ -2495,16 +2514,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0"
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2514,19 +2533,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.55.0",
"eslint-visitor-keys": "^4.2.1"
"@typescript-eslint/types": "8.56.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2537,13 +2556,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -3012,22 +3031,22 @@
}
},
"node_modules/@vue/eslint-config-typescript": {
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.6.0.tgz",
"integrity": "sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==",
"version": "14.7.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.7.0.tgz",
"integrity": "sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.35.1",
"@typescript-eslint/utils": "^8.56.0",
"fast-glob": "^3.3.3",
"typescript-eslint": "^8.35.1",
"vue-eslint-parser": "^10.2.0"
"typescript-eslint": "^8.56.0",
"vue-eslint-parser": "^10.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": "^9.10.0",
"eslint": "^9.10.0 || ^10.0.0",
"eslint-plugin-vue": "^9.28.0 || ^10.0.0",
"typescript": ">=4.8.4"
},
@@ -3159,6 +3178,42 @@
}
}
},
"node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -3170,9 +3225,9 @@
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -3340,13 +3395,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
@@ -3868,9 +3926,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"dev": true,
"license": "ISC"
},
@@ -4111,13 +4169,13 @@
}
},
"node_modules/eslint-plugin-playwright": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.5.1.tgz",
"integrity": "sha512-q7oqVQTTfa3VXJQ8E+ln0QttPGrs/XmSO1FjOMzQYBMYF3btih4FIrhEYh34JF184GYDmq3lJ/n7CMa49OHBvA==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.7.0.tgz",
"integrity": "sha512-kUgwDZL3knnuJF53WSf5xNnB1aLPnX8furoh0PSrmmFIfMfIMmY3sNd4gtZ2MUUnaIX1/A9ndYtD7bhV1dj+1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"globals": "^16.4.0"
"globals": "^17.3.0"
},
"engines": {
"node": ">=16.9.0"
@@ -4127,9 +4185,9 @@
}
},
"node_modules/eslint-plugin-playwright/node_modules/globals": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5145,6 +5203,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5340,11 +5407,11 @@
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -6604,6 +6671,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-results": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/ts-results/-/ts-results-3.3.0.tgz",
"integrity": "sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==",
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6632,16 +6705,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.55.0",
"@typescript-eslint/parser": "8.55.0",
"@typescript-eslint/typescript-estree": "8.55.0",
"@typescript-eslint/utils": "8.55.0"
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6651,7 +6724,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
@@ -7243,6 +7316,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-eslint-parser": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
@@ -7294,14 +7393,14 @@
}
},
"node_modules/vue-router": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
"integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
"integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.28.6",
"@vue-macros/common": "^3.1.1",
"@vue/devtools-api": "^8.0.0",
"@vue/devtools-api": "^8.0.6",
"ast-walker-scope": "^0.8.3",
"chokidar": "^5.0.0",
"json5": "^2.2.3",
@@ -7456,9 +7555,9 @@
}
},
"node_modules/whatwg-url": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz",
"integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==",
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
+3
View File
@@ -17,7 +17,10 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@jcss/vue-plaid-link": "^1.1.3",
"jwt-decode": "^4.0.0",
"pinia": "^3.0.4",
"ts-results": "^3.3.0",
"vue": "beta",
"vue-router": "^5.0.2"
},
+2 -1
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<h1>FiscalOS</h1>
<RouterView />
</template>
<style scoped></style>
@@ -1,11 +0,0 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import App from "../App.vue";
describe("App", () => {
it("mounts renders properly", () => {
const wrapper = mount(App);
expect(wrapper.text()).toContain("You did it!");
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import LoginForm from '@/components/LoginForm.vue';
describe('LoginForm', () => {
it('should have username field', () => {
const wrapper = mount(LoginForm);
const username = wrapper.get('#username');
expect(username.element.tagName).toBe('INPUT');
expect(username.attributes()['type']).toBe('text');
});
it('should have password field', () => {
const wrapper = mount(LoginForm);
const password = wrapper.get('#password');
expect(password.element.tagName).toBe('INPUT');
expect(password.attributes()['type']).toBe('password');
});
it('should require username field on submission', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('Username is required');
});
it('should require password field on submission', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('Password is required');
});
it('should reset error state when new input is entered', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('Username is required');
expect(wrapper.text()).toContain('Password is required');
const usernameInput = wrapper.get('#username');
const passwordInput = wrapper.get('#password');
await usernameInput.setValue('Stevan');
await passwordInput.setValue('password');
expect(wrapper.text()).not.toContain('Username is required');
expect(wrapper.text()).not.toContain('Password is required');
});
it('should disable login button while submitting', async () => {
let resolveSubmit;
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
resolveSubmit = resolve;
}),
},
});
const usernameInput = wrapper.get('#username');
const passwordInput = wrapper.get('#password');
const submitButton = wrapper.get('button');
await usernameInput.setValue('Stevan');
await passwordInput.setValue('password');
await submitButton.trigger('click');
expect('disabled' in submitButton.attributes()).toBe(true);
resolveSubmit!();
await flushPromises();
expect('disabled' in submitButton.attributes()).toBe(false);
});
it('should call the onValidSubmit function when submitted with valid state', async () => {
let wasCalled = false;
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
wasCalled = true;
resolve();
}),
},
});
const usernameInput = wrapper.get('#username');
const passwordInput = wrapper.get('#password');
const submitButton = wrapper.get('button');
await usernameInput.setValue('Stevan');
await passwordInput.setValue('password');
await submitButton.trigger('click');
expect(wasCalled).toBe(true);
});
});
+71
View File
@@ -0,0 +1,71 @@
@import './reset.css';
@font-face {
font-family: 'CaskaydiaCove NFM';
src: url('../fonts/CaskaydiaCoveNFM-Regular.eot');
src:
url('../fonts/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
url('../fonts/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
url('../fonts/CaskaydiaCoveNFM-Regular.woff') format('woff'),
url('../fonts/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--palette-lavender: #b39cd0;
--palette-rose: #d09c9f;
--palette-sage: #b9d09c;
--palette-mint: #9cd0cd;
--neutral-white: #ffffff;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-800: #262626;
--neutral-900: #171717;
--neutral-black: #000000;
--bg-app: var(--neutral-white);
--bg-surface: var(--neutral-100);
--bg-element: var(--neutral-200);
--text-primary: var(--neutral-black);
--text-secondary: var(--neutral-800);
--border-subtle: var(--neutral-200);
--brand-primary: var(--palette-lavender);
--brand-secondary: var(--palette-rose);
--brand-accent: var(--palette-mint);
--state-success: var(--palette-sage);
--state-error: var(--palette-rose);
font-family: 'CaskaydiaCove NFM', monospace;
font-size: 16px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-app: var(--neutral-black);
--bg-surface: var(--neutral-900);
--bg-element: var(--neutral-800);
--text-primary: var(--neutral-white);
--text-secondary: var(--neutral-200);
--border-subtle: var(--neutral-800);
}
}
body {
color: var(--text-primary);
background: var(--bg-app);
}
html,
body,
#app {
height: 100%;
}
+44
View File
@@ -0,0 +1,44 @@
* {
margin: 0;
padding: 0;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
button {
background: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
input {
font-family: inherit;
font-size: inherit;
color: inherit;
}
a {
text-decoration: none;
color: inherit;
}
ul {
list-style-type: none;
}
img {
max-width: 100%;
display: block;
}
table {
width: 100%;
border-collapse: collapse;
}
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 6.4 MiB

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref } from 'vue';
export type LoginFormState = {
isLoggingIn: boolean;
username: {
value: string;
error: string;
};
password: {
value: string;
error: string;
};
};
const props = defineProps<{
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
}>();
const formState = ref<LoginFormState>({
isLoggingIn: false,
username: {
value: '',
error: '',
},
password: {
value: '',
error: '',
},
});
function handleUsernameInput(e: Event) {
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.username.error.trim()) {
formState.value.username.error = '';
}
}
function handlePasswordInput(e: Event) {
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.password.error.trim()) {
formState.value.password.error = '';
}
}
function validateFormState() {
let isValid = true;
if (!formState.value.username.value.trim()) {
formState.value.username.error = 'Username is required';
isValid = false;
}
if (!formState.value.password.value.trim()) {
formState.value.password.error = 'Password is required';
isValid = false;
}
return isValid;
}
async function handleSubmit() {
formState.value.isLoggingIn = true;
try {
if (validateFormState() === false) {
return;
}
if (props.onValidSubmit !== undefined) {
await props.onValidSubmit(formState.value);
}
} finally {
formState.value.isLoggingIn = false;
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="username">Username</label>
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
<div class="error">{{ formState.username.error }}</div>
</div>
<div>
<label for="password">Password</label>
<input type="password" name="password" id="password" :value="formState.password.value"
@input="handlePasswordInput" />
<div class="error">{{ formState.password.error }}</div>
</div>
<div>
<button type="submit" :disabled="formState.isLoggingIn">
Login
</button>
</div>
</form>
</template>
<style scoped>
form,
form>div {
display: flex;
flex-direction: column;
background-color: var(--bg-surface);
}
form {
gap: 1rem;
padding: 1rem;
border-radius: 0.25rem;
}
form>div {
gap: 0.25rem;
}
form>div label {
font-weight: 700;
}
form>div input {
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid black;
background-color: var(--bg-element);
}
form>div button {
background-color: var(--brand-primary);
padding: 0.5rem 0.25rem;
border-radius: 0.25rem;
}
form>div button:disabled {
opacity: 50%;
pointer-events: none;
}
form>div .error {
color: var(--state-error);
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const userStore = useUserStore();
const router = useRouter();
const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed,
}));
function handleToggleButtonClick() {
userStore.toggleSidebar();
}
function handleLogout() {
// TODO: Also need to log user
// out on the server...which is
// basically just clearing
// refresh token cookie and revoking
// it in the database
userStore.logUserOut();
router.push({ path: '/public/login' });
}
</script>
<template>
<aside :class="asideClasses">
<button
@click="handleToggleButtonClick"
type="button"
class="toggle-button"
>
<LeftArrowIcon />
</button>
<button
class="logout-button"
type="button"
@click="handleLogout"
>
Logout
</button>
</aside>
</template>
<style scoped>
aside {
--sidebar-width: 15.625rem;
--button-size: 1.5rem;
--transition-duration: 0.5s;
--transition-function: ease-in-out;
position: relative;
width: var(--sidebar-width);
height: 100%;
z-index: 999;
background: var(--bg-surface);
transition-property: width, transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
@media screen and (max-width: 48rem) {
aside {
position: absolute;
right: 0;
}
}
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 3rem;
left: calc(-1 * var(--button-size) / 2);
height: var(--button-size);
width: var(--button-size);
background: var(--bg-element);
border-radius: 50%;
transition-property: transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import NavSidebar from './NavSidebar.vue';
</script>
<template>
<div class="layout">
<main>
<RouterView />
</main>
<NavSidebar />
</div>
</template>
<style scoped>
.layout {
display: flex;
height: 100%;
position: relative;
}
main {
flex: 1;
padding: 1rem;
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<main>
<RouterView />
</main>
</template>
<style scoped>
main {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
</svg>
</template>
@@ -0,0 +1,27 @@
import { AuthServiceFactoryKey } from "@/services/authService";
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
export function useAuthService(store: UserStore) {
const clientFactory = inject(ClientFactoryKey);
const authServiceFactory = inject(AuthServiceFactoryKey);
if (clientFactory === undefined) {
throw new Error("Failed to inject client factory.")
}
if (authServiceFactory === undefined) {
throw new Error("Failed to inject auth service factory.")
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}`},
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
const authService = authServiceFactory.create(client);
return authService;
}
@@ -0,0 +1,27 @@
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import { InstituionServiceFactoryKey } from "@/services/institutionService";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
export function useInstitutionService(store: UserStore) {
const clientFactory = inject(ClientFactoryKey);
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
if (clientFactory === undefined) {
throw new Error("Failed to inject client factory.")
}
if (institutionServiceFactory === undefined) {
throw new Error("Failed to inject institution service factory.")
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}`},
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
const institutionService = institutionServiceFactory.create(client);
return institutionService;
}

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