Compare commits

..
135 changed files with 297 additions and 59427 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
}
-11
View File
@@ -1,11 +0,0 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
-14
View File
@@ -1,14 +0,0 @@
{
"dotnet.defaultSolution": "FiscalOS.slnx",
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+131
View File
@@ -0,0 +1,131 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API.Tests", "tests\FiscalOS.API.Tests\FiscalOS.API.Tests.csproj", "{1869E249-07F6-41CD-B143-AF86F022E4A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.AdminCLI", "src\FiscalOS.AdminCLI\FiscalOS.AdminCLI.csproj", "{C46818A0-1A92-4E4F-8390-48CBA9971745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core", "src\FiscalOS.Core\FiscalOS.Core.csproj", "{612496CD-1F25-4804-8C52-49E62D01FFDC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\FiscalOS.Infra\FiscalOS.Infra.csproj", "{DAB42870-02A2-4ECB-B07F-6815C52D2435}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core.Tests", "tests\FiscalOS.Core.Tests\FiscalOS.Core.Tests.csproj", "{2D9C02A9-C704-4275-88E9-1BBD3AC12700}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.Build.0 = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.Build.0 = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.Build.0 = Release|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.ActiveCfg = Debug|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.Build.0 = Debug|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.ActiveCfg = Debug|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.Build.0 = Debug|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.Build.0 = Release|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.ActiveCfg = Release|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.Build.0 = Release|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.ActiveCfg = Release|Any CPU
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{85E0C8F3-AE69-4012-836C-759E9D297762} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C46818A0-1A92-4E4F-8390-48CBA9971745} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{2D9C02A9-C704-4275-88E9-1BBD3AC12700} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
-21
View File
@@ -1,21 +0,0 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj" />
<Project Path="src/FiscalOS.API/FiscalOS.API.csproj" />
<Project Path="src/FiscalOS.Core/FiscalOS.Core.csproj" />
<Project Path="src/FiscalOS.Infra/FiscalOS.Infra.csproj" />
</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>
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
</Solution>
+1 -10
View File
@@ -3,8 +3,6 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.1.1" />
<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">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -14,12 +12,5 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.1.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
</ItemGroup>
</Project>
</Project>
@@ -1,16 +0,0 @@
namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions
{
private const string RouteGroupPrefix = "/accounts";
public static RouteGroupBuilder MapAccountsEndpoints(this WebApplication app)
{
var accountsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
accountsGroup.MapAddEndpoint();
return accountsGroup;
}
}
-56
View File
@@ -1,56 +0,0 @@
namespace FiscalOS.API.Accounts.Add;
internal static class Endpoint
{
private const string Route = "/";
public static RouteHandlerBuilder MapAddEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
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.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
.ThenInclude(a => a.Metadata)
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Institutions.Any() is false)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
});
}
if (user.Accounts.Any())
{
return Results.Conflict();
}
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
var account = Account.From(user.Institutions.First().Id, request.PlaidAccountName, accountMetadata);
var balance = Balance.From(request.AccountCurrentBalance, request.AccountAvailableBalance, request.AccountCurrencyCode);
account.AddBalance(balance);
user.AddAccount(account);
await appDbContext.SaveChangesAsync(ct);
return Results.Ok();
}
}
-38
View File
@@ -1,38 +0,0 @@
namespace FiscalOS.API.Accounts.Add;
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)
{
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
{
var fieldName = nameof(PlaidInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidAccountId))
{
var fieldName = nameof(PlaidAccountId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidAccountName))
{
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]);
}
}
}
-18
View File
@@ -1,18 +0,0 @@
namespace FiscalOS.API.Auth;
internal static class AuthExtensions
{
private const string RouteGroupPrefix = "/auth";
public static RouteGroupBuilder MapAuthEndpoints(this WebApplication app)
{
var authGroup = app.MapGroup(RouteGroupPrefix);
authGroup.MapLoginEndpoint();
authGroup.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
return authGroup;
}
}
-1
View File
@@ -10,7 +10,6 @@
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.ServiceDefaults\FiscalOS.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -1,3 +1,5 @@
using FiscalOS.Core.Identity;
namespace FiscalOS.API.Http;
internal static class HttpContextExtensions
@@ -1,57 +0,0 @@
namespace FiscalOS.API.Institutions.Connect;
internal static class Endpoint
{
private const string Route = "/connect";
public static RouteHandlerBuilder MapConnectEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService,
[FromServices] IEncryptor encryptor,
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
)
)
.ThenInclude(i => i.Metadata)
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Institutions.Any())
{
return Results.Conflict();
}
var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken);
var item = await plaidService.GetItemAsync(accessToken);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct);
var plaidMetadata = PlaidMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken);
var institution = Institution.From(item.InstitutionName, plaidMetadata);
user.AddInstitution(institution);
await appDbContext.SaveChangesAsync(ct);
return Results.Ok();
}
}
@@ -1,22 +0,0 @@
namespace FiscalOS.API.Institutions.Connect;
public record Request : IValidatableObject
{
public string PublicToken { get; init; } = string.Empty;
public string PlaidInstitutionId { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(PublicToken))
{
var fieldName = nameof(PublicToken);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
{
var fieldName = nameof(PlaidInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
}
@@ -1,34 +0,0 @@
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)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
{
return Results.Unauthorized();
}
var institutionDtos = user.Institutions
.Select(InstitutionDto.FromInstitution)
.OrderBy(dto => dto.Name);
return Results.Ok(Response.From(institutionDtos));
}
}
@@ -1,39 +0,0 @@
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 InstitutionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
private InstitutionDto()
{
}
public static InstitutionDto FromInstitution(Institution institution)
{
return new InstitutionDto
{
Id = institution.Id.ToString(),
Name = institution.Name,
};
}
}
@@ -1,51 +0,0 @@
namespace FiscalOS.API.Institutions.GetAvailable;
internal static class Endpoint
{
private const string Route = "/{id}/available";
public static RouteHandlerBuilder MapGetAvailableEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapGet(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromRoute] Guid id,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService,
[FromServices] IEncryptor encryptor,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Id == id))
.ThenInclude(i => i.Metadata)
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Institutions.Any() is false)
{
return Results.NotFound();
}
var institution = user.Institutions.First();
if (institution.Metadata is not PlaidMetadata 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);
return Results.Ok(Response.From(accountsDtos));
}
}
@@ -1,45 +0,0 @@
namespace FiscalOS.API.Institutions.GetAvailable;
internal sealed record Response
{
public IEnumerable<AccountDto> Accounts { get; init; } = [];
[JsonConstructor]
private Response()
{
}
public static Response From(IEnumerable<AccountDto> accounts)
{
return new Response
{
Accounts = accounts
};
}
}
internal sealed record AccountDto
{
public string ProviderId { get; init; } = string.Empty;
public string ProviderName { get; init; } = string.Empty;
public decimal CurrentBalance { get; init; }
public decimal AvailableBalance { get; init; }
public string CurrencyCode { get; init; } = string.Empty;
[JsonConstructor]
private AccountDto()
{
}
public static AccountDto FromPlaidAccount(Going.Plaid.Entity.Account plaidAccount)
{
return new AccountDto
{
ProviderId = plaidAccount.AccountId,
ProviderName = plaidAccount.Name,
AvailableBalance = plaidAccount.Available,
CurrentBalance = plaidAccount.Current,
CurrencyCode = plaidAccount.CurrencyCode,
};
}
}
@@ -1,19 +0,0 @@
namespace FiscalOS.API.Institutions;
internal static class InstitutionsExtensions
{
private const string RouteGroupPrefix = "/institutions";
public static RouteGroupBuilder MapInstitutionsEndpoints(this WebApplication app)
{
var institutionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
institutionsGroup.MapConnectEndpoint();
institutionsGroup.MapGetAvailableEndpoint();
institutionsGroup.MapLinkEndpoint();
institutionsGroup.MapGetEndpoint();
return institutionsGroup;
}
}
@@ -1,31 +0,0 @@
namespace FiscalOS.API.Institutions.Link;
internal static class Endpoint
{
private const string Route = "/link";
public static RouteHandlerBuilder MapLinkEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Id == userId);
if (user is null)
{
return Results.Unauthorized();
}
var linkToken = await plaidService.CreateLinkTokenAsync(user.Id.ToString());
return Results.Ok(Response.From(linkToken));
}
}
@@ -1,19 +0,0 @@
namespace FiscalOS.API.Institutions.Link;
public sealed record Response
{
public string LinkToken { get; init; } = string.Empty;
[JsonConstructor]
private Response()
{
}
public static Response From(string linkToken)
{
return new()
{
LinkToken = linkToken,
};
}
}
@@ -1,17 +1,17 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
internal static class Endpoint
{
private const string Route = "/login";
public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder)
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
{
return groupBuilder.MapPost(Route, HandleAsync);
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request loginRequest,
[FromBody] LoginRequest loginRequest,
[FromServices] AppDbContext appDbContext,
[FromServices] IPasswordHasher passwordHasher,
[FromServices] ITokenGenerator tokenService
@@ -1,6 +1,7 @@
namespace FiscalOS.API.Auth.Login;
public record Request : IValidatableObject
namespace FiscalOS.API.Login;
public record LoginRequest : IValidatableObject
{
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
@@ -9,14 +10,12 @@ public record Request : IValidatableObject
{
if (string.IsNullOrWhiteSpace(Username))
{
var fieldName = nameof(Username);
yield return new($"The {fieldName} field is required.", [fieldName]);
yield return new("The Username field is required.", [nameof(Username)]);
}
if (string.IsNullOrWhiteSpace(Password))
{
var fieldName = nameof(Password);
yield return new($"The {fieldName} field is required.", [fieldName]);
yield return new("The Password field is required.", [nameof(Password)]);
}
}
}
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
internal sealed record Response
{
+3 -8
View File
@@ -1,9 +1,5 @@
using FiscalOS.ServiceDefaults;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
@@ -36,10 +32,9 @@ app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePages();
app.MapDefaultEndpoints();
app.MapLoginEndpoint();
app.MapAuthEndpoints();
app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints();
app.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
app.Run();
@@ -18,6 +18,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
}
}
@@ -1,12 +1,12 @@
namespace FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Refresh;
internal static class Endpoint
{
private const string Route = "/refresh";
public static RouteHandlerBuilder MapRefreshEndpoint(this RouteGroupBuilder groupBuilder)
public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app)
{
return groupBuilder.MapPost(Route, HandleAsync);
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Refresh;
internal sealed record Response
{
+2 -14
View File
@@ -2,22 +2,10 @@ global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth;
global using FiscalOS.API.Auth.Login;
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.GetAvailable;
global using FiscalOS.API.Institutions.Link;
global using FiscalOS.Core.Accounts;
global using FiscalOS.API.Login;
global using FiscalOS.API.Refresh;
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.Authentication;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
+1 -1
View File
@@ -19,7 +19,7 @@
"KeysDirectoryPath": "KeysDirectoryPath",
"PrimaryKeyId": "PrimaryKeyId"
},
"PlaidClientOptions": {
"PlaidOptions": {
"ClientId": "ClientId",
"Secret": "Secret"
}
-12
View File
@@ -1,12 +0,0 @@
using FiscalOS.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.FiscalOS_API>(ProjectNames.API)
.WithHttpHealthCheck("/health");
builder.AddViteApp(ProjectNames.Web, "../FiscalOS.Web")
.WithReference(api)
.WithHttpsEndpoint(port: 7061, env: "PORT");
builder.Build().Run();
@@ -1,19 +0,0 @@
<Project Sdk="Aspire.AppHost.Sdk/13.1.1">
<ItemGroup>
<ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>80a27726-36cc-4a4d-a55d-11af527158bc</UserSecretsId>
</PropertyGroup>
</Project>
-7
View File
@@ -1,7 +0,0 @@
namespace FiscalOS.AppHost;
public static class ProjectNames
{
public const string API = "api";
public const string Web = "web";
}
@@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17104;http://localhost:15068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23228",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22081"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19221",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18206",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20097"
}
}
}
}
-30
View File
@@ -1,30 +0,0 @@
namespace FiscalOS.Core.Accounts;
public sealed class Account : Entity
{
public Guid UserId { get; init; }
public Guid InstitutionId { get; init; }
public Institution? Institution { get; init; }
public string Name { get; init; } = string.Empty;
public AccountMetadata? Metadata { get; init; }
private readonly List<Balance> _balances = [];
public IEnumerable<Balance> Balances => _balances;
public static Account From(Guid institutionId, string name, AccountMetadata accountMetadata)
{
return new()
{
InstitutionId = institutionId,
Name = name,
Metadata = accountMetadata,
};
}
public void AddBalance(Balance balance)
{
ArgumentNullException.ThrowIfNull(balance);
_balances.Add(balance);
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Core.Accounts;
public abstract class AccountMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid AccountId { get; init; }
protected AccountMetadata(string type)
{
_type = type;
}
}
-27
View File
@@ -1,27 +0,0 @@
namespace FiscalOS.Core.Accounts;
public sealed class Balance : Entity
{
public Guid AccountId { get; init; }
public decimal Current { get; init; }
public decimal Available { get; init; }
public string CurrencyCode { get; init; } = string.Empty;
private Balance()
{
}
public static Balance From(
decimal current,
decimal available,
string currencyCode
)
{
return new()
{
Current = current,
Available = available,
CurrencyCode = currencyCode,
};
}
}
-24
View File
@@ -1,24 +0,0 @@
namespace FiscalOS.Core.Accounts;
public sealed class Institution : Entity
{
public Guid UserId { get; init; }
public string Name { get; init; } = string.Empty;
public InstitutionMetadata? Metadata { get; init; }
private Institution()
{
}
public static Institution From(string? name, InstitutionMetadata metadata)
{
ArgumentNullException.ThrowIfNull(name, nameof(name));
ArgumentNullException.ThrowIfNull(metadata, nameof(metadata));
return new Institution()
{
Name = name,
Metadata = metadata
};
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Core.Accounts;
public abstract class InstitutionMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid InstitutionId { get; init; }
protected InstitutionMetadata(string type)
{
_type = type;
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class RefreshToken : Entity
public static RefreshToken From(User user, string token, DateTimeOffset expiresAt)
{
ArgumentNullException.ThrowIfNull(user, nameof(user));
ArgumentNullException.ThrowIfNull(user);
return new()
{
+6 -25
View File
@@ -2,20 +2,15 @@ namespace FiscalOS.Core.Identity;
public sealed class User : Entity
{
private readonly List<RefreshToken> _refreshTokens = [];
public string Username { get; init; } = string.Empty;
public string HashedPassword { get; init; } = string.Empty;
public string EncryptionKeyId { get; init; } = string.Empty;
public string EncryptedDataKey { get; init; } = string.Empty;
private readonly List<RefreshToken> _refreshTokens = [];
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
private readonly List<Institution> _institutions = [];
public IEnumerable<Institution> Institutions => _institutions;
private readonly List<Account> _accounts = [];
public IEnumerable<Account> Accounts => _accounts;
private User()
{
}
@@ -27,9 +22,9 @@ public sealed class User : Entity
public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey)
{
ArgumentNullException.ThrowIfNull(username, nameof(username));
ArgumentNullException.ThrowIfNull(hashedPassword, nameof(hashedPassword));
ArgumentNullException.ThrowIfNull(encryptedDataKey, nameof(encryptedDataKey));
ArgumentNullException.ThrowIfNull(username);
ArgumentNullException.ThrowIfNull(hashedPassword);
ArgumentNullException.ThrowIfNull(encryptedDataKey);
return new()
{
@@ -42,22 +37,8 @@ public sealed class User : Entity
public void AddRefreshToken(RefreshToken refreshToken)
{
ArgumentNullException.ThrowIfNull(refreshToken, nameof(refreshToken));
ArgumentNullException.ThrowIfNull(refreshToken);
_refreshTokens.Add(refreshToken);
}
public void AddInstitution(Institution institution)
{
ArgumentNullException.ThrowIfNull(institution, nameof(institution));
_institutions.Add(institution);
}
public void AddAccount(Account account)
{
ArgumentNullException.ThrowIfNull(account, nameof(account));
_accounts.Add(account);
}
}
@@ -13,8 +13,8 @@ public sealed record EncryptedDataKey
public static EncryptedDataKey From(string keyIdUsed, string encryptedKey)
{
ArgumentNullException.ThrowIfNull(keyIdUsed, nameof(keyIdUsed));
ArgumentNullException.ThrowIfNull(encryptedKey, nameof(encryptedKey));
ArgumentNullException.ThrowIfNull(keyIdUsed);
ArgumentNullException.ThrowIfNull(encryptedKey);
return new(keyIdUsed, encryptedKey);
}
+2 -2
View File
@@ -13,8 +13,8 @@ public sealed record KeyRingEntry
public static KeyRingEntry From(string keyId, string key)
{
ArgumentNullException.ThrowIfNull(keyId, nameof(keyId));
ArgumentNullException.ThrowIfNull(key, nameof(key));
ArgumentNullException.ThrowIfNull(keyId);
ArgumentNullException.ThrowIfNull(key);
return new(keyId, key);
}
-1
View File
@@ -1,4 +1,3 @@
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
@@ -1,21 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
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;
private PlaidAccountMetadata() : base(TypeValue)
{
}
public static PlaidAccountMetadata From(string plaidId, string plaidName)
{
return new()
{
PlaidId = plaidId,
PlaidName = plaidName,
};
}
}
@@ -1,32 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed record PlaidClientOptions
{
public string ClientId { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public IOptions<PlaidOptions> ToPlaidOptions()
{
return Options.Create<PlaidOptions>(new()
{
ClientId = ClientId,
Secret = Secret
});
}
}
public sealed record PlaidClientOptionsSetup : IConfigureOptions<PlaidClientOptions>
{
private const string SectionName = nameof(PlaidClientOptions);
private readonly IConfiguration _configuration;
public PlaidClientOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(PlaidClientOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
@@ -1,16 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public class PlaidException : Exception
{
public PlaidException()
{
}
public PlaidException(string message) : base(message)
{
}
public PlaidException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public static class PlaidExtensions
{
#pragma warning disable CA1034
extension(Going.Plaid.Entity.Account act)
{
public string CurrencyCode => act.Balances.IsoCurrencyCode
?? act.Balances.UnofficialCurrencyCode
?? "Unknown";
public decimal Available => act.Balances.Available.GetValueOrDefault();
public decimal Current => act.Balances.Current.GetValueOrDefault();
}
}
@@ -1,31 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidMetadata : 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;
private PlaidMetadata() : base(TypeValue)
{
}
public static PlaidMetadata From(
string? plaidId,
string? plaidName,
string encryptedAccessToken
)
{
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName));
ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken));
return new PlaidMetadata
{
PlaidId = plaidId,
PlaidName = plaidName,
EncryptedAccessToken = encryptedAccessToken
};
}
}
@@ -1,91 +0,0 @@
using System.Reflection;
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidService
{
private readonly PlaidClient _client;
private PlaidService(PlaidClient client)
{
_client = client;
}
public static PlaidService From(IServiceProvider sp)
{
var client = sp.GetRequiredService<PlaidClient>();
return new(client);
}
public async Task<string> CreateLinkTokenAsync(string id)
{
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var ltr = await _client.LinkTokenCreateAsync(new()
{
ClientName = $"{assemblyName}_{environmentName}",
Products = [Products.Transactions],
CountryCodes = [CountryCode.Us],
Language = Language.English,
User = new()
{
ClientUserId = id,
},
}).ConfigureAwait(false);
if (ltr.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to create link token");
}
return ltr.LinkToken;
}
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
{
var ptr = await _client.ItemPublicTokenExchangeAsync(new()
{
PublicToken = publicToken,
}).ConfigureAwait(false);
if (ptr.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to exchange public token for access token");
}
return (ptr.ItemId, ptr.AccessToken);
}
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
{
var ar = await _client.AccountsGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (ar.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to retrieve accounts");
}
return [.. ar.Accounts];
}
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
{
var ar = await _client.ItemGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (ar.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to retrieve item");
}
return ar.Item;
}
}
-6
View File
@@ -1,6 +0,0 @@
namespace FiscalOS.Infra.Accounts;
public static class Providers
{
public const string Plaid = nameof(Plaid);
}
+3 -89
View File
@@ -1,6 +1,3 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.Infra.Data;
public sealed class AppDbContext(
@@ -52,25 +49,15 @@ public sealed class AppDbContext(
modelBuilder.Entity<User>(static eb =>
{
eb.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.Property(static u => u.HashedPassword);
eb.HasMany(static u => u.RefreshTokens)
.WithOne(static t => t.User)
.HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasMany(static u => u.Institutions)
.WithOne()
.HasForeignKey(static i => i.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.HasMany(static u => u.Accounts)
.WithOne()
.HasForeignKey(static a => a.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.HashedPassword);
});
modelBuilder.Entity<RefreshToken>(static eb =>
@@ -82,78 +69,5 @@ public sealed class AppDbContext(
eb.Property(static t => t.Token);
eb.HasIndex(static t => t.Token).IsUnique();
});
modelBuilder.Entity<Institution>(static eb =>
{
eb.Property(static i => i.Name);
eb.HasOne(static i => i.Metadata)
.WithOne()
.HasForeignKey<InstitutionMetadata>(static m => m.InstitutionId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<InstitutionMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue<PlaidMetadata>(PlaidMetadata.TypeValue);
eb.Property(static m => m.InstitutionId);
eb.Property(static m => m.Type);
});
modelBuilder.Entity<PlaidMetadata>(static eb =>
{
eb.HasBaseType<InstitutionMetadata>();
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
eb.Property(static m => m.EncryptedAccessToken);
});
modelBuilder.Entity<Account>(static eb =>
{
eb.Property(static a => a.Name);
eb.HasOne(static a => a.Institution)
.WithMany()
.HasForeignKey(static a => a.InstitutionId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasOne(static a => a.Metadata)
.WithOne()
.HasForeignKey<AccountMetadata>(static m => m.AccountId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasMany(static a => a.Balances)
.WithOne()
.HasForeignKey(static b => b.AccountId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<AccountMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue<PlaidAccountMetadata>(PlaidAccountMetadata.TypeValue);
eb.Property(static m => m.AccountId);
eb.Property(static m => m.Type);
});
modelBuilder.Entity<PlaidAccountMetadata>(static eb =>
{
eb.HasBaseType<AccountMetadata>();
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
});
modelBuilder.Entity<Balance>(static eb =>
{
eb.Property(static b => b.AccountId);
eb.Property(static b => b.Current);
eb.Property(static b => b.Available);
eb.Property(static b => b.CurrencyCode);
});
}
}
@@ -6,7 +6,6 @@ public static class ServiceCollectionExtensions
{
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
services.AddHttpClient();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IFileSystem, FileSystem>();
@@ -24,17 +23,6 @@ public static class ServiceCollectionExtensions
services.AddDbContext<AppDbContext>();
services.AddHostedService<MigrationService>();
services.ConfigureOptions<PlaidClientOptionsSetup>();
services.AddSingleton(static sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
var options = clientOptions.Value.ToPlaidOptions();
return new PlaidClient(options, factory, logger);
});
services.AddSingleton(PlaidService.From);
return services;
}
}
-1
View File
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Going.Plaid" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
@@ -1,215 +0,0 @@
// <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("20260212215015_InstitutionAndMetadata")]
partial class InstitutionAndMetadata
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
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.PlaidMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.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.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.Institution", b =>
{
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,82 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class InstitutionAndMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Institution",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Institution", x => x.Id);
table.ForeignKey(
name: "FK_Institution_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "InstitutionMetadata",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
InstitutionId = table.Column<Guid>(type: "TEXT", nullable: false),
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
EncryptedAccessToken = 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_InstitutionMetadata", x => x.Id);
table.ForeignKey(
name: "FK_InstitutionMetadata_Institution_InstitutionId",
column: x => x.InstitutionId,
principalTable: "Institution",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Institution_UserId",
table: "Institution",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_InstitutionMetadata_InstitutionId",
table: "InstitutionMetadata",
column: "InstitutionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InstitutionMetadata");
migrationBuilder.DropTable(
name: "Institution");
}
}
}
@@ -1,327 +0,0 @@
// <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("20260213130218_AccountAndMetadata")]
partial class AccountAndMetadata
{
/// <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>("InstituionId")
.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("InstituionId");
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.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>("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()
.HasForeignKey("InstituionId")
.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.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("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,93 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AccountAndMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Account",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
InstituionId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Account", x => x.Id);
table.ForeignKey(
name: "FK_Account_Institution_InstituionId",
column: x => x.InstituionId,
principalTable: "Institution",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Account_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AccountMetadata",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
PlaidName = 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_AccountMetadata", x => x.Id);
table.ForeignKey(
name: "FK_AccountMetadata_Account_AccountId",
column: x => x.AccountId,
principalTable: "Account",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Account_InstituionId",
table: "Account",
column: "InstituionId");
migrationBuilder.CreateIndex(
name: "IX_Account_UserId",
table: "Account",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AccountMetadata_AccountId",
table: "AccountMetadata",
column: "AccountId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AccountMetadata");
migrationBuilder.DropTable(
name: "Account");
}
}
}
@@ -1,327 +0,0 @@
// <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("20260213180002_FixAccountInstitutionColumnName")]
partial class FixAccountInstitutionColumnName
{
/// <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.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>("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()
.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.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("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,62 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class FixAccountInstitutionColumnName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Account_Institution_InstituionId",
table: "Account");
migrationBuilder.RenameColumn(
name: "InstituionId",
table: "Account",
newName: "InstitutionId");
migrationBuilder.RenameIndex(
name: "IX_Account_InstituionId",
table: "Account",
newName: "IX_Account_InstitutionId");
migrationBuilder.AddForeignKey(
name: "FK_Account_Institution_InstitutionId",
table: "Account",
column: "InstitutionId",
principalTable: "Institution",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Account_Institution_InstitutionId",
table: "Account");
migrationBuilder.RenameColumn(
name: "InstitutionId",
table: "Account",
newName: "InstituionId");
migrationBuilder.RenameIndex(
name: "IX_Account_InstitutionId",
table: "Account",
newName: "IX_Account_InstituionId");
migrationBuilder.AddForeignKey(
name: "FK_Account_Institution_InstituionId",
table: "Account",
column: "InstituionId",
principalTable: "Institution",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -1,364 +0,0 @@
// <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("20260214112234_AddBalancesWithConnectionToAccounts")]
partial class AddBalancesWithConnectionToAccounts
{
/// <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<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>("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()
.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("Metadata");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,50 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddBalancesWithConnectionToAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Balance",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
Current = table.Column<decimal>(type: "TEXT", nullable: false),
Available = table.Column<decimal>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Balance", x => x.Id);
table.ForeignKey(
name: "FK_Balance_Account_AccountId",
column: x => x.AccountId,
principalTable: "Account",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Balance_AccountId",
table: "Balance",
column: "AccountId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Balance");
}
}
}
@@ -1,368 +0,0 @@
// <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("20260214122622_AddCurrencyCodeToBalanceModel")]
partial class AddCurrencyCodeToBalanceModel
{
/// <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>("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()
.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("Metadata");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddCurrencyCodeToBalanceModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CurrencyCode",
table: "Balance",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrencyCode",
table: "Balance");
}
}
}
@@ -17,159 +17,6 @@ namespace FiscalOS.Infra.Migrations
#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")
@@ -241,93 +88,6 @@ namespace FiscalOS.Infra.Migrations
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>("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()
.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")
@@ -339,24 +99,8 @@ namespace FiscalOS.Infra.Migrations
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("Metadata");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
-5
View File
@@ -5,20 +5,15 @@ global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Security;
global using Going.Plaid;
global using Going.Plaid.Entity;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Authorization.Policy;
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>
@@ -1,89 +0,0 @@
namespace FiscalOS.ServiceDefaults;
public static class ServiceDefaultsExtensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(static http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
)
.WithTracing(tracing => tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
tracing.Filter = context =>
context.Request.Path.StartsWithSegments(HealthEndpointPath, StringComparison.InvariantCultureIgnoreCase) is false &&
context.Request.Path.StartsWithSegments(AlivenessEndpointPath, StringComparison.InvariantCultureIgnoreCase) is false
)
.AddHttpClientInstrumentation()
);
builder.AddOpenTelemetryExporters();
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
.AddCheck("self", static () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
if (app.Environment.IsDevelopment())
{
app.MapHealthChecks(HealthEndpointPath);
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = static r => r.Tags.Contains("live")
});
}
return app;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
}
-10
View File
@@ -1,10 +0,0 @@
global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Diagnostics.HealthChecks;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using OpenTelemetry;
global using OpenTelemetry.Metrics;
global using OpenTelemetry.Trace;
-8
View File
@@ -1,8 +0,0 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
-42
View File
@@ -1,42 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
test-results/
playwright-report/
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"$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
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
-6
View File
@@ -1,6 +0,0 @@
import { test, expect } from '@playwright/test'
test('visits the app root url', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('You did it!')
})
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-27
View File
@@ -1,27 +0,0 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FiscalOS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-7779
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
{
"name": "fiscalos-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"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"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
"@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4",
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.47.0",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "beta",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"overrides": {
"vue": "beta",
"@vue/compiler-core": "beta",
"@vue/compiler-dom": "beta",
"@vue/compiler-sfc": "beta",
"@vue/compiler-ssr": "beta",
"@vue/compiler-vapor": "beta",
"@vue/reactivity": "beta",
"@vue/runtime-core": "beta",
"@vue/runtime-dom": "beta",
"@vue/runtime-vapor": "beta",
"@vue/server-renderer": "beta",
"@vue/shared": "beta",
"@vue/compat": "beta"
}
}
-45
View File
@@ -1,45 +0,0 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
actionTimeout: 0,
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
trace: 'on-first-retry',
headless: !!process.env.CI,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
],
webServer: {
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})
-9
View File
@@ -1,9 +0,0 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>
<style scoped></style>
@@ -1,116 +0,0 @@
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
@@ -1,71 +0,0 @@
@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
@@ -1,44 +0,0 @@
* {
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

Before

Width:  |  Height:  |  Size: 6.4 MiB

@@ -1,147 +0,0 @@
<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>
@@ -1,106 +0,0 @@
<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>
@@ -1,26 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,6 +0,0 @@
<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>
@@ -1,27 +0,0 @@
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;
}
@@ -1,27 +0,0 @@
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;
}
-21
View File
@@ -1,21 +0,0 @@
import './assets/css/main.css';
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { ClientFactory, ClientFactoryKey } from "./services/client";
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.use(createPinia());
app.use(router);
app.mount("#app");
-70
View File
@@ -1,70 +0,0 @@
import { AuthService } from '@/services/authService';
import { Client, ClientConfig } from '@/services/client';
import { useUserStore } from '@/stores/userStore';
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/public',
component: () => import('../components/PublicLayout.vue'),
redirect: '/public/login',
beforeEnter: () => {
const userStore = useUserStore();
if (userStore.user) {
return { path: '/' };
}
return true;
},
children: [
{
path: 'login',
component: () => import('../views/LoginView.vue'),
},
],
},
{
path: '/',
component: () => import('../components/ProtectedLayout.vue'),
beforeEnter: async () => {
const userStore = useUserStore();
if (userStore.user === null) {
return { path: '/public/login' };
}
const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000;
if (isExpired) {
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${userStore.user.token}` },
true
);
const client = new Client(clientConfig);
const authService = new AuthService(client);
const refreshResult = await authService.refreshToken();
if (refreshResult.err) {
userStore.logUserOut();
return { path: '/public/login' };
}
userStore.logUserIn(refreshResult.val.accessToken);
}
return true;
},
children: [
{
path: '/',
component: () => import('../views/HomeView.vue'),
},
],
},
],
});
export default router;
@@ -1,112 +0,0 @@
import { Err, Ok, Result } from 'ts-results';
import { type InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
type AuthServiceFactoryKeyType = InjectionKey<IAuthServiceFactory>;
export const AuthServiceFactoryKey: AuthServiceFactoryKeyType = Symbol('AuthServiceFactory');
export interface IAuthServiceFactory {
create: (client: IClient) => IAuthService;
}
export class AuthServiceFactory implements IAuthServiceFactory {
create(client: IClient): IAuthService {
return new AuthService(client);
}
}
export interface IAuthService {
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
}
export class AuthService implements IAuthService {
private readonly client: IClient;
private readonly endpoints = {
login: '/api/auth/login',
refreshToken: '/api/auth/refresh',
};
constructor(client: IClient) {
this.client = client;
}
async login(username: string, password: string) {
const request = new ClientRequestWithBody(
this.endpoints.login,
undefined,
new LoginRequest(username, password)
);
try {
const res = await this.client.post(request);
if (res.status === 400) {
const body = await res.json();
const validationErrors = body.errors as Record<string, string[]>;
const errors = Object.values(validationErrors)
.flat()
.map(e => new Error(e));
return Err(errors);
}
if (res.status === 401) {
return Err([new Error('Email/Password combination is not valid')]);
}
if (res.ok === false) {
return Err([new Error('Login failed. Please try again.')]);
}
const body = await res.json();
return Ok(body as LoginResponse);
} catch (e) {
console.error(e);
return Err([new Error('Login failed. Please try again.')]);
}
}
async refreshToken() {
const request = new ClientRequestWithBody(this.endpoints.refreshToken, undefined, undefined);
try {
const res = await this.client.post(request);
if (res.status === 401) {
return Err([new Error('Refresh and/or access token is not valid')]);
}
if (res.ok === false) {
return Err([new Error('Refreshing token failed')]);
}
const body = await res.json();
return Ok(body as LoginResponse);
} catch (e) {
console.error(e);
return Err([new Error('Refreshing token failed.')]);
}
}
}
class BaseAuthRequest {
readonly username: string;
readonly password: string;
constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
}
class LoginRequest extends BaseAuthRequest {
constructor(username: string, password: string) {
super(username, password);
}
}
type LoginResponse = {
accessToken: string;
};

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