Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b779c1841 | ||
|
|
e47e08b9f0 | ||
|
|
f0c63c7ded | ||
|
|
66f4cd2959 | ||
|
|
6a21e92aa6 | ||
|
|
88e8cfbf40 | ||
|
|
559908a150 | ||
|
|
e3acb2166d | ||
|
|
e6f70f4ab4 | ||
|
|
6c8ee3e203 | ||
|
|
cb7ed41296 | ||
|
|
a168d318b2 | ||
|
|
839d17de0d | ||
|
|
9e1d04da80 | ||
|
|
a721c60ad9 | ||
|
|
64c64e8d95 | ||
|
|
e30f19e939 | ||
|
|
d33b1d28dd | ||
|
|
36e2f70da2 | ||
|
|
9572097d38 | ||
|
|
ff4d10edcc | ||
|
|
379f975e7f | ||
|
|
6bf7499981 | ||
|
|
cd69cea2ac | ||
|
|
e3a75ccbc6 | ||
|
|
5eda5fbb76 | ||
|
|
4d1c01776f | ||
|
|
3408f6ea4f | ||
|
|
e2d2058960 | ||
|
|
02147cffdb | ||
|
|
e13c83013b | ||
|
|
c713a5448d | ||
|
|
091fa6f01c | ||
|
|
c8505081a5 |
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
|
||||
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj",
|
||||
"defaultWatchEnabled": "true"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.git/
|
||||
.vscode/
|
||||
.aspire/
|
||||
Coverage/
|
||||
tests/
|
||||
docker/data/
|
||||
docker/keys/
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/.env
|
||||
+2
-1
@@ -84,6 +84,7 @@ dotnet_diagnostic.IDE0058.severity = none
|
||||
dotnet_diagnostic.IDE0100.severity = none
|
||||
dotnet_diagnostic.CA1515.severity = none
|
||||
dotnet_diagnostic.CA1848.severity = none
|
||||
dotnet_diagnostic.CA1873.severity = none
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
@@ -94,7 +95,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_expression_bodied_accessors = true:silent
|
||||
csharp_style_expression_bodied_constructors = false:silent
|
||||
csharp_style_expression_bodied_indexers = true:silent
|
||||
csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_lambdas = false:silent
|
||||
csharp_style_expression_bodied_local_functions = false:silent
|
||||
csharp_style_expression_bodied_methods = false:silent
|
||||
csharp_style_expression_bodied_operators = false:silent
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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
|
||||
@@ -10,6 +10,10 @@ coverage/
|
||||
appsettings*.json
|
||||
!appsettings.Example.json
|
||||
|
||||
# docker bind-mount data
|
||||
docker/data/
|
||||
docker/keys/
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"cSpell.words": ["Encryptor"],
|
||||
"cSpell.words": ["DOCKERHUB", "Dtos", "Encryptor", "fiscalos", "Validatable"],
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||
"search.exclude": {
|
||||
"**/Migrations": true
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Tests.Common/FiscalOS.Tests.Common.csproj" />
|
||||
</Folder>
|
||||
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
|
||||
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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"]
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "latestPatch"
|
||||
},
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using FiscalOS.API.Accounts.Add;
|
||||
|
||||
namespace FiscalOS.API.Accounts;
|
||||
|
||||
internal static class AccountsExtensions
|
||||
|
||||
@@ -24,9 +24,9 @@ internal static class Endpoint
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
|
||||
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.ProviderInstitutionId))
|
||||
.ThenInclude(i => i.Metadata)
|
||||
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
|
||||
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.ProviderAccountId))
|
||||
.ThenInclude(a => a.Metadata)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
@@ -40,7 +40,7 @@ internal static class Endpoint
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ internal static class Endpoint
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ internal static class Endpoint
|
||||
}
|
||||
|
||||
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
|
||||
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
|
||||
var account = Account.From(request.PlaidAccountName, accountMetadata);
|
||||
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName);
|
||||
var account = Account.From(request.ProviderAccountName, accountMetadata);
|
||||
user.AddAccount(account);
|
||||
user.Institutions.First().AddAccount(account);
|
||||
|
||||
|
||||
@@ -2,27 +2,27 @@ namespace FiscalOS.API.Accounts.Add;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
public string PlaidInstitutionId { get; init; } = string.Empty;
|
||||
public string PlaidAccountId { get; init; } = string.Empty;
|
||||
public string PlaidAccountName { get; init; } = string.Empty;
|
||||
public string ProviderInstitutionId { get; init; } = string.Empty;
|
||||
public string ProviderAccountId { get; init; } = string.Empty;
|
||||
public string ProviderAccountName { get; init; } = string.Empty;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
|
||||
if (string.IsNullOrWhiteSpace(ProviderInstitutionId))
|
||||
{
|
||||
var fieldName = nameof(PlaidInstitutionId);
|
||||
var fieldName = nameof(ProviderInstitutionId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidAccountId))
|
||||
if (string.IsNullOrWhiteSpace(ProviderAccountId))
|
||||
{
|
||||
var fieldName = nameof(PlaidAccountId);
|
||||
var fieldName = nameof(ProviderAccountId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidAccountName))
|
||||
if (string.IsNullOrWhiteSpace(ProviderAccountName))
|
||||
{
|
||||
var fieldName = nameof(PlaidAccountName);
|
||||
var fieldName = nameof(ProviderAccountName);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using FiscalOS.API.Auth.Login;
|
||||
using FiscalOS.API.Auth.Logout;
|
||||
using FiscalOS.API.Auth.Refresh;
|
||||
|
||||
namespace FiscalOS.API.Auth;
|
||||
|
||||
internal static class AuthExtensions
|
||||
@@ -9,6 +13,7 @@ internal static class AuthExtensions
|
||||
var authGroup = app.MapGroup(RouteGroupPrefix);
|
||||
|
||||
authGroup.MapLoginEndpoint();
|
||||
authGroup.MapLogoutEndpoint();
|
||||
|
||||
authGroup.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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,6 +25,21 @@ 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)
|
||||
{
|
||||
return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty;
|
||||
|
||||
@@ -19,6 +19,7 @@ internal static class Endpoint
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions)
|
||||
.ThenInclude(i => i.Accounts)
|
||||
.ThenInclude(a => a.Metadata)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user is null)
|
||||
|
||||
@@ -24,6 +24,7 @@ internal sealed record Response
|
||||
internal sealed record AccountDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
@@ -36,6 +37,9 @@ internal sealed record AccountDto
|
||||
return new()
|
||||
{
|
||||
Id = account.Id.ToString(),
|
||||
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
|
||||
? plaidMetadata.PlaidId
|
||||
: string.Empty,
|
||||
Name = account.Name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
using FiscalOS.API.Institutions.Connect;
|
||||
using FiscalOS.API.Institutions.Get;
|
||||
using FiscalOS.API.Institutions.GetAvailable;
|
||||
using FiscalOS.API.Institutions.Link;
|
||||
|
||||
namespace FiscalOS.API.Institutions;
|
||||
|
||||
internal static class InstitutionsExtensions
|
||||
|
||||
@@ -3,6 +3,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.Services.AddValidation();
|
||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
@@ -39,9 +40,9 @@ app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStatusCodePages();
|
||||
app.UseExceptionHandler();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.MapAuthEndpoints();
|
||||
app.MapAccountsEndpoints();
|
||||
app.MapInstitutionsEndpoints();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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,3 +1,8 @@
|
||||
using FiscalOS.API.Transactions.DeleteById;
|
||||
using FiscalOS.API.Transactions.FireWebhook;
|
||||
using FiscalOS.API.Transactions.Get;
|
||||
using FiscalOS.API.Transactions.Webhook;
|
||||
|
||||
namespace FiscalOS.API.Transactions;
|
||||
|
||||
internal static class TransactionsExtensions
|
||||
@@ -9,13 +14,15 @@ internal static class TransactionsExtensions
|
||||
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
|
||||
.RequireAuthorization();
|
||||
|
||||
transactionsGroup.MapGetEndpoint();
|
||||
transactionsGroup.MapDeleteEndpoint();
|
||||
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
|
||||
|
||||
if (app.Environment.IsProduction() is false)
|
||||
{
|
||||
transactionsGroup.MapFireWebhookEndpoint();
|
||||
}
|
||||
|
||||
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
|
||||
|
||||
return transactionsGroup;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,11 @@ global using System.Security.Claims;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
global using FiscalOS.API.Accounts;
|
||||
global using FiscalOS.API.Accounts.Add;
|
||||
global using FiscalOS.API.Auth;
|
||||
global using FiscalOS.API.Auth.Login;
|
||||
global using FiscalOS.API.Auth.Refresh;
|
||||
global using FiscalOS.API.Common;
|
||||
global using FiscalOS.API.Http;
|
||||
global using FiscalOS.API.Institutions;
|
||||
global using FiscalOS.API.Institutions.Connect;
|
||||
global using FiscalOS.API.Institutions.Get;
|
||||
global using FiscalOS.API.Institutions.GetAvailable;
|
||||
global using FiscalOS.API.Institutions.Link;
|
||||
global using FiscalOS.API.Transactions;
|
||||
global using FiscalOS.API.Transactions.FireWebhook;
|
||||
global using FiscalOS.API.Transactions.Webhook;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Queuing;
|
||||
|
||||
@@ -23,5 +23,10 @@
|
||||
"ClientId": "ClientId",
|
||||
"Secret": "Secret",
|
||||
"Webhook": "Webhook"
|
||||
},
|
||||
"SeqOptions": {
|
||||
"ServerUrl": "ServerUrl",
|
||||
"ApiKeyHeader": "ApiKeyHeader",
|
||||
"ApiKey": "ApiKey"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>fiscaloscli</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
await Host.CreateDefaultBuilder(args)
|
||||
.ConfigureAppConfiguration(
|
||||
static c => c.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json")
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
)
|
||||
.ConfigureLogging(static c => c.ClearProviders())
|
||||
|
||||
@@ -31,7 +31,7 @@ internal sealed class PlaidAccountService : IPlaidAccountService
|
||||
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
|
||||
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
|
||||
var ltr = await _client.LinkTokenCreateAsync(new()
|
||||
var linkTokenResponse = await _client.LinkTokenCreateAsync(new()
|
||||
{
|
||||
ClientName = $"{assemblyName}_{environmentName}",
|
||||
Products = [Products.Transactions],
|
||||
@@ -44,56 +44,76 @@ internal sealed class PlaidAccountService : IPlaidAccountService
|
||||
Webhook = _options.Value.Webhook,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ltr.IsSuccessStatusCode is false)
|
||||
if (linkTokenResponse.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to create link token");
|
||||
throw new PlaidException(
|
||||
"Unable to create link token",
|
||||
linkTokenResponse.Error,
|
||||
linkTokenResponse.RequestId,
|
||||
(int?)linkTokenResponse.StatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return ltr.LinkToken;
|
||||
return linkTokenResponse.LinkToken;
|
||||
}
|
||||
|
||||
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
|
||||
{
|
||||
var ptr = await _client.ItemPublicTokenExchangeAsync(new()
|
||||
var publicTokenResponse = await _client.ItemPublicTokenExchangeAsync(new()
|
||||
{
|
||||
PublicToken = publicToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ptr.IsSuccessStatusCode is false)
|
||||
if (publicTokenResponse.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to exchange public token for access token");
|
||||
throw new PlaidException(
|
||||
"Unable to exchange public token for access token",
|
||||
publicTokenResponse.Error,
|
||||
publicTokenResponse.RequestId,
|
||||
(int?)publicTokenResponse.StatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return (ptr.ItemId, ptr.AccessToken);
|
||||
return (publicTokenResponse.ItemId, publicTokenResponse.AccessToken);
|
||||
}
|
||||
|
||||
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
|
||||
{
|
||||
var ar = await _client.AccountsGetAsync(new()
|
||||
var accountResponse = await _client.AccountsGetAsync(new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ar.IsSuccessStatusCode is false)
|
||||
if (accountResponse.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to retrieve accounts");
|
||||
throw new PlaidException(
|
||||
"Unable to retrieve accounts",
|
||||
accountResponse.Error,
|
||||
accountResponse.RequestId,
|
||||
(int?)accountResponse.StatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return [.. ar.Accounts];
|
||||
return [.. accountResponse.Accounts];
|
||||
}
|
||||
|
||||
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
|
||||
{
|
||||
var ar = await _client.ItemGetAsync(new()
|
||||
var itemResponse = await _client.ItemGetAsync(new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ar.IsSuccessStatusCode is false)
|
||||
if (itemResponse.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to retrieve item");
|
||||
throw new PlaidException(
|
||||
"Unable to retrieve item",
|
||||
itemResponse.Error,
|
||||
itemResponse.RequestId,
|
||||
(int?)itemResponse.StatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return ar.Item;
|
||||
return itemResponse.Item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ namespace FiscalOS.Infra.Common;
|
||||
|
||||
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()
|
||||
{
|
||||
}
|
||||
@@ -13,4 +20,34 @@ public class PlaidException : Exception
|
||||
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,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
|
||||
namespace FiscalOS.Infra.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
@@ -31,6 +33,14 @@ public static class ServiceCollectionExtensions
|
||||
var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
|
||||
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
|
||||
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);
|
||||
});
|
||||
services.AddSingleton<IPlaidAccountService>(PlaidAccountService.From);
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
|
||||
{
|
||||
var existingTransactionIds = added.Select(t => t.TransactionId);
|
||||
var existingTransactions = await _appDbContext.Transactions
|
||||
.Include(t => t.Metadata)
|
||||
.Where(t => t.Metadata is PlaidTransactionMetadata && existingTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
@@ -89,7 +90,7 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl
|
||||
account.Id,
|
||||
addedTransaction.Merchant,
|
||||
addedTransaction.Description,
|
||||
addedTransaction.Amount,
|
||||
addedTransaction.CanonicalAmount,
|
||||
addedTransaction.PostedDate,
|
||||
transactionMetadata
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ public static class PlaidExtensions
|
||||
{
|
||||
public string Merchant => transaction.MerchantName ?? "Unknown merchant";
|
||||
public string Description => transaction.OriginalDescription ?? "";
|
||||
public decimal? CanonicalAmount => transaction.Amount is decimal amount ? -amount : null;
|
||||
public DateTimeOffset PostedDate => transaction.Datetime ?? (
|
||||
transaction.Date.HasValue
|
||||
? new DateTimeOffset(
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
|
||||
{
|
||||
var modifiedTransactionIds = modified.Select(t => t.TransactionId);
|
||||
var existingModifiedTransactions = await _appDbContext.Transactions
|
||||
.Include(t => t.Metadata)
|
||||
.Where(t => t.Metadata is PlaidTransactionMetadata && modifiedTransactionIds.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
@@ -84,7 +85,7 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio
|
||||
existing.AccountId,
|
||||
plaidModifiedTransaction.Merchant,
|
||||
plaidModifiedTransaction.Description,
|
||||
plaidModifiedTransaction.Amount,
|
||||
plaidModifiedTransaction.CanonicalAmount,
|
||||
plaidModifiedTransaction.PostedDate,
|
||||
plaidMetadata
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ internal sealed class PlaidRemovedTransactionHandler : IPlaidRemovedTransactionH
|
||||
try
|
||||
{
|
||||
removedCount = await _appDbContext.Transactions
|
||||
.Include(t => t.Metadata)
|
||||
.Where(t => t.Metadata is PlaidTransactionMetadata && removedIdsList.Contains(((PlaidTransactionMetadata)t.Metadata).PlaidId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -16,25 +16,29 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
|
||||
private readonly IPlaidAddedTransactionHandler _addedHandler;
|
||||
private readonly IPlaidModifiedTransactionHandler _modifiedHandler;
|
||||
private readonly IPlaidRemovedTransactionHandler _removedHandler;
|
||||
private readonly ILogger<PlaidTransactionProcessor> _logger;
|
||||
|
||||
private PlaidTransactionProcessor(
|
||||
IPlaidAddedTransactionHandler addedHandler,
|
||||
IPlaidModifiedTransactionHandler modifiedHandler,
|
||||
IPlaidRemovedTransactionHandler removedHandler
|
||||
IPlaidRemovedTransactionHandler removedHandler,
|
||||
ILogger<PlaidTransactionProcessor> logger
|
||||
)
|
||||
{
|
||||
_addedHandler = addedHandler;
|
||||
_modifiedHandler = modifiedHandler;
|
||||
_removedHandler = removedHandler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
internal static PlaidTransactionProcessor From(
|
||||
IPlaidAddedTransactionHandler addedHandler,
|
||||
IPlaidModifiedTransactionHandler modifiedHandler,
|
||||
IPlaidRemovedTransactionHandler removedHandler
|
||||
IPlaidRemovedTransactionHandler removedHandler,
|
||||
ILogger<PlaidTransactionProcessor> logger
|
||||
)
|
||||
{
|
||||
return new(addedHandler, modifiedHandler, removedHandler);
|
||||
return new(addedHandler, modifiedHandler, removedHandler, logger);
|
||||
}
|
||||
|
||||
internal static PlaidTransactionProcessor From(IServiceProvider provider)
|
||||
@@ -42,7 +46,8 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
|
||||
return new(
|
||||
provider.GetRequiredService<IPlaidAddedTransactionHandler>(),
|
||||
provider.GetRequiredService<IPlaidModifiedTransactionHandler>(),
|
||||
provider.GetRequiredService<IPlaidRemovedTransactionHandler>()
|
||||
provider.GetRequiredService<IPlaidRemovedTransactionHandler>(),
|
||||
provider.GetRequiredService<ILogger<PlaidTransactionProcessor>>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,8 +74,18 @@ internal sealed class PlaidTransactionProcessor : IPlaidTransactionProcessor
|
||||
cancellationToken
|
||||
).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 &&
|
||||
numModified == syncResponse.Modified.Count &&
|
||||
numRemoved == syncResponse.Removed.Count;
|
||||
numModified == syncResponse.Modified.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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,3 +1,8 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Logs;
|
||||
|
||||
namespace FiscalOS.ServiceDefaults;
|
||||
|
||||
public static class ServiceDefaultsExtensions
|
||||
@@ -28,6 +33,7 @@ public static class ServiceDefaultsExtensions
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
logging.ParseStateValues = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
@@ -76,11 +82,38 @@ public static class ServiceDefaultsExtensions
|
||||
|
||||
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"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
var otel = builder.Services.AddOpenTelemetry();
|
||||
|
||||
if (seqOptions.IsEnabled)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
otel
|
||||
.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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('visits the app root url', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('h1')).toHaveText('You did it!')
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
import { globalIgnores } from 'eslint/config';
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import pluginVitest from '@vitest/eslint-plugin';
|
||||
import pluginOxlint from 'eslint-plugin-oxlint';
|
||||
import skipFormatting from 'eslint-config-prettier/flat';
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
@@ -15,7 +14,6 @@ export default defineConfigWithVueTs(
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
{
|
||||
...pluginPlaywright.configs['flat/recommended'],
|
||||
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
{
|
||||
@@ -23,5 +21,5 @@ export default defineConfigWithVueTs(
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
skipFormatting,
|
||||
)
|
||||
skipFormatting
|
||||
);
|
||||
|
||||
Generated
+123
-218
@@ -16,7 +16,6 @@
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
@@ -29,7 +28,6 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.46.0",
|
||||
"eslint-plugin-playwright": "^2.5.1",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
@@ -1206,9 +1204,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -1280,9 +1278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -1851,22 +1849,6 @@
|
||||
"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": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1882,9 +1864,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1896,9 +1878,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1910,9 +1892,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1924,9 +1906,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1938,9 +1920,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1952,9 +1934,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1966,9 +1948,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1980,9 +1962,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1994,9 +1976,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2008,9 +1990,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2022,9 +2004,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2036,9 +2018,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2050,9 +2032,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2064,9 +2046,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2078,9 +2060,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2092,9 +2074,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2106,9 +2088,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2120,9 +2102,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2134,9 +2116,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2148,9 +2130,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2162,9 +2144,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2176,9 +2158,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2190,9 +2172,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2204,9 +2186,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2218,9 +2200,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3257,9 +3239,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3878,15 +3860,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
|
||||
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "9.0.1",
|
||||
"minimatch": "^9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3896,22 +3878,6 @@
|
||||
"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": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -4168,35 +4134,6 @@
|
||||
"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": {
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz",
|
||||
@@ -4297,9 +4234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -5391,13 +5328,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -5929,38 +5866,6 @@
|
||||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -6155,9 +6060,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6171,31 +6076,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
@@ -25,7 +24,6 @@
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
@@ -38,7 +36,6 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.46.0",
|
||||
"eslint-plugin-playwright": "^2.5.1",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import process from 'node:process'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
headless: !!process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: process.env.CI ? 'npm run preview' : 'npm run dev',
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -68,7 +68,8 @@ describe('LoginForm', () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
onValidSubmit: () =>
|
||||
new Promise(resolve => {
|
||||
resolveSubmit = resolve;
|
||||
}),
|
||||
},
|
||||
@@ -96,7 +97,8 @@ describe('LoginForm', () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
onValidSubmit: () =>
|
||||
new Promise(resolve => {
|
||||
wasCalled = true;
|
||||
resolve();
|
||||
}),
|
||||
|
||||
@@ -24,6 +24,24 @@ input {
|
||||
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 {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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>
|
||||
@@ -0,0 +1,21 @@
|
||||
<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>
|
||||
@@ -0,0 +1,236 @@
|
||||
<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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type LoginFormState = {
|
||||
export type LoginFormState = {
|
||||
isLoggingIn: boolean;
|
||||
username: {
|
||||
value: string;
|
||||
@@ -11,13 +11,13 @@ export type LoginFormState = {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
const props = defineProps<{
|
||||
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
|
||||
}>();
|
||||
}>();
|
||||
|
||||
const formState = ref<LoginFormState>({
|
||||
const formState = ref<LoginFormState>({
|
||||
isLoggingIn: false,
|
||||
username: {
|
||||
value: '',
|
||||
@@ -27,25 +27,25 @@ const formState = ref<LoginFormState>({
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function handleUsernameInput(e: Event) {
|
||||
function handleUsernameInput(e: Event) {
|
||||
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.username.error.trim()) {
|
||||
formState.value.username.error = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordInput(e: Event) {
|
||||
function handlePasswordInput(e: Event) {
|
||||
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.password.error.trim()) {
|
||||
formState.value.password.error = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormState() {
|
||||
function validateFormState() {
|
||||
let isValid = true;
|
||||
|
||||
if (!formState.value.username.value.trim()) {
|
||||
@@ -59,9 +59,9 @@ function validateFormState() {
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit() {
|
||||
formState.value.isLoggingIn = true;
|
||||
|
||||
try {
|
||||
@@ -72,28 +72,41 @@ async function handleSubmit() {
|
||||
if (props.onValidSubmit !== undefined) {
|
||||
await props.onValidSubmit(formState.value);
|
||||
}
|
||||
|
||||
} finally {
|
||||
formState.value.isLoggingIn = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
:value="formState.username.value"
|
||||
@input="handleUsernameInput"
|
||||
/>
|
||||
<div class="error">{{ formState.username.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" :value="formState.password.value"
|
||||
@input="handlePasswordInput" />
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
:value="formState.password.value"
|
||||
@input="handlePasswordInput"
|
||||
/>
|
||||
<div class="error">{{ formState.password.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" :disabled="formState.isLoggingIn">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="formState.isLoggingIn"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,47 +114,47 @@ async function handleSubmit() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
form,
|
||||
form>div {
|
||||
form,
|
||||
form > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
form {
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
form>div {
|
||||
form > div {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
form>div label {
|
||||
form > div label {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
form>div input {
|
||||
form > div input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid black;
|
||||
background-color: var(--bg-element);
|
||||
}
|
||||
}
|
||||
|
||||
form>div button {
|
||||
form > div button {
|
||||
background-color: var(--brand-primary);
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
form>div button:disabled {
|
||||
form > div button:disabled {
|
||||
opacity: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
form>div .error {
|
||||
form > div .error {
|
||||
color: var(--state-error);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import RightArrowBracketIcon from './icons/RightArrowBracketIcon.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
const asideClasses = computed(() => ({
|
||||
collapsed: userStore.user?.sidebarCollapsed,
|
||||
@@ -15,12 +18,14 @@ import { useRouter } from 'vue-router';
|
||||
userStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// TODO: Also need to log user
|
||||
// out on the server...which is
|
||||
// basically just clearing
|
||||
// refresh token cookie and revoking
|
||||
// it in the database
|
||||
async function handleLogout() {
|
||||
const logoutResult = await authService.logout();
|
||||
|
||||
if (logoutResult.err) {
|
||||
alert(logoutResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
userStore.logUserOut();
|
||||
router.push({ path: '/public/login' });
|
||||
}
|
||||
@@ -35,13 +40,30 @@ import { useRouter } from 'vue-router';
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</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
|
||||
class="logout-button"
|
||||
type="button"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<RightArrowBracketIcon />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -68,12 +90,79 @@ import { useRouter } from 'vue-router';
|
||||
}
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: var(--bg-element);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.logout-button svg {
|
||||
--size: 1.5rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -90,6 +179,13 @@ import { useRouter } from 'vue-router';
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
.toggle-button svg {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
|
||||
aside.collapsed {
|
||||
width: calc(0.125rem + var(--button-size) / 2);
|
||||
}
|
||||
@@ -98,9 +194,9 @@ import { useRouter } from 'vue-router';
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toggle-button svg {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
aside.collapsed .sidebar-content {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,5 +22,16 @@
|
||||
main {
|
||||
flex: 1;
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<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,6 +1,10 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 640 640"
|
||||
>
|
||||
<path
|
||||
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
|
||||
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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,27 +1,7 @@
|
||||
import { AuthServiceFactoryKey } from "@/services/authService";
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
import { AuthServiceFactoryKey } from '@/services/authService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useAuthService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (authServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject auth service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
return authService;
|
||||
return useService(store, AuthServiceFactoryKey);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import { InstituionServiceFactoryKey } from "@/services/institutionService";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
import { InstitutionServiceFactoryKey } from '@/services/institutionService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useInstitutionService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (institutionServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject institution service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const institutionService = institutionServiceFactory.create(client);
|
||||
|
||||
return institutionService;
|
||||
return useService(store, InstitutionServiceFactoryKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TransactionServiceFactoryKey } from '@/services/transactionService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useTransactionService(store: UserStore) {
|
||||
return useService(store, TransactionServiceFactoryKey);
|
||||
}
|
||||
@@ -1,21 +1,31 @@
|
||||
import './assets/css/main.css';
|
||||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { ClientFactory, ClientFactoryKey } from "./services/client";
|
||||
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
|
||||
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { ClientFactory, ClientFactoryKey } from './services/client';
|
||||
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
|
||||
import {
|
||||
InstitutionServiceFactoryKey,
|
||||
InstitutionServiceFactory,
|
||||
} from './services/institutionService';
|
||||
import {
|
||||
TransactionServiceFactory,
|
||||
TransactionServiceFactoryKey,
|
||||
} from './services/transactionService';
|
||||
import { AccountServiceFactory, AccountServiceFactoryKey } from './services/accountService';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.provide(ClientFactoryKey, new ClientFactory());
|
||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
app.provide(InstitutionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
|
||||
app.provide(AccountServiceFactoryKey, new AccountServiceFactory());
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount("#app");
|
||||
app.mount('#app');
|
||||
|
||||
@@ -23,6 +23,9 @@ const router = createRouter({
|
||||
{
|
||||
path: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: {
|
||||
title: 'Login',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -60,11 +63,30 @@ const router = createRouter({
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
redirect: '/accounts',
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,6 +19,7 @@ export class AuthServiceFactory implements IAuthServiceFactory {
|
||||
export interface IAuthService {
|
||||
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
|
||||
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
|
||||
logout: () => Promise<Result<boolean, Error[]>>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
@@ -26,12 +27,30 @@ export class AuthService implements IAuthService {
|
||||
private readonly endpoints = {
|
||||
login: '/api/auth/login',
|
||||
refreshToken: '/api/auth/refresh',
|
||||
logout: '/api/auth/logout',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
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) {
|
||||
const request = new ClientRequestWithBody(
|
||||
this.endpoints.login,
|
||||
|
||||
@@ -4,8 +4,9 @@ import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||
|
||||
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
|
||||
Symbol('AuthServiceFactory');
|
||||
export const InstitutionServiceFactoryKey: InstitutionServiceFactoryKeyType = Symbol(
|
||||
'InstitutionServiceFactory'
|
||||
);
|
||||
|
||||
export interface IInstitutionServiceFactory {
|
||||
create: (client: IClient) => IInstitutionService;
|
||||
@@ -136,8 +137,9 @@ type LinkTokenResponse = {
|
||||
|
||||
type Account = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
name: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type Institution = {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
};
|
||||
@@ -88,7 +88,7 @@ export const useUserStore = defineStore('userStore', () => {
|
||||
const updatedUser = {
|
||||
...user.value,
|
||||
sidebarCollapsed: !user.value?.sidebarCollapsed,
|
||||
}
|
||||
};
|
||||
user.value = updatedUser;
|
||||
saveUserToLocalSotrage(updatedUser);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<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>
|
||||
@@ -1,210 +0,0 @@
|
||||
<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,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authService = useAuthService(userStore);
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
async function handleValideSubmit(formState: LoginFormState) {
|
||||
async function handleValideSubmit(formState: LoginFormState) {
|
||||
const loginResult = await authService.login(formState.username.value, formState.password.value);
|
||||
|
||||
if (loginResult.err) {
|
||||
@@ -18,7 +18,7 @@ async function handleValideSubmit(formState: LoginFormState) {
|
||||
|
||||
userStore.logUserIn(loginResult.val.accessToken);
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<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>
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -22,6 +22,7 @@
|
||||
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
|
||||
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
||||
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace FiscalOS.API.Tests.Infra;
|
||||
|
||||
internal sealed class HttpRequestBuilder
|
||||
@@ -8,6 +10,7 @@ internal sealed class HttpRequestBuilder
|
||||
private string? _bearerToken;
|
||||
private readonly Dictionary<string, string> _cookies = [];
|
||||
private readonly Dictionary<string, string> _headers = [];
|
||||
private readonly Dictionary<string, string?> _queryParameters = [];
|
||||
|
||||
private HttpRequestBuilder()
|
||||
{
|
||||
@@ -68,6 +71,12 @@ internal sealed class HttpRequestBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithQueryParameter(string name, string value)
|
||||
{
|
||||
_queryParameters[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Post(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Post;
|
||||
@@ -103,7 +112,11 @@ internal sealed class HttpRequestBuilder
|
||||
throw new InvalidOperationException("URI must be set before building the request.");
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(_method, _uri);
|
||||
var uri = _queryParameters.Count > 0
|
||||
? QueryHelpers.AddQueryString(_uri.ToString(), _queryParameters)
|
||||
: _uri.ToString();
|
||||
|
||||
var request = new HttpRequestMessage(_method, uri);
|
||||
|
||||
if (_body is not null)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FiscalOS.API.Accounts.Add;
|
||||
using FiscalOS.Core.Queuing;
|
||||
using FiscalOS.Infra.Transactions.Plaid;
|
||||
|
||||
@@ -31,9 +32,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
|
||||
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
|
||||
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,9 +46,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidAccountId = "accountId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerAccountId = "accountId",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -55,7 +55,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,9 +67,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "institutionId",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -77,7 +76,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,9 +88,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountId = "accountId",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "institutionId",
|
||||
providerAccountId = "accountId",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -99,7 +97,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,10 +109,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "id",
|
||||
providerAccountId = "id",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -145,10 +142,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "id",
|
||||
providerAccountId = "id",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -156,7 +152,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,10 +195,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
providerAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
providerAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -252,20 +247,15 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
var newAccountId = "newAccountId";
|
||||
var newAccountName = "New Account";
|
||||
var expectedBalance = 100;
|
||||
var expectedCurrencyCode = "USD";
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = newAccountId,
|
||||
plaidAccountName = newAccountName,
|
||||
accountCurrentBalance = expectedBalance,
|
||||
accountAvailableBalance = expectedBalance,
|
||||
accountCurrencyCode = expectedCurrencyCode,
|
||||
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
providerAccountId = newAccountId,
|
||||
providerAccountName = newAccountName,
|
||||
})
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
global using System.Globalization;
|
||||
global using System.IdentityModel.Tokens.Jwt;
|
||||
global using System.Net;
|
||||
global using System.Net.Http.Headers;
|
||||
@@ -10,6 +11,7 @@ global using System.Text.Json;
|
||||
global using AwesomeAssertions.Execution;
|
||||
global using AwesomeAssertions.Primitives;
|
||||
|
||||
global using FiscalOS.API.Common;
|
||||
global using FiscalOS.API.Tests.Assertions;
|
||||
global using FiscalOS.API.Tests.Infra;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
@@ -18,6 +20,7 @@ global using FiscalOS.Core.Security;
|
||||
global using FiscalOS.Infra.Accounts.Plaid;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
global using FiscalOS.Infra.Data;
|
||||
global using FiscalOS.Tests.Common.Data;
|
||||
|
||||
global using Going.Plaid;
|
||||
global using Going.Plaid.Entity;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
||||
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Infra.Tests.Data;
|
||||
|
||||
namespace FiscalOS.Infra.Tests.Integration;
|
||||
|
||||
public abstract class IntegrationTest : IAsyncLifetime
|
||||
|
||||
@@ -30,6 +30,9 @@ public class PlaidAddedTransactionHandlerTests : IntegrationTest
|
||||
|
||||
result.Should().Be(1);
|
||||
account.Transactions.Should().HaveCount(2);
|
||||
account.Transactions
|
||||
.Single(t => t.Metadata is PlaidTransactionMetadata m && m.PlaidId == transaction.TransactionId)
|
||||
.Amount.Should().Be(-transaction.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -31,5 +31,6 @@ public class PlaidModifiedTransactionHandlerTests : IntegrationTest
|
||||
|
||||
result.Should().Be(1);
|
||||
existingTransaction.MerchantName.Should().Be(transaction.MerchantName);
|
||||
existingTransaction.Amount.Should().Be(-transaction.Amount);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ public class PlaidTransactionProcessorTests
|
||||
private readonly Mock<IPlaidAddedTransactionHandler> _mockAddedHandler = new();
|
||||
private readonly Mock<IPlaidModifiedTransactionHandler> _mockModifiedHandler = new();
|
||||
private readonly Mock<IPlaidRemovedTransactionHandler> _mockRemovedHandler = new();
|
||||
private readonly Mock<ILogger<PlaidTransactionProcessor>> _mockLogger = new();
|
||||
private readonly PlaidTransactionProcessor _sut;
|
||||
|
||||
public PlaidTransactionProcessorTests()
|
||||
@@ -12,7 +13,8 @@ public class PlaidTransactionProcessorTests
|
||||
_sut = PlaidTransactionProcessor.From(
|
||||
_mockAddedHandler.Object,
|
||||
_mockModifiedHandler.Object,
|
||||
_mockRemovedHandler.Object
|
||||
_mockRemovedHandler.Object,
|
||||
_mockLogger.Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +23,7 @@ public class PlaidTransactionProcessorTests
|
||||
[InlineData(1, 1, 1, true)]
|
||||
[InlineData(0, 1, 1, false)]
|
||||
[InlineData(1, 0, 1, false)]
|
||||
[InlineData(1, 1, 0, false)]
|
||||
[InlineData(1, 1, 0, true)]
|
||||
public async Task ProcessAsync_WhenCalledAndTransactionsAreHandled_ItShouldReturnCorrectResult(
|
||||
int numAdded,
|
||||
int numModified,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Infra.Tests.Data;
|
||||
|
||||
namespace FiscalOS.Infra.Tests.Unit;
|
||||
|
||||
public class PlaidTransactionSyncerTests
|
||||
|
||||
@@ -19,6 +19,7 @@ global using FiscalOS.Infra.Data;
|
||||
global using FiscalOS.Infra.Security;
|
||||
global using FiscalOS.Infra.Tests.Assertions;
|
||||
global using FiscalOS.Infra.Tests.Mocks;
|
||||
global using FiscalOS.Tests.Common.Data;
|
||||
global using FiscalOS.Infra.Transactions.Plaid;
|
||||
|
||||
global using Going.Plaid;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace FiscalOS.Infra.Tests.Data;
|
||||
namespace FiscalOS.Tests.Common.Data;
|
||||
|
||||
internal sealed class AccountBuilder
|
||||
public sealed class AccountBuilder
|
||||
{
|
||||
private string _name = "accountName";
|
||||
private AccountMetadata? _metadata;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace FiscalOS.Infra.Tests.Data;
|
||||
namespace FiscalOS.Tests.Common.Data;
|
||||
|
||||
internal sealed class AccountMetadataBuilder
|
||||
public sealed class AccountMetadataBuilder
|
||||
{
|
||||
private string _plaidId = "plaidId";
|
||||
private string _plaidName = "plaidName";
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace FiscalOS.Infra.Tests.Data;
|
||||
namespace FiscalOS.Tests.Common.Data;
|
||||
|
||||
internal sealed class InstitutionBuilder
|
||||
public sealed class InstitutionBuilder
|
||||
{
|
||||
private string _name = "institutionName";
|
||||
private InstitutionMetadata? _metadata;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace FiscalOS.Infra.Tests.Data;
|
||||
namespace FiscalOS.Tests.Common.Data;
|
||||
|
||||
internal sealed class InstitutionMetadataBuilder
|
||||
public sealed class InstitutionMetadataBuilder
|
||||
{
|
||||
private string _plaidId = "plaidId";
|
||||
private string _plaidName = "plaidName";
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace FiscalOS.Infra.Tests.Data;
|
||||
namespace FiscalOS.Tests.Common.Data;
|
||||
|
||||
internal sealed class TransactionBuilder
|
||||
public sealed class TransactionBuilder
|
||||
{
|
||||
private string _merchantName = "merchantName";
|
||||
private string _description = "description";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user