Compare commits

..
Author SHA1 Message Date
Stevan Freeborn 4b030651b3 chore: add coverage directory to .gitignore 2026-03-08 16:22:39 -05:00
Stevan Freeborn d293c0d75a refactor: remove unused using directive for Going.Plaid.Transactions in PlaidTransactionProcessorTests 2026-03-08 16:16:07 -05:00
Stevan Freeborn 1c0fdc205e tests(infra.tests): enhance AccountBuilder with metadata handling and expand PlaidTransactionSyncerTests 2026-03-08 16:14:51 -05:00
Stevan Freeborn 1a4f7fac8d tests(infra,api): refactor to allow further config of TestApi 2026-03-08 09:15:45 -05:00
Stevan Freeborn 870baf7b6c tests(infra.tests): added builder classes for creating test entities 2026-03-08 07:18:27 -05:00
Stevan Freeborn 27d2ccbd45 tests(infra.tests): wip on builder apis for test data 2026-03-06 07:14:57 -06:00
Stevan Freeborn 66f4046d2e feat: implement transaction handling and add integration tests for Plaid transaction handlers 2026-03-05 13:58:00 -06:00
Stevan Freeborn 0f0ed561c3 feat: update account handling in add endpoint and tests 2026-03-03 07:40:19 -06:00
Stevan Freeborn 74793b5800 feat: continued wip on supporting transactions 2026-03-03 07:36:09 -06:00
Stevan Freeborn 562a708c74 fix(infra): don't advance account cursor unless all transactions proceed successfully 2026-03-03 05:09:03 -06:00
Stevan Freeborn 19a4520a7b refactor(core): make parameters non-nullable 2026-03-03 05:08:35 -06:00
Stevan Freeborn 33f74ee018 tests(api.tests): fix methods broken in refactor 2026-03-03 05:08:12 -06:00
Stevan Freeborn 2bc9cab43d feat: rename transaction table to transactions and update related references 2026-03-01 20:35:33 -06:00
Stevan Freeborn b9d0869ae3 feat: add Plaid transaction handling and async queue processing
- introduced new migration to add Cursor column to AccountMetadata.
- updated AppDbContext model snapshot to reflect changes in the database schema.
- implemented async queue processing with ChannelAsyncQueue and AsyncQueueHostedService.
- created Plaid transaction handlers for added, modified, and removed transactions.
- developed PlaidTransactionService and PlaidTransactionSyncer for syncing transactions.
- added SyncUpdatesProcessor to handle sync updates from Plaid.
- updated tests to accommodate changes in institution and account metadata handling.
2026-03-01 19:56:29 -06:00
104 changed files with 824 additions and 2513 deletions
+1 -2
View File
@@ -1,4 +1,3 @@
{ {
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj", "appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
"defaultWatchEnabled": "true"
} }
-11
View File
@@ -1,11 +0,0 @@
.git/
.vscode/
.aspire/
Coverage/
tests/
docker/data/
docker/keys/
**/bin/
**/obj/
**/node_modules/
**/.env
+1 -2
View File
@@ -84,7 +84,6 @@ dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.IDE0100.severity = none dotnet_diagnostic.IDE0100.severity = none
dotnet_diagnostic.CA1515.severity = none dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CA1848.severity = none dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA1873.severity = none
# var preferences # var preferences
csharp_style_var_elsewhere = true:suggestion csharp_style_var_elsewhere = true:suggestion
@@ -95,7 +94,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_accessors = true:silent csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = false:silent csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_style_expression_bodied_local_functions = false:silent csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_operators = false:silent
-100
View File
@@ -1,100 +0,0 @@
name: Deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
WEB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-web
API_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-api
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.web
push: true
tags: |
${{ env.WEB_IMAGE }}:latest
${{ env.WEB_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.api
push: true
tags: |
${{ env.API_IMAGE }}:latest
${{ env.API_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Copy compose file to server
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: docker/docker-compose.prod.yml
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 1
- name: Deploy on server
uses: appleboy/ssh-action@v1
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_TAG: ${{ github.sha }}
JWT_AUDIENCE: ${{ secrets.JWT_AUDIENCE }}
JWT_ISSUER: ${{ secrets.JWT_ISSUER }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_EXPIRY_IN_MINUTES: ${{ secrets.JWT_EXPIRY_IN_MINUTES }}
FILE_KEY_RING_PRIMARY_KEY_ID: ${{ secrets.FILE_KEY_RING_PRIMARY_KEY_ID }}
PLAID_CLIENT_ID: ${{ secrets.PLAID_CLIENT_ID }}
PLAID_SECRET: ${{ secrets.PLAID_SECRET }}
PLAID_WEBHOOK: ${{ secrets.PLAID_WEBHOOK }}
SEQ_SERVER_URL: ${{ secrets.SEQ_SERVER_URL }}
SEQ_API_KEY_HEADER: ${{ secrets.SEQ_API_KEY_HEADER }}
SEQ_API_KEY: ${{ secrets.SEQ_API_KEY }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: DOCKERHUB_USERNAME,IMAGE_TAG,JWT_AUDIENCE,JWT_ISSUER,JWT_SECRET,JWT_EXPIRY_IN_MINUTES,FILE_KEY_RING_PRIMARY_KEY_ID,PLAID_CLIENT_ID,PLAID_SECRET,PLAID_WEBHOOK,SEQ_SERVER_URL,SEQ_API_KEY_HEADER,SEQ_API_KEY
script: |
cd ${{ secrets.DEPLOY_PATH }}
cat > .env << EOF
DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME}
IMAGE_TAG=${IMAGE_TAG}
JWT_AUDIENCE=${JWT_AUDIENCE}
JWT_ISSUER=${JWT_ISSUER}
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRY_IN_MINUTES=${JWT_EXPIRY_IN_MINUTES}
FILE_KEY_RING_PRIMARY_KEY_ID=${FILE_KEY_RING_PRIMARY_KEY_ID}
PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
PLAID_SECRET=${PLAID_SECRET}
PLAID_WEBHOOK=${PLAID_WEBHOOK}
SEQ_SERVER_URL=${SEQ_SERVER_URL}
SEQ_API_KEY_HEADER=${SEQ_API_KEY_HEADER}
SEQ_API_KEY=${SEQ_API_KEY}
EOF
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
-4
View File
@@ -10,10 +10,6 @@ coverage/
appsettings*.json appsettings*.json
!appsettings.Example.json !appsettings.Example.json
# docker bind-mount data
docker/data/
docker/keys/
# dotenv files # dotenv files
.env .env
+1 -1
View File
@@ -11,7 +11,7 @@
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["DOCKERHUB", "Dtos", "Encryptor", "fiscalos", "Validatable"], "cSpell.words": ["Encryptor"],
"python-envs.defaultEnvManager": "ms-python.python:system", "python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": { "search.exclude": {
"**/Migrations": true "**/Migrations": true
-1
View File
@@ -15,7 +15,6 @@
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.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.Core.Tests/FiscalOS.Core.Tests.csproj" />
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" /> <Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
<Project Path="tests/FiscalOS.Tests.Common/FiscalOS.Tests.Common.csproj" />
</Folder> </Folder>
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" /> <Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" /> <Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
-39
View File
@@ -1,39 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
COPY Directory.Build.props .
COPY .editorconfig .
COPY src/.editorconfig src/
COPY src/Directory.Build.props src/
COPY src/Directory.Packages.props src/
COPY src/FiscalOS.Core/FiscalOS.Core.csproj src/FiscalOS.Core/
COPY src/FiscalOS.Infra/FiscalOS.Infra.csproj src/FiscalOS.Infra/
COPY src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj src/FiscalOS.ServiceDefaults/
COPY src/FiscalOS.API/FiscalOS.API.csproj src/FiscalOS.API/
COPY src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj src/FiscalOS.AdminCLI/
RUN dotnet restore src/FiscalOS.API/FiscalOS.API.csproj
RUN dotnet restore src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj
COPY src/ src/
RUN dotnet publish src/FiscalOS.API/FiscalOS.API.csproj \
-c Release \
-o /publish/api \
--no-restore
RUN dotnet publish src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj \
-c Release \
-o /publish/admincli \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /publish/api .
COPY --from=build /publish/admincli /usr/local/lib/admincli
ENV PATH="/usr/local/lib/admincli:${PATH}"
EXPOSE 8080
ENTRYPOINT ["dotnet", "FiscalOS.API.dll"]
-13
View File
@@ -1,13 +0,0 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY src/FiscalOS.Web/package.json src/FiscalOS.Web/package-lock.json ./
RUN npm ci
COPY src/FiscalOS.Web/ .
RUN npm run build
FROM nginx:alpine AS final
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
-30
View File
@@ -1,30 +0,0 @@
services:
web:
image: ${DOCKERHUB_USERNAME}/fiscalos-web:${IMAGE_TAG:-latest}
ports:
- "8081:80"
depends_on:
- api
restart: unless-stopped
api:
image: ${DOCKERHUB_USERNAME}/fiscalos-api:${IMAGE_TAG:-latest}
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
- SeqOptions__ServerUrl=${SEQ_SERVER_URL}
- SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER}
- SeqOptions__ApiKey=${SEQ_API_KEY}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
-36
View File
@@ -1,36 +0,0 @@
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
ports:
- "80:80"
depends_on:
- api
restart: unless-stopped
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
- SeqOptions__ServerUrl=${SEQ_SERVER_URL}
- SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER}
- SeqOptions__ApiKey=${SEQ_API_KEY}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
-18
View File
@@ -1,18 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
-4
View File
@@ -1,8 +1,4 @@
{ {
"sdk": {
"version": "10.0.103",
"rollForward": "latestPatch"
},
"test": { "test": {
"runner": "Microsoft.Testing.Platform" "runner": "Microsoft.Testing.Platform"
} }
@@ -1,5 +1,3 @@
using FiscalOS.API.Accounts.Add;
namespace FiscalOS.API.Accounts; namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions internal static class AccountsExtensions
+6 -6
View File
@@ -24,9 +24,9 @@ internal static class Endpoint
var userId = httpContext.GetUserId(); var userId = httpContext.GetUserId();
var user = await appDbContext.Users var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.ProviderInstitutionId)) .Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
.ThenInclude(i => i.Metadata) .ThenInclude(i => i.Metadata)
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.ProviderAccountId)) .Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
.ThenInclude(a => a.Metadata) .ThenInclude(a => a.Metadata)
.AsSplitQuery() .AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == userId, ct); .SingleOrDefaultAsync(u => u.Id == userId, ct);
@@ -40,7 +40,7 @@ internal static class Endpoint
{ {
return Results.ValidationProblem(new Dictionary<string, string[]> return Results.ValidationProblem(new Dictionary<string, string[]>
{ {
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."], ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
}); });
} }
@@ -50,7 +50,7 @@ internal static class Endpoint
{ {
return Results.ValidationProblem(new Dictionary<string, string[]> return Results.ValidationProblem(new Dictionary<string, string[]>
{ {
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."], ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"],
}); });
} }
@@ -60,8 +60,8 @@ internal static class Endpoint
} }
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct); var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName); var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
var account = Account.From(request.ProviderAccountName, accountMetadata); var account = Account.From(request.PlaidAccountName, accountMetadata);
user.AddAccount(account); user.AddAccount(account);
user.Institutions.First().AddAccount(account); user.Institutions.First().AddAccount(account);
+9 -9
View File
@@ -2,27 +2,27 @@ namespace FiscalOS.API.Accounts.Add;
public record Request : IValidatableObject public record Request : IValidatableObject
{ {
public string ProviderInstitutionId { get; init; } = string.Empty; public string PlaidInstitutionId { get; init; } = string.Empty;
public string ProviderAccountId { get; init; } = string.Empty; public string PlaidAccountId { get; init; } = string.Empty;
public string ProviderAccountName { get; init; } = string.Empty; public string PlaidAccountName { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {
if (string.IsNullOrWhiteSpace(ProviderInstitutionId)) if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
{ {
var fieldName = nameof(ProviderInstitutionId); var fieldName = nameof(PlaidInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]); yield return new($"The {fieldName} field is required.", [fieldName]);
} }
if (string.IsNullOrWhiteSpace(ProviderAccountId)) if (string.IsNullOrWhiteSpace(PlaidAccountId))
{ {
var fieldName = nameof(ProviderAccountId); var fieldName = nameof(PlaidAccountId);
yield return new($"The {fieldName} field is required.", [fieldName]); yield return new($"The {fieldName} field is required.", [fieldName]);
} }
if (string.IsNullOrWhiteSpace(ProviderAccountName)) if (string.IsNullOrWhiteSpace(PlaidAccountName))
{ {
var fieldName = nameof(ProviderAccountName); var fieldName = nameof(PlaidAccountName);
yield return new($"The {fieldName} field is required.", [fieldName]); yield return new($"The {fieldName} field is required.", [fieldName]);
} }
} }
-5
View File
@@ -1,7 +1,3 @@
using FiscalOS.API.Auth.Login;
using FiscalOS.API.Auth.Logout;
using FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Auth; namespace FiscalOS.API.Auth;
internal static class AuthExtensions internal static class AuthExtensions
@@ -13,7 +9,6 @@ internal static class AuthExtensions
var authGroup = app.MapGroup(RouteGroupPrefix); var authGroup = app.MapGroup(RouteGroupPrefix);
authGroup.MapLoginEndpoint(); authGroup.MapLoginEndpoint();
authGroup.MapLogoutEndpoint();
authGroup.MapRefreshEndpoint() authGroup.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens); .RequireAuthorization(Schemes.AllowExpiredTokens);
-40
View File
@@ -1,40 +0,0 @@
namespace FiscalOS.API.Auth.Logout;
internal static class Endpoint
{
private const string Route = "/logout";
public static RouteHandlerBuilder MapLogoutEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var refreshToken = httpContext.GetRefreshTokenFromCookie();
var user = await appDbContext.Users
.Include(u => u.RefreshTokens
.Where(r => r.Token == refreshToken)
)
.Where(u => u.Id == userId)
.SingleOrDefaultAsync(ct);
if (user is null || user.RefreshTokens.Any() is false)
{
return Results.Unauthorized();
}
user.RefreshTokens.First().Revoke();
await appDbContext.SaveChangesAsync(ct);
httpContext.ExpireRefreshTokenCookie();
return Results.NoContent();
}
}
@@ -1,48 +0,0 @@
using FiscalOS.Infra.Common;
using Microsoft.AspNetCore.Diagnostics;
internal sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger
) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken
)
{
switch (exception)
{
case PlaidException plaidException:
logger.LogError(
plaidException,
"Unhandled Plaid exception. PlaidRequestId: {PlaidRequestId}, PlaidStatusCode: {PlaidStatusCode}, PlaidErrorCode: {PlaidErrorCode}, PlaidErrorType: {PlaidErrorType}, PlaidErrorMessage: {PlaidErrorMessage}",
plaidException.RequestId,
plaidException.StatusCode,
plaidException.ErrorCode,
plaidException.ErrorType,
plaidException.ErrorMessage
);
break;
default:
logger.LogError(exception, "An unhandled exception occurred");
break;
}
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = exception.GetType().Name,
Title = "Uh oh something has gone wrong.",
Detail = exception.Message
}
});
}
}
-36
View File
@@ -1,36 +0,0 @@
namespace FiscalOS.API.Common;
internal sealed record PagedQuery
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public static async ValueTask<PagedQuery> BindAsync(HttpContext context)
{
var pageNumber = int.TryParse(context.Request.Query["pageNumber"], out var pn) ? pn : 1;
var pageSize = int.TryParse(context.Request.Query["pageSize"], out var ps) ? ps : 1000;
return new PagedQuery
{
PageNumber = pageNumber,
PageSize = pageSize
};
}
public Dictionary<string, string[]> Validate()
{
var validationResults = new Dictionary<string, string[]>();
if (PageNumber <= 0)
{
validationResults[nameof(PageNumber)] = ["PageNumber must be greater than 0."];
}
if (PageSize <= 0 || PageSize > 1000)
{
validationResults[nameof(PageSize)] = ["PageSize must be between 1 and 1000."];
}
return validationResults;
}
}
-34
View File
@@ -1,34 +0,0 @@
namespace FiscalOS.API.Common;
internal sealed record PagedResponse<T>
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalItems { get; init; }
public int TotalPages { get; init; }
public T[] Items { get; init; } = [];
[JsonConstructor]
private PagedResponse()
{
}
public static PagedResponse<T> From(
int pageNumber,
int pageSize,
int totalItems,
T[] items
)
{
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
return new PagedResponse<T>
{
PageNumber = pageNumber,
PageSize = pageSize,
TotalItems = totalItems,
TotalPages = totalPages,
Items = items
};
}
}
@@ -25,21 +25,6 @@ internal static class HttpContextExtensions
); );
} }
public static void ExpireRefreshTokenCookie(this HttpContext context)
{
context.Response.Cookies.Append(
RefreshTokenCookieName,
string.Empty,
new CookieOptions
{
HttpOnly = true,
Expires = DateTimeOffset.MinValue,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
}
public static string GetRefreshTokenFromCookie(this HttpContext context) public static string GetRefreshTokenFromCookie(this HttpContext context)
{ {
return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty; return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty;
@@ -19,7 +19,6 @@ internal static class Endpoint
var user = await appDbContext.Users var user = await appDbContext.Users
.Include(u => u.Institutions) .Include(u => u.Institutions)
.ThenInclude(i => i.Accounts) .ThenInclude(i => i.Accounts)
.ThenInclude(a => a.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId); .FirstOrDefaultAsync(u => u.Id == userId);
if (user is null) if (user is null)
@@ -24,7 +24,6 @@ internal sealed record Response
internal sealed record AccountDto internal sealed record AccountDto
{ {
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
[JsonConstructor] [JsonConstructor]
@@ -37,9 +36,6 @@ internal sealed record AccountDto
return new() return new()
{ {
Id = account.Id.ToString(), Id = account.Id.ToString(),
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
? plaidMetadata.PlaidId
: string.Empty,
Name = account.Name, Name = account.Name,
}; };
} }
@@ -1,8 +1,3 @@
using FiscalOS.API.Institutions.Connect;
using FiscalOS.API.Institutions.Get;
using FiscalOS.API.Institutions.GetAvailable;
using FiscalOS.API.Institutions.Link;
namespace FiscalOS.API.Institutions; namespace FiscalOS.API.Institutions;
internal static class InstitutionsExtensions internal static class InstitutionsExtensions
+1 -2
View File
@@ -3,7 +3,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); builder.AddServiceDefaults();
builder.Services.AddValidation(); builder.Services.AddValidation();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(static options => builder.Services.ConfigureHttpJsonOptions(static options =>
@@ -40,9 +39,9 @@ app.UseHttpsRedirection();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseStatusCodePages(); app.UseStatusCodePages();
app.UseExceptionHandler();
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
app.MapAuthEndpoints(); app.MapAuthEndpoints();
app.MapAccountsEndpoints(); app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints(); app.MapInstitutionsEndpoints();
@@ -1,34 +0,0 @@
namespace FiscalOS.API.Transactions.DeleteById;
internal static class Endpoint
{
private const string Route = "/{id}";
public static RouteHandlerBuilder MapDeleteEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapDelete(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromRoute] Guid id,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var existingTransaction = await appDbContext.Transactions
.SingleOrDefaultAsync(t => t.UserId == userId && t.Id == id, ct);
if (existingTransaction is null)
{
return Results.NotFound();
}
appDbContext.Transactions.Remove(existingTransaction);
await appDbContext.SaveChangesAsync(ct);
return Results.NoContent();
}
}
@@ -1,53 +0,0 @@
namespace FiscalOS.API.Transactions.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,
PagedQuery pagedQuery,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var pagedQueryValidationResults = pagedQuery.Validate();
if (pagedQueryValidationResults.Count is not 0)
{
return Results.ValidationProblem(pagedQueryValidationResults);
}
var userId = httpContext.GetUserId();
var query = appDbContext.Transactions
.Where(t => t.UserId == userId);
var transactions = await query
.Skip((pagedQuery.PageNumber - 1) * pagedQuery.PageSize)
.Take(pagedQuery.PageSize)
// TODO: Migrate Date to DateTime
// to avoid in-memory sorting
.AsAsyncEnumerable()
.OrderByDescending(t => t.Date)
.ToListAsync(ct);
var count = await query
.Where(t => t.UserId == userId)
.CountAsync(ct);
var transactionDtos = transactions.Select(TransactionDto.From).ToArray();
var pagedResponse = PagedResponse<TransactionDto>.From(
pagedQuery.PageNumber,
pagedQuery.PageSize,
count,
transactionDtos
);
return Results.Ok(pagedResponse);
}
}
@@ -1,29 +0,0 @@
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.API.Transactions;
internal sealed record TransactionDto
{
public Guid Id { get; init; }
public string MerchantName { get; init; } = string.Empty;
public decimal Amount { get; init; }
public DateTimeOffset Date { get; init; }
public string Description { get; init; } = string.Empty;
[JsonConstructor]
private TransactionDto()
{
}
public static TransactionDto From(Transaction transaction)
{
return new TransactionDto
{
Id = transaction.Id,
MerchantName = transaction.MerchantName,
Amount = transaction.Amount,
Date = transaction.Date,
Description = transaction.Description
};
}
}
@@ -1,8 +1,3 @@
using FiscalOS.API.Transactions.DeleteById;
using FiscalOS.API.Transactions.FireWebhook;
using FiscalOS.API.Transactions.Get;
using FiscalOS.API.Transactions.Webhook;
namespace FiscalOS.API.Transactions; namespace FiscalOS.API.Transactions;
internal static class TransactionsExtensions internal static class TransactionsExtensions
@@ -14,15 +9,13 @@ internal static class TransactionsExtensions
var transactionsGroup = app.MapGroup(RouteGroupPrefix) var transactionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization(); .RequireAuthorization();
transactionsGroup.MapGetEndpoint();
transactionsGroup.MapDeleteEndpoint();
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
if (app.Environment.IsProduction() is false) if (app.Environment.IsProduction() is false)
{ {
transactionsGroup.MapFireWebhookEndpoint(); transactionsGroup.MapFireWebhookEndpoint();
} }
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
return transactionsGroup; return transactionsGroup;
} }
} }
+9 -1
View File
@@ -3,11 +3,19 @@ global using System.Security.Claims;
global using System.Text.Json.Serialization; global using System.Text.Json.Serialization;
global using FiscalOS.API.Accounts; global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth; global using FiscalOS.API.Auth;
global using FiscalOS.API.Common; global using FiscalOS.API.Auth.Login;
global using FiscalOS.API.Auth.Refresh;
global using FiscalOS.API.Http; global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions; global using FiscalOS.API.Institutions;
global using FiscalOS.API.Institutions.Connect;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Institutions.GetAvailable;
global using FiscalOS.API.Institutions.Link;
global using FiscalOS.API.Transactions; global using FiscalOS.API.Transactions;
global using FiscalOS.API.Transactions.FireWebhook;
global using FiscalOS.API.Transactions.Webhook;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing; global using FiscalOS.Core.Queuing;
@@ -23,10 +23,5 @@
"ClientId": "ClientId", "ClientId": "ClientId",
"Secret": "Secret", "Secret": "Secret",
"Webhook": "Webhook" "Webhook": "Webhook"
},
"SeqOptions": {
"ServerUrl": "ServerUrl",
"ApiKeyHeader": "ApiKeyHeader",
"ApiKey": "ApiKey"
} }
} }
@@ -2,7 +2,6 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<AssemblyName>fiscaloscli</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
await Host.CreateDefaultBuilder(args) await Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration( .ConfigureAppConfiguration(
static c => c.SetBasePath(AppContext.BaseDirectory) static c => c.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.json")
.AddEnvironmentVariables() .AddEnvironmentVariables()
) )
.ConfigureLogging(static c => c.ClearProviders()) .ConfigureLogging(static c => c.ClearProviders())
@@ -31,7 +31,7 @@ internal sealed class PlaidAccountService : IPlaidAccountService
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName; var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var linkTokenResponse = await _client.LinkTokenCreateAsync(new() var ltr = await _client.LinkTokenCreateAsync(new()
{ {
ClientName = $"{assemblyName}_{environmentName}", ClientName = $"{assemblyName}_{environmentName}",
Products = [Products.Transactions], Products = [Products.Transactions],
@@ -44,76 +44,56 @@ internal sealed class PlaidAccountService : IPlaidAccountService
Webhook = _options.Value.Webhook, Webhook = _options.Value.Webhook,
}).ConfigureAwait(false); }).ConfigureAwait(false);
if (linkTokenResponse.IsSuccessStatusCode is false) if (ltr.IsSuccessStatusCode is false)
{ {
throw new PlaidException( throw new PlaidException("Unable to create link token");
"Unable to create link token",
linkTokenResponse.Error,
linkTokenResponse.RequestId,
(int?)linkTokenResponse.StatusCode
);
} }
return linkTokenResponse.LinkToken; return ltr.LinkToken;
} }
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken) public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
{ {
var publicTokenResponse = await _client.ItemPublicTokenExchangeAsync(new() var ptr = await _client.ItemPublicTokenExchangeAsync(new()
{ {
PublicToken = publicToken, PublicToken = publicToken,
}).ConfigureAwait(false); }).ConfigureAwait(false);
if (publicTokenResponse.IsSuccessStatusCode is false) if (ptr.IsSuccessStatusCode is false)
{ {
throw new PlaidException( throw new PlaidException("Unable to exchange public token for access token");
"Unable to exchange public token for access token",
publicTokenResponse.Error,
publicTokenResponse.RequestId,
(int?)publicTokenResponse.StatusCode
);
} }
return (publicTokenResponse.ItemId, publicTokenResponse.AccessToken); return (ptr.ItemId, ptr.AccessToken);
} }
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken) public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
{ {
var accountResponse = await _client.AccountsGetAsync(new() var ar = await _client.AccountsGetAsync(new()
{ {
AccessToken = accessToken, AccessToken = accessToken,
}).ConfigureAwait(false); }).ConfigureAwait(false);
if (accountResponse.IsSuccessStatusCode is false) if (ar.IsSuccessStatusCode is false)
{ {
throw new PlaidException( throw new PlaidException("Unable to retrieve accounts");
"Unable to retrieve accounts",
accountResponse.Error,
accountResponse.RequestId,
(int?)accountResponse.StatusCode
);
} }
return [.. accountResponse.Accounts]; return [.. ar.Accounts];
} }
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken) public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
{ {
var itemResponse = await _client.ItemGetAsync(new() var ar = await _client.ItemGetAsync(new()
{ {
AccessToken = accessToken, AccessToken = accessToken,
}).ConfigureAwait(false); }).ConfigureAwait(false);
if (itemResponse.IsSuccessStatusCode is false) if (ar.IsSuccessStatusCode is false)
{ {
throw new PlaidException( throw new PlaidException("Unable to retrieve item");
"Unable to retrieve item",
itemResponse.Error,
itemResponse.RequestId,
(int?)itemResponse.StatusCode
);
} }
return itemResponse.Item; return ar.Item;
} }
} }
@@ -2,13 +2,6 @@ namespace FiscalOS.Infra.Common;
public class PlaidException : Exception public class PlaidException : Exception
{ {
public PlaidError? Error { get; }
public string? RequestId { get; }
public int? StatusCode { get; }
public string? ErrorCode => Error?.ErrorCode;
public string? ErrorType => Error?.ErrorType;
public string? ErrorMessage => Error?.ErrorMessage;
public PlaidException() public PlaidException()
{ {
} }
@@ -20,34 +13,4 @@ public class PlaidException : Exception
public PlaidException(string message, Exception innerException) : base(message, innerException) public PlaidException(string message, Exception innerException) : base(message, innerException)
{ {
} }
public PlaidException(
string message,
PlaidError? error = null,
string? plaidRequestId = null,
int? statusCode = null,
Exception? innerException = null
) : base(message, innerException)
{
Error = error;
RequestId = plaidRequestId;
StatusCode = statusCode;
if (error is not null)
{
Data[nameof(ErrorCode)] = error.ErrorCode;
Data[nameof(ErrorType)] = error.ErrorType;
Data[nameof(ErrorMessage)] = error.ErrorMessage;
}
if (plaidRequestId is not null)
{
Data[nameof(RequestId)] = plaidRequestId;
}
if (statusCode is not null)
{
Data[nameof(StatusCode)] = statusCode;
}
}
} }
@@ -1,5 +1,3 @@
using Microsoft.AspNetCore.Hosting;
namespace FiscalOS.Infra.DependencyInjection; namespace FiscalOS.Infra.DependencyInjection;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
@@ -33,14 +31,6 @@ public static class ServiceCollectionExtensions
var logger = sp.GetRequiredService<ILogger<PlaidClient>>(); var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>(); var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
var options = clientOptions.Value.ToPlaidOptions(); var options = clientOptions.Value.ToPlaidOptions();
var env = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (env is not null && env.Equals("Production", StringComparison.OrdinalIgnoreCase))
{
options.Value.Environment = Going.Plaid.Environment.Production;
}
return new PlaidClient(options, factory, logger); return new PlaidClient(options, factory, logger);
}); });
services.AddSingleton<IPlaidAccountService>(PlaidAccountService.From); services.AddSingleton<IPlaidAccountService>(PlaidAccountService.From);
@@ -46,7 +46,6 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
{ {
var existingTransactionIds = added.Select(t => t.TransactionId); var existingTransactionIds = added.Select(t => t.TransactionId);
var existingTransactions = await _appDbContext.Transactions var existingTransactions = await _appDbContext.Transactions
.Include(t => t.Metadata)
.Where(t => t.Metadata is PlaidTransactionMetadata && existingTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId)) .Where(t => t.Metadata is PlaidTransactionMetadata && existingTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ToListAsync(ct) .ToListAsync(ct)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -90,7 +89,7 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
account.Id, account.Id,
addedTransaction.Merchant, addedTransaction.Merchant,
addedTransaction.Description, addedTransaction.Description,
addedTransaction.CanonicalAmount, addedTransaction.Amount,
addedTransaction.PostedDate, addedTransaction.PostedDate,
transactionMetadata transactionMetadata
); );
@@ -7,7 +7,6 @@ public static class PlaidExtensions
{ {
public string Merchant => transaction.MerchantName ?? "Unknown merchant"; public string Merchant => transaction.MerchantName ?? "Unknown merchant";
public string Description => transaction.OriginalDescription ?? ""; public string Description => transaction.OriginalDescription ?? "";
public decimal? CanonicalAmount => transaction.Amount is decimal amount ? -amount : null;
public DateTimeOffset PostedDate => transaction.Datetime ?? ( public DateTimeOffset PostedDate => transaction.Datetime ?? (
transaction.Date.HasValue transaction.Date.HasValue
? new DateTimeOffset( ? new DateTimeOffset(
@@ -46,7 +46,6 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
{ {
var modifiedTransactionIds = modified.Select(t => t.TransactionId); var modifiedTransactionIds = modified.Select(t => t.TransactionId);
var existingModifiedTransactions = await _appDbContext.Transactions var existingModifiedTransactions = await _appDbContext.Transactions
.Include(t => t.Metadata)
.Where(t => t.Metadata is PlaidTransactionMetadata && modifiedTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId)) .Where(t => t.Metadata is PlaidTransactionMetadata && modifiedTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ToListAsync(ct) .ToListAsync(ct)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -85,7 +84,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
existing.AccountId, existing.AccountId,
plaidModifiedTransaction.Merchant, plaidModifiedTransaction.Merchant,
plaidModifiedTransaction.Description, plaidModifiedTransaction.Description,
plaidModifiedTransaction.CanonicalAmount, plaidModifiedTransaction.Amount,
plaidModifiedTransaction.PostedDate, plaidModifiedTransaction.PostedDate,
plaidMetadata plaidMetadata
); );
@@ -43,7 +43,6 @@ internal sealed class PlaidRemovedTransactionHandler : IPlaidRemovedTransactionH
try try
{ {
removedCount = await _appDbContext.Transactions removedCount = await _appDbContext.Transactions
.Include(t => t.Metadata)
.Where(t => t.Metadata is PlaidTransactionMetadata && removedIdsList.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId)) .Where(t => t.Metadata is PlaidTransactionMetadata && removedIdsList.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
.ExecuteDeleteAsync(cancellationToken) .ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -16,29 +16,25 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
private readonly IPlaidAddedTransactionHandler _addedHandler; private readonly IPlaidAddedTransactionHandler _addedHandler;
private readonly IPlaidModifiedTransactionHandler _modifiedHandler; private readonly IPlaidModifiedTransactionHandler _modifiedHandler;
private readonly IPlaidRemovedTransactionHandler _removedHandler; private readonly IPlaidRemovedTransactionHandler _removedHandler;
private readonly ILogger<PlaidTransactionProcessor> _logger;
private PlaidTransactionProcessor( private PlaidTransactionProcessor(
IPlaidAddedTransactionHandler addedHandler, IPlaidAddedTransactionHandler addedHandler,
IPlaidModifiedTransactionHandler modifiedHandler, IPlaidModifiedTransactionHandler modifiedHandler,
IPlaidRemovedTransactionHandler removedHandler, IPlaidRemovedTransactionHandler removedHandler
ILogger<PlaidTransactionProcessor> logger
) )
{ {
_addedHandler = addedHandler; _addedHandler = addedHandler;
_modifiedHandler = modifiedHandler; _modifiedHandler = modifiedHandler;
_removedHandler = removedHandler; _removedHandler = removedHandler;
_logger = logger;
} }
internal static PlaidTransactionProcessor From( internal static PlaidTransactionProcessor From(
IPlaidAddedTransactionHandler addedHandler, IPlaidAddedTransactionHandler addedHandler,
IPlaidModifiedTransactionHandler modifiedHandler, IPlaidModifiedTransactionHandler modifiedHandler,
IPlaidRemovedTransactionHandler removedHandler, IPlaidRemovedTransactionHandler removedHandler
ILogger<PlaidTransactionProcessor> logger
) )
{ {
return new(addedHandler, modifiedHandler, removedHandler, logger); return new(addedHandler, modifiedHandler, removedHandler);
} }
internal static PlaidTransactionProcessor From(IServiceProvider provider) internal static PlaidTransactionProcessor From(IServiceProvider provider)
@@ -46,8 +42,7 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
return new( return new(
provider.GetRequiredService<IPlaidAddedTransactionHandler>(), provider.GetRequiredService<IPlaidAddedTransactionHandler>(),
provider.GetRequiredService<IPlaidModifiedTransactionHandler>(), provider.GetRequiredService<IPlaidModifiedTransactionHandler>(),
provider.GetRequiredService<IPlaidRemovedTransactionHandler>(), provider.GetRequiredService<IPlaidRemovedTransactionHandler>()
provider.GetRequiredService<ILogger<PlaidTransactionProcessor>>()
); );
} }
@@ -74,18 +69,8 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
cancellationToken cancellationToken
).ConfigureAwait(false); ).ConfigureAwait(false);
_logger.LogInformation(
"Processed Plaid transactions sync for account {AccountId}: {NumAdded} of {TotalAdded} added, {NumModified} of {TotalModified} modified, {NumRemoved} of {TotalRemoved} removed",
account.Id,
numAdded,
syncResponse.Added.Count,
numModified,
syncResponse.Modified.Count,
numRemoved,
syncResponse.Removed.Count
);
return numAdded == syncResponse.Added.Count && return numAdded == syncResponse.Added.Count &&
numModified == syncResponse.Modified.Count; numModified == syncResponse.Modified.Count &&
numRemoved == syncResponse.Removed.Count;
} }
} }
@@ -1,14 +0,0 @@
namespace FiscalOS.ServiceDefaults;
internal sealed class SeqOptions
{
public string ServerUrl { get; init; } = string.Empty;
public string ApiKeyHeader { get; init; } = string.Empty;
public string ApiKey { get; init; } = string.Empty;
public bool IsEnabled => string.IsNullOrWhiteSpace(ServerUrl) is false &&
string.IsNullOrWhiteSpace(ApiKeyHeader) is false &&
string.IsNullOrWhiteSpace(ApiKey) is false;
public string LogEndpoint => $"{ServerUrl}/ingest/otlp/v1/logs";
public string TraceEndpoint => $"{ServerUrl}/ingest/otlp/v1/traces";
public string AuthHeader => $"{ApiKeyHeader}={ApiKey}";
}
@@ -1,8 +1,3 @@
using Microsoft.Extensions.Configuration;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
namespace FiscalOS.ServiceDefaults; namespace FiscalOS.ServiceDefaults;
public static class ServiceDefaultsExtensions public static class ServiceDefaultsExtensions
@@ -33,7 +28,6 @@ public static class ServiceDefaultsExtensions
{ {
logging.IncludeFormattedMessage = true; logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true; logging.IncludeScopes = true;
logging.ParseStateValues = true;
}); });
builder.Services.AddOpenTelemetry() builder.Services.AddOpenTelemetry()
@@ -82,38 +76,11 @@ public static class ServiceDefaultsExtensions
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{ {
var seqOptions = new SeqOptions();
builder.Configuration.GetSection(nameof(SeqOptions)).Bind(seqOptions);
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
var otel = builder.Services.AddOpenTelemetry(); if (useOtlpExporter)
if (seqOptions.IsEnabled)
{ {
otel builder.Services.AddOpenTelemetry().UseOtlpExporter();
.WithLogging(lb =>
{
lb.AddOtlpExporter(o =>
{
o.Protocol = OtlpExportProtocol.HttpProtobuf;
o.Endpoint = new Uri(seqOptions.LogEndpoint);
o.Headers = seqOptions.AuthHeader;
});
})
.WithTracing(tb =>
{
tb.AddOtlpExporter(o =>
{
o.Protocol = OtlpExportProtocol.HttpProtobuf;
o.Endpoint = new Uri(seqOptions.TraceEndpoint);
o.Headers = seqOptions.AuthHeader;
});
});
}
else if (useOtlpExporter)
{
otel.UseOtlpExporter();
} }
return builder; return builder;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
+6
View File
@@ -0,0 +1,6 @@
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!')
})
+10 -8
View File
@@ -1,9 +1,10 @@
import { globalIgnores } from 'eslint/config'; import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'; import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'; import pluginPlaywright from 'eslint-plugin-playwright'
import pluginOxlint from 'eslint-plugin-oxlint'; import pluginVitest from '@vitest/eslint-plugin'
import skipFormatting from 'eslint-config-prettier/flat'; import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
export default defineConfigWithVueTs( export default defineConfigWithVueTs(
{ {
@@ -14,6 +15,7 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'], ...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended, vueTsConfigs.recommended,
{ {
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'], files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
}, },
{ {
@@ -21,5 +23,5 @@ export default defineConfigWithVueTs(
files: ['src/**/__tests__/*'], files: ['src/**/__tests__/*'],
}, },
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'), ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting skipFormatting,
); )
+218 -123
View File
@@ -16,6 +16,7 @@
"vue-router": "^5.0.2" "vue-router": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4", "@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13", "@types/node": "^24.10.13",
@@ -28,6 +29,7 @@
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0", "eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
@@ -1204,9 +1206,9 @@
} }
}, },
"node_modules/@eslint/config-array/node_modules/minimatch": { "node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -1278,9 +1280,9 @@
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/minimatch": { "node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -1849,6 +1851,22 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1864,9 +1882,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1878,9 +1896,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1892,9 +1910,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1906,9 +1924,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1920,9 +1938,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1934,9 +1952,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1948,9 +1966,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1962,9 +1980,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1976,9 +1994,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1990,9 +2008,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2004,9 +2022,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -2018,9 +2036,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -2032,9 +2050,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -2046,9 +2064,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -2060,9 +2078,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2074,9 +2092,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2088,9 +2106,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -2102,9 +2120,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2116,9 +2134,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2130,9 +2148,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2144,9 +2162,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2158,9 +2176,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2172,9 +2190,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -2186,9 +2204,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2200,9 +2218,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3239,9 +3257,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.14.0", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3860,15 +3878,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/editorconfig": { "node_modules/editorconfig": {
"version": "1.0.7", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@one-ini/wasm": "0.1.1", "@one-ini/wasm": "0.1.1",
"commander": "^10.0.0", "commander": "^10.0.0",
"minimatch": "^9.0.1", "minimatch": "9.0.1",
"semver": "^7.5.3" "semver": "^7.5.3"
}, },
"bin": { "bin": {
@@ -3878,6 +3896,22 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/editorconfig/node_modules/semver": { "node_modules/editorconfig/node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -4134,6 +4168,35 @@
"jsonc-parser": "^3.3.1" "jsonc-parser": "^3.3.1"
} }
}, },
"node_modules/eslint-plugin-playwright": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.7.0.tgz",
"integrity": "sha512-kUgwDZL3knnuJF53WSf5xNnB1aLPnX8furoh0PSrmmFIfMfIMmY3sNd4gtZ2MUUnaIX1/A9ndYtD7bhV1dj+1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"globals": "^17.3.0"
},
"engines": {
"node": ">=16.9.0"
},
"peerDependencies": {
"eslint": ">=8.40.0"
}
},
"node_modules/eslint-plugin-playwright/node_modules/globals": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-vue": { "node_modules/eslint-plugin-vue": {
"version": "10.8.0", "version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz",
@@ -4234,9 +4297,9 @@
} }
}, },
"node_modules/eslint/node_modules/minimatch": { "node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -5328,13 +5391,13 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.2" "brace-expansion": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -5866,6 +5929,38 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -6060,9 +6155,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6076,31 +6171,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.57.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
+3
View File
@@ -8,6 +8,7 @@
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest", "test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"lint": "run-s lint:*", "lint": "run-s lint:*",
@@ -24,6 +25,7 @@
"vue-router": "^5.0.2" "vue-router": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4", "@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13", "@types/node": "^24.10.13",
@@ -36,6 +38,7 @@
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0", "eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
+45
View File
@@ -0,0 +1,45 @@
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,
},
})
@@ -68,8 +68,7 @@ describe('LoginForm', () => {
const wrapper = mount(LoginForm, { const wrapper = mount(LoginForm, {
attachTo: document.body, attachTo: document.body,
props: { props: {
onValidSubmit: () => onValidSubmit: () => new Promise(resolve => {
new Promise(resolve => {
resolveSubmit = resolve; resolveSubmit = resolve;
}), }),
}, },
@@ -97,8 +96,7 @@ describe('LoginForm', () => {
const wrapper = mount(LoginForm, { const wrapper = mount(LoginForm, {
attachTo: document.body, attachTo: document.body,
props: { props: {
onValidSubmit: () => onValidSubmit: () => new Promise(resolve => {
new Promise(resolve => {
wasCalled = true; wasCalled = true;
resolve(); resolve();
}), }),
-18
View File
@@ -24,24 +24,6 @@ input {
color: inherit; color: inherit;
} }
select {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option :hover {
background-color: var(--bg-element);
}
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
@@ -1,87 +0,0 @@
<script setup lang="ts">
import CircleSpinner from '@/components/CircleSpinner.vue';
import type { AvailableAccount } from '@/services/institutionService';
import { ref, watchEffect } from 'vue';
const props = defineProps<{
availableAccounts: AvailableAccount[];
isSubmitting: boolean;
}>();
const emit = defineEmits<{
submit: [account: AvailableAccount];
}>();
const selectedAccount = ref<AvailableAccount | null>(null);
const selectRef = ref<HTMLSelectElement | null>(null);
watchEffect(() => {
if (props.availableAccounts.length > 0) {
selectRef.value?.focus();
}
});
function handleSubmit() {
if (selectedAccount.value === null) {
return;
}
emit('submit', selectedAccount.value);
}
</script>
<template>
<form
class="add-account-form"
@submit.prevent="handleSubmit"
>
<label for="account-select">Select account to add:</label>
<select
id="account-select"
ref="selectRef"
v-model="selectedAccount"
>
<option
:value="null"
disabled
hidden
>
Select an account
</option>
<option
v-for="account in availableAccounts"
:key="account.providerId"
:value="account"
>
{{ account.providerName }}
</option>
</select>
<button
class="add-account-button"
type="submit"
:disabled="isSubmitting"
>
<CircleSpinner v-if="isSubmitting" />
<span v-else>Add</span>
</button>
</form>
</template>
<style scoped>
.add-account-form {
display: flex;
gap: 0.25rem;
align-items: center;
}
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.add-account-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>
@@ -1,21 +0,0 @@
<template>
<span class="spinner" />
</template>
<style scoped>
.spinner {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,236 +0,0 @@
<script setup lang="ts">
import AddAccountForm from '@/components/AddAccountForm.vue';
import CircleSpinner from '@/components/CircleSpinner.vue';
import { useAccountService } from '@/composables/useAccountService';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import { ref } from 'vue';
const props = defineProps<{
institution: Institution;
}>();
const emit = defineEmits<{
accountAdded: [];
}>();
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
const accountService = useAccountService(userStore);
const loading = ref(false);
const addingAccount = ref(false);
const availableAccounts = ref<AvailableAccount[]>([]);
const showAddForm = ref(false);
async function handleAddAccountClick() {
loading.value = true;
const getAccountsResult = await institutionService.getAvailableAccounts(props.institution.id);
loading.value = false;
if (getAccountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
const notAlreadyAddedAccounts = getAccountsResult.val.filter(a => {
return props.institution.accounts.some(ac => ac.providerId === a.providerId) === false;
});
availableAccounts.value = notAlreadyAddedAccounts;
showAddForm.value = true;
}
async function handleFormSubmit(account: AvailableAccount) {
addingAccount.value = true;
try {
const addAccountResult = await accountService.add(
account.providerInstitutionId,
account.providerId,
account.providerName
);
if (addAccountResult.err) {
alert(addAccountResult.val.map(e => e.message).join('\n'));
return;
}
showAddForm.value = false;
emit('accountAdded');
} finally {
addingAccount.value = false;
}
}
</script>
<template>
<div class="institution-container">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button
class="add-account-button"
type="button"
:disabled="loading"
@click="handleAddAccountClick"
>
<CircleSpinner v-if="loading" />
<span v-else>Add Account</span>
</button>
</div>
</div>
<div class="accounts-list">
<div
class="account-row"
v-for="account in institution.accounts"
:key="account.id"
>
<div class="account-card">{{ account.name }}</div>
</div>
</div>
<div
class="add-account-card"
v-if="showAddForm"
>
<AddAccountForm
:availableAccounts="availableAccounts"
:isSubmitting="addingAccount"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
<style scoped>
.institution-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.institution-card,
.account-card,
.add-account-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card,
.account-card {
position: relative;
}
.institution-card > div {
flex: 1;
}
.institution-card > div:last-of-type {
display: flex;
justify-content: flex-end;
}
.institution-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
.institution-card::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
height: calc(50% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.add-account-card {
margin-left: 2rem;
gap: 0.5rem;
}
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.add-account-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.account-row {
position: relative;
padding-left: 2rem;
}
.account-row::before {
content: '';
position: absolute;
top: 0;
left: -1px;
height: calc(100% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.account-row:not(:first-child)::before {
top: -0.5rem;
height: calc(100% + 1rem);
}
.account-row:last-child::before {
height: 50%;
}
.account-row:not(:first-child):last-child::before {
top: -0.5rem;
height: calc(50% + 0.5rem);
}
.account-row::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
width: calc(2rem + 1px);
height: 2px;
background: var(--brand-primary);
transform: translateY(-50%);
}
.account-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
</style>
+5 -18
View File
@@ -72,6 +72,7 @@
if (props.onValidSubmit !== undefined) { if (props.onValidSubmit !== undefined) {
await props.onValidSubmit(formState.value); await props.onValidSubmit(formState.value);
} }
} finally { } finally {
formState.value.isLoggingIn = false; formState.value.isLoggingIn = false;
} }
@@ -82,31 +83,17 @@
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input <input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
type="text"
name="username"
id="username"
:value="formState.username.value"
@input="handleUsernameInput"
/>
<div class="error">{{ formState.username.error }}</div> <div class="error">{{ formState.username.error }}</div>
</div> </div>
<div> <div>
<label for="password">Password</label> <label for="password">Password</label>
<input <input type="password" name="password" id="password" :value="formState.password.value"
type="password" @input="handlePasswordInput" />
name="password"
id="password"
:value="formState.password.value"
@input="handlePasswordInput"
/>
<div class="error">{{ formState.password.error }}</div> <div class="error">{{ formState.password.error }}</div>
</div> </div>
<div> <div>
<button <button type="submit" :disabled="formState.isLoggingIn">
type="submit"
:disabled="formState.isLoggingIn"
>
Login Login
</button> </button>
</div> </div>
+12 -108
View File
@@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue'; import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import RightArrowBracketIcon from './icons/RightArrowBracketIcon.vue';
const userStore = useUserStore(); const userStore = useUserStore();
const router = useRouter(); const router = useRouter();
const authService = useAuthService(userStore);
const asideClasses = computed(() => ({ const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed, collapsed: userStore.user?.sidebarCollapsed,
@@ -18,14 +15,12 @@
userStore.toggleSidebar(); userStore.toggleSidebar();
} }
async function handleLogout() { function handleLogout() {
const logoutResult = await authService.logout(); // TODO: Also need to log user
// out on the server...which is
if (logoutResult.err) { // basically just clearing
alert(logoutResult.val.map(e => e.message).join('\n')); // refresh token cookie and revoking
return; // it in the database
}
userStore.logUserOut(); userStore.logUserOut();
router.push({ path: '/public/login' }); router.push({ path: '/public/login' });
} }
@@ -40,30 +35,13 @@
> >
<LeftArrowIcon /> <LeftArrowIcon />
</button> </button>
<div class="sidebar-content">
<div class="top">
<nav>
<ul>
<li>
<RouterLink to="/accounts">Accounts</RouterLink>
</li>
<li>
<RouterLink to="/transactions">Transactions</RouterLink>
</li>
</ul>
</nav>
</div>
<div class="bottom">
<button <button
class="logout-button" class="logout-button"
type="button" type="button"
@click="handleLogout" @click="handleLogout"
> >
<RightArrowBracketIcon />
Logout Logout
</button> </button>
</div>
</div>
</aside> </aside>
</template> </template>
@@ -90,79 +68,12 @@
} }
} }
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: 3rem;
flex: 1;
overflow: hidden;
opacity: 1;
visibility: visible;
transition-property: opacity, visibility;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
.top {
padding: 1rem;
}
nav ul {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
nav a {
display: flex;
width: 100%;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
nav a.router-link-exact-active,
nav a:hover {
background: var(--bg-element);
}
nav a.router-link-exact-active {
position: relative;
}
nav a.router-link-exact-active::before {
content: ' ';
position: absolute;
left: 0;
height: 1.25rem;
background: red;
border: 2px solid var(--brand-primary);
}
.bottom {
padding: 1rem;
}
.logout-button { .logout-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
background: var(--bg-element); background: var(--bg-element);
padding: 0.5rem 1rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.logout-button svg {
--size: 1.5rem;
width: var(--size);
height: var(--size);
fill: var(--text-primary);
}
.toggle-button { .toggle-button {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -179,13 +90,6 @@
transition-timing-function: var(--transition-function); transition-timing-function: var(--transition-function);
} }
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
fill: var(--text-primary);
}
aside.collapsed { aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2); width: calc(0.125rem + var(--button-size) / 2);
} }
@@ -194,9 +98,9 @@
transform: rotate(180deg); transform: rotate(180deg);
} }
aside.collapsed .sidebar-content { .toggle-button svg {
opacity: 0; --size: 1rem;
visibility: hidden; width: var(--size);
pointer-events: none; height: var(--size);
} }
</style> </style>
@@ -22,16 +22,5 @@
main { main {
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
padding-right: 0rem;
overflow: auto;
scroll-behavior: smooth;
scrollbar-color: var(--bg-surface) var(--bg-app);
scrollbar-gutter: stable;
}
@media screen and (max-width: 48rem) {
main {
margin-right: 1rem;
}
} }
</style> </style>
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
</script> </script>
<template> <template>
@@ -1,106 +0,0 @@
<script setup lang="ts">
import type { Transaction } from '@/services/transactionService';
defineProps<{
transaction: Transaction;
}>();
const emit = defineEmits<{
delete: [id: string];
}>();
function formatDate(date: string) {
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return formatter.format(new Date(date));
}
function formatAmount(amount: number): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
return formatter.format(amount);
}
</script>
<template>
<div class="card">
<div class="top">
<div class="left">
<div>{{ transaction.merchantName }}</div>
<div class="date">{{ formatDate(transaction.date) }}</div>
</div>
<div class="right">
<div>{{ formatAmount(transaction.amount) }}</div>
</div>
</div>
<div class="bottom">
<p>{{ transaction.description }}</p>
</div>
<div class="actions">
<button
class="delete-btn"
@click="emit('delete', transaction.id)"
>
Delete
</button>
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--bg-surface);
border-radius: 0.25rem;
padding: 1rem;
}
.top {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date,
.bottom {
font-size: 0.875rem;
color: #666666;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.5rem;
}
.delete-btn {
background: none;
border: 0.0625rem solid var(--state-error);
border-radius: 0.25rem;
color: var(--state-error);
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.delete-btn:hover {
background: var(--state-error);
color: var(--text-primary);
}
</style>
@@ -1,10 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
>
<path
d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"
/>
</svg>
</template>
@@ -1,10 +1,6 @@
<template> <template>
<svg <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"
>
<path <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" 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> </svg>
</template> </template>
@@ -1,7 +0,0 @@
import { AccountServiceFactoryKey } from '@/services/accountService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useAccountService(store: UserStore) {
return useService(store, AccountServiceFactoryKey);
}
@@ -1,7 +1,27 @@
import { AuthServiceFactoryKey } from '@/services/authService'; import { AuthServiceFactoryKey } from "@/services/authService";
import type { UserStore } from '@/stores/userStore'; import { ClientConfig, ClientFactoryKey } from "@/services/client";
import { useService } from './useService'; import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
export function useAuthService(store: UserStore) { export function useAuthService(store: UserStore) {
return useService(store, AuthServiceFactoryKey); 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,7 +1,27 @@
import { InstitutionServiceFactoryKey } from '@/services/institutionService'; import { ClientConfig, ClientFactoryKey } from "@/services/client";
import type { UserStore } from '@/stores/userStore'; import { InstituionServiceFactoryKey } from "@/services/institutionService";
import { useService } from './useService'; import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
export function useInstitutionService(store: UserStore) { export function useInstitutionService(store: UserStore) {
return useService(store, InstitutionServiceFactoryKey); 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;
} }
@@ -1,33 +0,0 @@
import type { InjectionKey } from 'vue';
import { inject } from 'vue';
import { ClientConfig, ClientFactoryKey, type IClient } from '@/services/client';
import type { UserStore } from '@/stores/userStore';
export interface IServiceFactory<TService> {
create: (client: IClient) => TService;
}
export function useService<TService>(
store: UserStore,
serviceFactoryKey: InjectionKey<IServiceFactory<TService>>
): TService {
const clientFactory = inject(ClientFactoryKey);
const serviceFactory = inject(serviceFactoryKey);
if (clientFactory === undefined) {
throw new Error('Failed to inject client factory.');
}
if (serviceFactory === undefined) {
throw new Error('Failed to inject service factory.');
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}` },
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
return serviceFactory.create(client);
}
@@ -1,7 +0,0 @@
import { TransactionServiceFactoryKey } from '@/services/transactionService';
import type { UserStore } from '@/stores/userStore';
import { useService } from './useService';
export function useTransactionService(store: UserStore) {
return useService(store, TransactionServiceFactoryKey);
}
+9 -19
View File
@@ -1,31 +1,21 @@
import './assets/css/main.css'; import './assets/css/main.css';
import { createApp } from 'vue'; import { createApp } from "vue";
import { createPinia } from 'pinia'; import { createPinia } from "pinia";
import App from './App.vue'; import App from "./App.vue";
import router from './router'; import router from "./router";
import { ClientFactory, ClientFactoryKey } from './services/client'; import { ClientFactory, ClientFactoryKey } from "./services/client";
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService'; import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
import { import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
InstitutionServiceFactoryKey,
InstitutionServiceFactory,
} from './services/institutionService';
import {
TransactionServiceFactory,
TransactionServiceFactoryKey,
} from './services/transactionService';
import { AccountServiceFactory, AccountServiceFactoryKey } from './services/accountService';
const app = createApp(App); const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory()); app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory()); app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstitutionServiceFactoryKey, new InstitutionServiceFactory()); app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
app.provide(AccountServiceFactoryKey, new AccountServiceFactory());
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
app.mount('#app'); app.mount("#app");
+1 -23
View File
@@ -23,9 +23,6 @@ const router = createRouter({
{ {
path: 'login', path: 'login',
component: () => import('../views/LoginView.vue'), component: () => import('../views/LoginView.vue'),
meta: {
title: 'Login',
},
}, },
], ],
}, },
@@ -63,30 +60,11 @@ const router = createRouter({
children: [ children: [
{ {
path: '/', path: '/',
redirect: '/accounts', component: () => import('../views/HomeView.vue'),
},
{
path: '/accounts',
component: () => import('../views/AccountsView.vue'),
meta: {
title: 'Accounts',
},
},
{
path: '/transactions',
component: () => import('../views/TransactionsView.vue'),
meta: {
title: 'Transactions',
},
}, },
], ],
}, },
], ],
}); });
router.beforeEach((to, _, next) => {
document.title = to.meta.title ? `FiscalOS - ${to.meta.title}` : 'FiscalOS';
next();
});
export default router; export default router;
@@ -1,58 +0,0 @@
import type { InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
import { Err, Ok, type Result } from 'ts-results';
type AccountServiceFactoryKeyType = InjectionKey<IAccountServiceFactory>;
export const AccountServiceFactoryKey: AccountServiceFactoryKeyType =
Symbol('AccountServiceFactory');
export interface IAccountServiceFactory {
create: (client: IClient) => IAccountService;
}
export class AccountServiceFactory implements IAccountServiceFactory {
create(client: IClient): IAccountService {
return new AccountService(client);
}
}
export interface IAccountService {
add: (
providerInstitutionId: string,
providerAccountId: string,
providerAccountName: string
) => Promise<Result<boolean, Error[]>>;
}
export class AccountService implements IAccountService {
private readonly client: IClient;
private readonly endpoints = {
add: '/api/accounts',
};
constructor(client: IClient) {
this.client = client;
}
async add(providerInstitutionId: string, providerAccountId: string, providerAccountName: string) {
const request = new ClientRequestWithBody(this.endpoints.add, undefined, {
providerInstitutionId,
providerAccountId,
providerAccountName,
});
try {
const res = await this.client.post(request);
if (res.ok === false) {
return Err([new Error('Failed to add account.')]);
}
return Ok(true);
} catch (error) {
console.error(error);
return Err([new Error('Failed to add account.')]);
}
}
}
@@ -19,7 +19,6 @@ export class AuthServiceFactory implements IAuthServiceFactory {
export interface IAuthService { export interface IAuthService {
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>; login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
refreshToken: () => Promise<Result<LoginResponse, Error[]>>; refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
logout: () => Promise<Result<boolean, Error[]>>;
} }
export class AuthService implements IAuthService { export class AuthService implements IAuthService {
@@ -27,30 +26,12 @@ export class AuthService implements IAuthService {
private readonly endpoints = { private readonly endpoints = {
login: '/api/auth/login', login: '/api/auth/login',
refreshToken: '/api/auth/refresh', refreshToken: '/api/auth/refresh',
logout: '/api/auth/logout',
}; };
constructor(client: IClient) { constructor(client: IClient) {
this.client = client; this.client = client;
} }
async logout() {
const request = new ClientRequestWithBody(this.endpoints.logout, undefined);
try {
const res = await this.client.post(request);
if (res.ok === false) {
return Err([new Error('Failed to logout. Please try again.')]);
}
return Ok(true);
} catch (e) {
console.error(e);
return Err([new Error('Failed to logout. Please try again.')]);
}
}
async login(username: string, password: string) { async login(username: string, password: string) {
const request = new ClientRequestWithBody( const request = new ClientRequestWithBody(
this.endpoints.login, this.endpoints.login,
@@ -4,9 +4,8 @@ import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>; type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
export const InstitutionServiceFactoryKey: InstitutionServiceFactoryKeyType = Symbol( export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
'InstitutionServiceFactory' Symbol('AuthServiceFactory');
);
export interface IInstitutionServiceFactory { export interface IInstitutionServiceFactory {
create: (client: IClient) => IInstitutionService; create: (client: IClient) => IInstitutionService;
@@ -137,9 +136,8 @@ type LinkTokenResponse = {
type Account = { type Account = {
id: string; id: string;
providerId: string;
name: string; name: string;
}; }
export type Institution = { export type Institution = {
id: string; id: string;
@@ -1,92 +0,0 @@
import type { InjectionKey } from 'vue';
import { ClientRequest, type IClient } from './client';
import { Err, Ok, type Result } from 'ts-results';
type TransactionServiceFactoryKeyType = InjectionKey<ITransactionServiceFactory>;
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType = Symbol(
'TransactionServiceFactory'
);
export interface ITransactionServiceFactory {
create: (client: IClient) => ITransactionService;
}
export class TransactionServiceFactory implements ITransactionServiceFactory {
create(client: IClient): ITransactionService {
return new TransactionService(client);
}
}
export interface ITransactionService {
get: (pageNumber?: number, pageSize?: number) => Promise<Result<Page<Transaction>, Error[]>>;
deleteById: (id: string) => Promise<Result<boolean, Error[]>>;
}
export class TransactionService implements ITransactionService {
private readonly client: IClient;
private readonly endpoints = {
get: '/api/transactions',
};
constructor(client: IClient) {
this.client = client;
}
async deleteById(id: string): Promise<Result<boolean, Error[]>> {
const url = this.endpoints.get + '/' + id;
const request = new ClientRequest(url);
try {
const res = await this.client.delete(request);
if (res.ok === false) {
return Err([new Error('Failed to delete transaction.')]);
}
return Ok(true);
} catch (error) {
console.error(error);
return Err([new Error('Failed to delete transaction.')]);
}
}
async get(pageNumber: number = 1, pageSize: number = 500) {
const queryParams = new URLSearchParams({
pageNumber: pageNumber.toString(),
pageSize: pageSize.toString(),
});
const url = this.endpoints.get + '?' + queryParams.toString();
const request = new ClientRequest(url);
try {
const res = await this.client.get(request);
if (res.ok === false) {
return Err([new Error('Failed to retrieve transactions.')]);
}
const data = await res.json();
return Ok(data as Page<Transaction>);
} catch (error) {
console.error(error);
return Err([new Error('Failed to retrieve transactions.')]);
}
}
}
export type Page<T> = {
pageNumber: number;
pageSize: number;
totalItems: number;
totalPages: number;
items: T[];
};
export type Transaction = {
id: string;
merchantName: string;
description: string;
amount: number;
date: string;
};
+1 -1
View File
@@ -88,7 +88,7 @@ export const useUserStore = defineStore('userStore', () => {
const updatedUser = { const updatedUser = {
...user.value, ...user.value,
sidebarCollapsed: !user.value?.sidebarCollapsed, sidebarCollapsed: !user.value?.sidebarCollapsed,
}; }
user.value = updatedUser; user.value = updatedUser;
saveUserToLocalSotrage(updatedUser); saveUserToLocalSotrage(updatedUser);
} }
-129
View File
@@ -1,129 +0,0 @@
<script setup lang="ts">
import InstitutionCard from '@/components/InstitutionCard.vue';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import {
usePlaidLink,
type PlaidLinkOnSuccessMetadata,
type PlaidLinkOptions,
} from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
type InstitutionData =
| { status: 'loading' }
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
async function handleSuccess(publicToken: string, metadata: PlaidLinkOnSuccessMetadata) {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
}
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: handleSuccess,
});
const { open } = usePlaidLink(plaidOptions);
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
async function institutionsDataWatcher(data: InstitutionData) {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = { status: 'errored', errors: institutionsResult.val };
return;
}
institutionsData.value = { status: 'loaded', data: institutionsResult.val };
}
watch(institutionsData, institutionsDataWatcher, { immediate: true });
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
</script>
<template>
<div class="header">
<h1>Accounts</h1>
<button
class="add-institution-button"
type="button"
@click="handleAddInstitutionClick"
>
Add Institution
</button>
</div>
<div
v-if="institutionsData.status === 'loaded'"
class="institutions-container"
>
<InstitutionCard
v-for="institution in institutionsData.data"
:key="institution.id"
:institution="institution"
@accountAdded="institutionsData = { status: 'loading' }"
/>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.header {
display: flex;
gap: 1rem;
align-items: center;
}
.institutions-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.add-institution-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
type InstitutionData =
| {
status: 'loading';
}
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: async (publicToken, metadata) => {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
watch(
institutionsData,
async data => {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = {
status: 'errored',
errors: institutionsResult.val,
};
return;
}
institutionsData.value = {
status: 'loaded',
data: institutionsResult.val,
};
},
{ immediate: true }
);
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
const targetInstitutionOfAdd = ref<Institution | null>(null);
const availableAccounts = ref<AvailableAccount[]>([]);
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
async function handleAddAccountClick(institution: Institution) {
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
if (accountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
availableAccounts.value = accountsResult.val;
targetInstitutionOfAdd.value = institution;
}
async function handleAccountAddClick() {
if (targetAccountOfAdd.value === null) {
return;
}
const request = {
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
plaidAccountId: targetAccountOfAdd.value.providerId,
plaidAccountName: targetAccountOfAdd.value.providerName,
accountAvailableBalance: targetAccountOfAdd.value.availableBalance,
accountCurrencyCode: targetAccountOfAdd.value.currencyCode,
};
try {
const res = await fetch('/api/accounts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userStore.user?.token}`,
},
body: JSON.stringify(request),
});
if (res.ok === false) {
alert('Add failed');
return;
}
targetInstitutionOfAdd.value = null;
institutionsData.value = { status: 'loading' };
} catch (error) {
console.error(error);
}
}
</script>
<template>
<div>
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
Add Institution
</button>
</div>
<div v-if="institutionsData.status === 'loaded'" class="institutions-container">
<div v-for="institution in institutionsData.data" v-bind:key="institution.id">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button class="add-account-button" type="button" @click="handleAddAccountClick(institution)">
Add Account
</button>
</div>
</div>
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
<select v-model="targetAccountOfAdd">
<option v-for="account in availableAccounts" v-bind:key="account.providerId" :value="account">
{{ account.providerName }}
</option>
</select>
<button type="button" @click="handleAccountAddClick">
Add
</button>
</div>
<div v-for="accounts in institution.accounts" v-bind:key="accounts.id">
<div>{{ accounts.name }}</div>
</div>
</div>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.institutions-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.institution-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card>div {
flex: 1;
}
.institution-card>div:last-of-type {
display: flex;
justify-content: flex-end;
}
.add-institution-button,
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
@@ -1,60 +0,0 @@
<script setup lang="ts">
import TransactionCard from '@/components/TransactionCard.vue';
import { useTransactionService } from '@/composables/useTransactionService';
import type { Transaction } from '@/services/transactionService';
import { useUserStore } from '@/stores/userStore';
import { onMounted, ref } from 'vue';
const userStore = useUserStore();
const transactionService = useTransactionService(userStore);
const transactions = ref<Transaction[]>([]);
async function handleDelete(id: string) {
const result = await transactionService.deleteById(id);
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
transactions.value = transactions.value.filter(t => t.id !== id);
}
onMounted(async () => {
const result = await transactionService.get();
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
transactions.value = result.val.items;
});
</script>
<template>
<div>
<h1>Transactions</h1>
<ul class="transactions">
<li
v-for="transaction in transactions"
:key="transaction.id"
>
<TransactionCard
:transaction="transaction"
@delete="handleDelete"
/>
</li>
</ul>
</div>
</template>
<style scoped>
.transactions {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>
-1
View File
@@ -10,7 +10,6 @@
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" /> <PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" /> <PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" /> <PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -22,7 +22,6 @@
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" /> <ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" /> <ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" /> <ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,5 +1,3 @@
using Microsoft.AspNetCore.WebUtilities;
namespace FiscalOS.API.Tests.Infra; namespace FiscalOS.API.Tests.Infra;
internal sealed class HttpRequestBuilder internal sealed class HttpRequestBuilder
@@ -10,7 +8,6 @@ internal sealed class HttpRequestBuilder
private string? _bearerToken; private string? _bearerToken;
private readonly Dictionary<string, string> _cookies = []; private readonly Dictionary<string, string> _cookies = [];
private readonly Dictionary<string, string> _headers = []; private readonly Dictionary<string, string> _headers = [];
private readonly Dictionary<string, string?> _queryParameters = [];
private HttpRequestBuilder() private HttpRequestBuilder()
{ {
@@ -71,12 +68,6 @@ internal sealed class HttpRequestBuilder
return this; return this;
} }
public HttpRequestBuilder WithQueryParameter(string name, string value)
{
_queryParameters[name] = value;
return this;
}
public HttpRequestBuilder Post(Uri uri) public HttpRequestBuilder Post(Uri uri)
{ {
_method = HttpMethod.Post; _method = HttpMethod.Post;
@@ -112,11 +103,7 @@ internal sealed class HttpRequestBuilder
throw new InvalidOperationException("URI must be set before building the request."); throw new InvalidOperationException("URI must be set before building the request.");
} }
var uri = _queryParameters.Count > 0 var request = new HttpRequestMessage(_method, _uri);
? QueryHelpers.AddQueryString(_uri.ToString(), _queryParameters)
: _uri.ToString();
var request = new HttpRequestMessage(_method, uri);
if (_body is not null) if (_body is not null)
{ {
@@ -1,4 +1,3 @@
using FiscalOS.API.Accounts.Add;
using FiscalOS.Core.Queuing; using FiscalOS.Core.Queuing;
using FiscalOS.Infra.Transactions.Plaid; using FiscalOS.Infra.Transactions.Plaid;
@@ -32,9 +31,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>() await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{ {
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."], ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."], ["PlaidAccountId"] = ["The PlaidAccountId field is required."],
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."], ["PlaidAccountName"] = ["The PlaidAccountName field is required."],
}); });
} }
@@ -46,8 +45,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid()) .WithUserId(Guid.NewGuid())
.WithBody(new .WithBody(new
{ {
providerAccountId = "accountId", plaidAccountId = "accountId",
providerAccountName = "Some Account", plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
}) })
.Build(); .Build();
@@ -55,7 +55,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>() await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{ {
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."], ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
}); });
} }
@@ -67,8 +67,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid()) .WithUserId(Guid.NewGuid())
.WithBody(new .WithBody(new
{ {
providerInstitutionId = "institutionId", plaidInstitutionId = "institutionId",
providerAccountName = "Some Account", plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
}) })
.Build(); .Build();
@@ -76,7 +77,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>() await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{ {
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."], ["PlaidAccountId"] = ["The PlaidAccountId field is required."],
}); });
} }
@@ -88,8 +89,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid()) .WithUserId(Guid.NewGuid())
.WithBody(new .WithBody(new
{ {
providerInstitutionId = "institutionId", plaidInstitutionId = "institutionId",
providerAccountId = "accountId", plaidAccountId = "accountId",
accountCurrencyCode = "USD",
}) })
.Build(); .Build();
@@ -97,7 +99,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>() await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{ {
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."], ["PlaidAccountName"] = ["The PlaidAccountName field is required."],
}); });
} }
@@ -109,9 +111,10 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(Guid.NewGuid()) .WithUserId(Guid.NewGuid())
.WithBody(new .WithBody(new
{ {
providerInstitutionId = "id", plaidInstitutionId = "id",
providerAccountId = "id", plaidAccountId = "id",
providerAccountName = "Some Account", plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
}) })
.Build(); .Build();
@@ -142,9 +145,10 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id) .WithUserId(user.Id)
.WithBody(new .WithBody(new
{ {
providerInstitutionId = "id", plaidInstitutionId = "id",
providerAccountId = "id", plaidAccountId = "id",
providerAccountName = "Some Account", plaidAccountName = "Some Account",
accountCurrencyCode = "USD",
}) })
.Build(); .Build();
@@ -152,7 +156,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>() await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{ {
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."], ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
}); });
} }
@@ -195,9 +199,10 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
.WithUserId(user.Id) .WithUserId(user.Id)
.WithBody(new .WithBody(new
{ {
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId, plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
providerAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId, plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
providerAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName, plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
accountCurrencyCode = "USD",
}) })
.Build(); .Build();
@@ -247,15 +252,20 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
var newAccountId = "newAccountId"; var newAccountId = "newAccountId";
var newAccountName = "New Account"; var newAccountName = "New Account";
var expectedBalance = 100;
var expectedCurrencyCode = "USD";
using var request = HttpRequestBuilder.New() using var request = HttpRequestBuilder.New()
.Post(AddUri) .Post(AddUri)
.WithUserId(user.Id) .WithUserId(user.Id)
.WithBody(new .WithBody(new
{ {
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId, plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
providerAccountId = newAccountId, plaidAccountId = newAccountId,
providerAccountName = newAccountName, plaidAccountName = newAccountName,
accountCurrentBalance = expectedBalance,
accountAvailableBalance = expectedBalance,
accountCurrencyCode = expectedCurrencyCode,
}) })
.Build(); .Build();
@@ -1,76 +0,0 @@
using FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Tests.Integration.Auth;
public class LogoutTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri LogoutUri = new("/auth/logout", UriKind.Relative);
private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative);
[Fact]
public async Task Logout_WhenCalled_ItShouldExpireRefreshTokenCookie()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey);
context.Add(user);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var loginRequest = HttpRequestBuilder.New()
.Post(LoginUri)
.WithBody(new
{
username = "Stevan",
password = "@Password1",
})
.Build();
var loginResponse = await Client.SendAsync(loginRequest, TestContext.Current.CancellationToken);
var setCookieHeaders = loginResponse.Headers.GetValues("Set-Cookie");
var refreshTokenCookieHeader = setCookieHeaders.First(h => h.StartsWith("fiscalos_refresh_cookie", StringComparison.OrdinalIgnoreCase));
var refreshTokenCookieParts = refreshTokenCookieHeader.Trim().Split("=");
var accessToken = (await loginResponse.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK))
.Which
.AccessToken;
var userRefreshTokens = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<RefreshToken>()
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
}, TestContext.Current.CancellationToken);
userRefreshTokens.Should()
.ContainSingle(r => r.IsExpired(DateTimeOffset.UtcNow) == false && r.Revoked == false);
using var logoutRequest = HttpRequestBuilder.New()
.Post(LogoutUri)
.WithBearerToken(accessToken)
.WithCookie(refreshTokenCookieParts[0], refreshTokenCookieParts[1])
.Build();
var logoutResponse = await Client.SendAsync(logoutRequest, TestContext.Current.CancellationToken);
logoutResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
logoutResponse.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
var updatedUserRefreshTokens = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<RefreshToken>()
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
}, TestContext.Current.CancellationToken);
updatedUserRefreshTokens.Should()
.ContainSingle(r => r.Revoked && r.IsExpired(DateTimeOffset.UtcNow) == false);
}
}
@@ -1,120 +0,0 @@
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.API.Tests.Integration.Transactions;
public class DeleteByIdTests(TestApi testApi) : IntegrationTest(testApi)
{
private static Uri GetDeleteByIdUri(Guid id)
{
return new($"/transactions/{id}", UriKind.Relative);
}
[Fact]
public async Task DeleteById_WhenCalledAndUnauthenticated_ItShouldREturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Delete(GetDeleteByIdUri(Guid.NewGuid()))
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task DeleteById_WhenCalledWithNonExistentTransaction_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Delete(GetDeleteByIdUri(Guid.NewGuid()))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteById_WhenCalledWithTransactionIdThatDoesNotBelongToTheUser_ItShouldReturn404WithProblemDetails()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var user = UserBuilder.Create()
.WithInstitution(static ib =>
{
ib.WithMetadata();
ib.WithAccount(static ab =>
{
ab.WithMetadata();
ab.WithTransaction(static tb =>
{
tb.WithMetadata();
});
});
})
.Build();
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Delete(GetDeleteByIdUri(Guid.NewGuid()))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteById_WhenCalledWithTransactionIdThatDoesExistAndBelongsToTheUser_ItShouldReturn204WithNoContent()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var user = UserBuilder.Create()
.WithInstitution(static ib =>
{
ib.WithMetadata();
ib.WithAccount(static ab =>
{
ab.WithMetadata();
ab.WithTransaction(static tb =>
{
tb.WithMetadata();
});
});
})
.Build();
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Delete(GetDeleteByIdUri(user.Transactions.First().Id))
.WithUserId(user.Id)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
var deletedTransaction = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<Transaction>()
.SingleOrDefaultAsync(t => t.Id == user.Transactions.First().Id, ct);
}, TestContext.Current.CancellationToken);
deletedTransaction.Should().BeNull();
}
}
@@ -1,101 +0,0 @@
using FiscalOS.API.Transactions;
namespace FiscalOS.API.Tests.Integration.Transactions;
public class GetTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri GetUri = new("/transactions", UriKind.Relative);
[Fact]
public async Task Get_WhenNotLoggedIn_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Get_WhenCalledWithInvalidPageNumber_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithQueryParameter("pageNumber", "-1")
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PageNumber"] = ["PageNumber must be greater than 0."],
});
}
[Theory]
[InlineData(0)]
[InlineData(1001)]
public async Task Get_WhenCalledWithInvalidPageSize_ItShouldReturn400WithProblemDetails(int pageSize)
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithQueryParameter("pageSize", pageSize.ToString(CultureInfo.InvariantCulture))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PageSize"] = ["PageSize must be between 1 and 1000."],
});
}
[Fact]
public async Task Get_WhenCalled_ItShouldReturn200WithPagedResponseOfTransactions()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var user = UserBuilder.Create()
.WithInstitution(static ib =>
{
ib.WithMetadata();
ib.WithAccount(static ab =>
{
ab.WithMetadata();
ab.WithTransaction(static tb =>
{
tb.WithMetadata();
});
});
})
.Build();
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithUserId(user.Id)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
(await response.Should()
.BeJsonContentOfType<PagedResponse<TransactionDto>>(HttpStatusCode.OK))
.Which
.Items
.Should()
.BeEquivalentTo(
user.Transactions.Select(TransactionDto.From)
);
}
}
@@ -1,21 +0,0 @@
namespace FiscalOS.API.Tests.Unit;
public class PagedResponseTests
{
[Fact]
public void From_WhenCalled_ItShouldReturnPageWithCorrectValues()
{
var pageNumber = 1;
var pageSize = 10;
var totalItems = 25;
string[] items = ["test"];
var results = PagedResponse<string>.From(pageNumber, pageSize, totalItems, items);
results.PageNumber.Should().Be(pageNumber);
results.PageSize.Should().Be(pageSize);
results.TotalItems.Should().Be(totalItems);
results.TotalPages.Should().Be(3);
results.Items.Should().BeEquivalentTo(items);
}
}
-3
View File
@@ -1,4 +1,3 @@
global using System.Globalization;
global using System.IdentityModel.Tokens.Jwt; global using System.IdentityModel.Tokens.Jwt;
global using System.Net; global using System.Net;
global using System.Net.Http.Headers; global using System.Net.Http.Headers;
@@ -11,7 +10,6 @@ global using System.Text.Json;
global using AwesomeAssertions.Execution; global using AwesomeAssertions.Execution;
global using AwesomeAssertions.Primitives; global using AwesomeAssertions.Primitives;
global using FiscalOS.API.Common;
global using FiscalOS.API.Tests.Assertions; global using FiscalOS.API.Tests.Assertions;
global using FiscalOS.API.Tests.Infra; global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
@@ -20,7 +18,6 @@ global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using FiscalOS.Tests.Common.Data;
global using Going.Plaid; global using Going.Plaid;
global using Going.Plaid.Entity; global using Going.Plaid.Entity;
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class AccountBuilder internal sealed class AccountBuilder
{ {
private string _name = "accountName"; private string _name = "accountName";
private AccountMetadata? _metadata; private AccountMetadata? _metadata;
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class AccountMetadataBuilder internal sealed class AccountMetadataBuilder
{ {
private string _plaidId = "plaidId"; private string _plaidId = "plaidId";
private string _plaidName = "plaidName"; private string _plaidName = "plaidName";
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class InstitutionBuilder internal sealed class InstitutionBuilder
{ {
private string _name = "institutionName"; private string _name = "institutionName";
private InstitutionMetadata? _metadata; private InstitutionMetadata? _metadata;
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class InstitutionMetadataBuilder internal sealed class InstitutionMetadataBuilder
{ {
private string _plaidId = "plaidId"; private string _plaidId = "plaidId";
private string _plaidName = "plaidName"; private string _plaidName = "plaidName";
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class TransactionBuilder internal sealed class TransactionBuilder
{ {
private string _merchantName = "merchantName"; private string _merchantName = "merchantName";
private string _description = "description"; private string _description = "description";
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class TransactionMetadataBuilder internal sealed class TransactionMetadataBuilder
{ {
private string _plaidId = "plaidId"; private string _plaidId = "plaidId";
@@ -1,6 +1,6 @@
namespace FiscalOS.Tests.Common.Data; namespace FiscalOS.Infra.Tests.Data;
public sealed class UserBuilder internal sealed class UserBuilder
{ {
private string _username = "username"; private string _username = "username";
private string _password = "hashedPassword"; private string _password = "hashedPassword";
@@ -18,7 +18,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" /> <ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,3 +1,5 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Integration; namespace FiscalOS.Infra.Tests.Integration;
public abstract class IntegrationTest : IAsyncLifetime public abstract class IntegrationTest : IAsyncLifetime
@@ -30,9 +30,6 @@ public class PlaidAddedTransactionHandlerTests : IntegrationTest
result.Should().Be(1); result.Should().Be(1);
account.Transactions.Should().HaveCount(2); account.Transactions.Should().HaveCount(2);
account.Transactions
.Single(t => t.Metadata is PlaidTransactionMetadata m && m.PlaidId == transaction.TransactionId)
.Amount.Should().Be(-transaction.Amount);
} }
[Fact] [Fact]
@@ -31,6 +31,5 @@ public class PlaidModifiedTransactionHandlerTests : IntegrationTest
result.Should().Be(1); result.Should().Be(1);
existingTransaction.MerchantName.Should().Be(transaction.MerchantName); existingTransaction.MerchantName.Should().Be(transaction.MerchantName);
existingTransaction.Amount.Should().Be(-transaction.Amount);
} }
} }
@@ -5,7 +5,6 @@ public class PlaidTransactionProcessorTests
private readonly Mock<IPlaidAddedTransactionHandler> _mockAddedHandler = new(); private readonly Mock<IPlaidAddedTransactionHandler> _mockAddedHandler = new();
private readonly Mock<IPlaidModifiedTransactionHandler> _mockModifiedHandler = new(); private readonly Mock<IPlaidModifiedTransactionHandler> _mockModifiedHandler = new();
private readonly Mock<IPlaidRemovedTransactionHandler> _mockRemovedHandler = new(); private readonly Mock<IPlaidRemovedTransactionHandler> _mockRemovedHandler = new();
private readonly Mock<ILogger<PlaidTransactionProcessor>> _mockLogger = new();
private readonly PlaidTransactionProcessor _sut; private readonly PlaidTransactionProcessor _sut;
public PlaidTransactionProcessorTests() public PlaidTransactionProcessorTests()
@@ -13,8 +12,7 @@ public class PlaidTransactionProcessorTests
_sut = PlaidTransactionProcessor.From( _sut = PlaidTransactionProcessor.From(
_mockAddedHandler.Object, _mockAddedHandler.Object,
_mockModifiedHandler.Object, _mockModifiedHandler.Object,
_mockRemovedHandler.Object, _mockRemovedHandler.Object
_mockLogger.Object
); );
} }
@@ -23,7 +21,7 @@ public class PlaidTransactionProcessorTests
[InlineData(1, 1, 1, true)] [InlineData(1, 1, 1, true)]
[InlineData(0, 1, 1, false)] [InlineData(0, 1, 1, false)]
[InlineData(1, 0, 1, false)] [InlineData(1, 0, 1, false)]
[InlineData(1, 1, 0, true)] [InlineData(1, 1, 0, false)]
public async Task ProcessAsync_WhenCalledAndTransactionsAreHandled_ItShouldReturnCorrectResult( public async Task ProcessAsync_WhenCalledAndTransactionsAreHandled_ItShouldReturnCorrectResult(
int numAdded, int numAdded,
int numModified, int numModified,
@@ -1,3 +1,5 @@
using FiscalOS.Infra.Tests.Data;
namespace FiscalOS.Infra.Tests.Unit; namespace FiscalOS.Infra.Tests.Unit;
public class PlaidTransactionSyncerTests public class PlaidTransactionSyncerTests
-1
View File
@@ -19,7 +19,6 @@ global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Security; global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Tests.Assertions; global using FiscalOS.Infra.Tests.Assertions;
global using FiscalOS.Infra.Tests.Mocks; global using FiscalOS.Infra.Tests.Mocks;
global using FiscalOS.Tests.Common.Data;
global using FiscalOS.Infra.Transactions.Plaid; global using FiscalOS.Infra.Transactions.Plaid;
global using Going.Plaid; global using Going.Plaid;

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