Compare commits

..
Author SHA1 Message Date
Stevan Freeborn eef410d71b chore: run dotnet format 2026-02-02 15:46:12 -06:00
Stevan Freeborn fb7750328b feat(infra): add indexes with uniqueness 2026-02-02 15:45:44 -06:00
Stevan Freeborn 35557a8aa7 feat(admincli): add command for creating a user 2026-02-02 15:41:47 -06:00
Stevan Freeborn 5a5efbefd7 tests(api): add integration test for happy path when logging in 2026-02-02 13:33:23 -06:00
Stevan Freeborn dcc5b2e0f0 feat(core): add password hashing and token generation interfaces 2026-02-02 13:19:36 -06:00
Stevan Freeborn 7d5fd4149c test: update infra tests for new authentication namespaces 2026-02-02 13:18:41 -06:00
Stevan Freeborn abc6a7655e test: add moq and code coverage support to test suite 2026-02-02 13:18:28 -06:00
Stevan Freeborn 178f03459b feat(api): integrate abstracted token generation and update login endpoint 2026-02-02 13:18:15 -06:00
Stevan Freeborn b3bfa64b96 feat(infra): move authentication implementations and add ITokenGenerator 2026-02-02 13:17:59 -06:00
Stevan Freeborn 098aa23c62 refactor(core): encapsulate user and refresh token creation with factory methods 2026-02-02 13:17:43 -06:00
Stevan Freeborn 815b6aa57c chore: add todo with psuedo code 2026-02-02 07:00:37 -06:00
Stevan Freeborn 3a1a7138c5 feat(infra): update refresh token model 2026-02-02 07:00:28 -06:00
Stevan Freeborn ee6abc4559 chore: add example jwt options in example settings file 2026-02-02 06:54:39 -06:00
Stevan Freeborn b535c0a121 feat: implement token generation and issuing on successful login 2026-02-02 06:32:14 -06:00
Stevan Freeborn 822da7368c tests: refactor test suite to match new project structure 2026-02-02 04:49:00 -06:00
Stevan Freeborn 7c6e43d62d feat(api): integrate infra and implement password verification on login 2026-02-02 04:48:42 -06:00
Stevan Freeborn 48913fc434 feat: add timestamp interceptor and infrastructure service registration 2026-02-02 04:48:10 -06:00
Stevan Freeborn c01d0b5daa refactor(infra): migrate data access and identity impelementations to infrastructure 2026-02-02 04:47:32 -06:00
Stevan Freeborn 7a7ab90155 refactor(core): move identity models from API to Core 2026-02-02 04:45:56 -06:00
Stevan Freeborn 81e5b9ed6a feat(core): add base entity and password hashing abstractions 2026-02-02 04:45:17 -06:00
Stevan Freeborn b5ea9a514f feat: initialize core, infra, and admin cli projects 2026-02-02 04:44:13 -06:00
Stevan Freeborn c8c45f6a83 feat: implement password hasher 2026-02-01 11:32:14 -06:00
Stevan Freeborn ce45244813 chore: move editorconfig to correct location 2026-02-01 11:31:58 -06:00
Stevan Freeborn e479ba4c8a tests: fix integration test setup 2026-02-01 11:31:46 -06:00
Stevan Freeborn bf7b1b3fc8 fix: remove fake auth check 2026-02-01 11:31:25 -06:00
Stevan Freeborn 872eb3e95d feat: wip on testing login flows 2026-02-01 08:07:23 -06:00
Stevan Freeborn 725392227e feat: setup db context with initial models 2026-02-01 06:13:08 -06:00
Stevan Freeborn 093ee2c357 feat: add simple check for username and password 2026-02-01 06:12:49 -06:00
Stevan Freeborn a1d22b0ec2 chore: add example appsettings file 2026-02-01 06:12:25 -06:00
Stevan Freeborn 2b91e3b791 refactor: use ivalidatableobject api 2026-02-01 06:12:09 -06:00
Stevan Freeborn 8e40b15cb2 chore: update editorconfig settings 2026-02-01 06:11:31 -06:00
Stevan Freeborn 8012ecaa73 feat: wip on login endpoint
- added basic REPR pattern
- added basic vert architecture
- implemented basica validation for single test case
2026-02-01 04:27:06 -06:00
270 changed files with 318 additions and 71342 deletions
-4
View File
@@ -1,4 +0,0 @@
{
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj",
"defaultWatchEnabled": "true"
}
-11
View File
@@ -1,11 +0,0 @@
.git/
.vscode/
.aspire/
Coverage/
tests/
docker/data/
docker/keys/
**/bin/
**/obj/
**/node_modules/
**/.env
+1 -2
View File
@@ -84,7 +84,6 @@ dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.IDE0100.severity = none
dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA1873.severity = none
# var preferences
csharp_style_var_elsewhere = true:suggestion
@@ -95,7 +94,7 @@ csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = false:silent
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
-100
View File
@@ -1,100 +0,0 @@
name: Deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
WEB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-web
API_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fiscalos-api
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.web
push: true
tags: |
${{ env.WEB_IMAGE }}:latest
${{ env.WEB_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.api
push: true
tags: |
${{ env.API_IMAGE }}:latest
${{ env.API_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Copy compose file to server
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: docker/docker-compose.prod.yml
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 1
- name: Deploy on server
uses: appleboy/ssh-action@v1
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_TAG: ${{ github.sha }}
JWT_AUDIENCE: ${{ secrets.JWT_AUDIENCE }}
JWT_ISSUER: ${{ secrets.JWT_ISSUER }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_EXPIRY_IN_MINUTES: ${{ secrets.JWT_EXPIRY_IN_MINUTES }}
FILE_KEY_RING_PRIMARY_KEY_ID: ${{ secrets.FILE_KEY_RING_PRIMARY_KEY_ID }}
PLAID_CLIENT_ID: ${{ secrets.PLAID_CLIENT_ID }}
PLAID_SECRET: ${{ secrets.PLAID_SECRET }}
PLAID_WEBHOOK: ${{ secrets.PLAID_WEBHOOK }}
SEQ_SERVER_URL: ${{ secrets.SEQ_SERVER_URL }}
SEQ_API_KEY_HEADER: ${{ secrets.SEQ_API_KEY_HEADER }}
SEQ_API_KEY: ${{ secrets.SEQ_API_KEY }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: DOCKERHUB_USERNAME,IMAGE_TAG,JWT_AUDIENCE,JWT_ISSUER,JWT_SECRET,JWT_EXPIRY_IN_MINUTES,FILE_KEY_RING_PRIMARY_KEY_ID,PLAID_CLIENT_ID,PLAID_SECRET,PLAID_WEBHOOK,SEQ_SERVER_URL,SEQ_API_KEY_HEADER,SEQ_API_KEY
script: |
cd ${{ secrets.DEPLOY_PATH }}
cat > .env << EOF
DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME}
IMAGE_TAG=${IMAGE_TAG}
JWT_AUDIENCE=${JWT_AUDIENCE}
JWT_ISSUER=${JWT_ISSUER}
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRY_IN_MINUTES=${JWT_EXPIRY_IN_MINUTES}
FILE_KEY_RING_PRIMARY_KEY_ID=${FILE_KEY_RING_PRIMARY_KEY_ID}
PLAID_CLIENT_ID=${PLAID_CLIENT_ID}
PLAID_SECRET=${PLAID_SECRET}
PLAID_WEBHOOK=${PLAID_WEBHOOK}
SEQ_SERVER_URL=${SEQ_SERVER_URL}
SEQ_API_KEY_HEADER=${SEQ_API_KEY_HEADER}
SEQ_API_KEY=${SEQ_API_KEY}
EOF
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
-7
View File
@@ -3,17 +3,10 @@
##
## Get latest from `dotnet new gitignore`
# coverage
coverage/
# appsettings
appsettings*.json
!appsettings.Example.json
# docker bind-mount data
docker/data/
docker/keys/
# dotenv files
.env
-11
View File
@@ -1,11 +0,0 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
-11
View File
@@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "aspire",
"request": "launch",
"name": "Aspire: Launch default apphost",
"program": "${workspaceFolder}"
}
]
}
-19
View File
@@ -1,19 +0,0 @@
{
"dotnet.defaultSolution": "FiscalOS.slnx",
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["DOCKERHUB", "Dtos", "Encryptor", "fiscalos", "Validatable"],
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
}
}
+116
View File
@@ -0,0 +1,116 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API.Tests", "tests\FiscalOS.API.Tests\FiscalOS.API.Tests.csproj", "{1869E249-07F6-41CD-B143-AF86F022E4A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.AdminCLI", "src\FiscalOS.AdminCLI\FiscalOS.AdminCLI.csproj", "{C46818A0-1A92-4E4F-8390-48CBA9971745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core", "src\FiscalOS.Core\FiscalOS.Core.csproj", "{612496CD-1F25-4804-8C52-49E62D01FFDC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\FiscalOS.Infra\FiscalOS.Infra.csproj", "{DAB42870-02A2-4ECB-B07F-6815C52D2435}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.Build.0 = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.Build.0 = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{85E0C8F3-AE69-4012-836C-759E9D297762} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C46818A0-1A92-4E4F-8390-48CBA9971745} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
-22
View File
@@ -1,22 +0,0 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj" />
<Project Path="src/FiscalOS.API/FiscalOS.API.csproj" />
<Project Path="src/FiscalOS.Core/FiscalOS.Core.csproj" />
<Project Path="src/FiscalOS.Infra/FiscalOS.Infra.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj" />
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.Tests.csproj" />
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
<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" />
</Solution>
-39
View File
@@ -1,39 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
COPY Directory.Build.props .
COPY .editorconfig .
COPY src/.editorconfig src/
COPY src/Directory.Build.props src/
COPY src/Directory.Packages.props src/
COPY src/FiscalOS.Core/FiscalOS.Core.csproj src/FiscalOS.Core/
COPY src/FiscalOS.Infra/FiscalOS.Infra.csproj src/FiscalOS.Infra/
COPY src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj src/FiscalOS.ServiceDefaults/
COPY src/FiscalOS.API/FiscalOS.API.csproj src/FiscalOS.API/
COPY src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj src/FiscalOS.AdminCLI/
RUN dotnet restore src/FiscalOS.API/FiscalOS.API.csproj
RUN dotnet restore src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj
COPY src/ src/
RUN dotnet publish src/FiscalOS.API/FiscalOS.API.csproj \
-c Release \
-o /publish/api \
--no-restore
RUN dotnet publish src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj \
-c Release \
-o /publish/admincli \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /publish/api .
COPY --from=build /publish/admincli /usr/local/lib/admincli
ENV PATH="/usr/local/lib/admincli:${PATH}"
EXPOSE 8080
ENTRYPOINT ["dotnet", "FiscalOS.API.dll"]
-13
View File
@@ -1,13 +0,0 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY src/FiscalOS.Web/package.json src/FiscalOS.Web/package-lock.json ./
RUN npm ci
COPY src/FiscalOS.Web/ .
RUN npm run build
FROM nginx:alpine AS final
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
-30
View File
@@ -1,30 +0,0 @@
services:
web:
image: ${DOCKERHUB_USERNAME}/fiscalos-web:${IMAGE_TAG:-latest}
ports:
- "8081:80"
depends_on:
- api
restart: unless-stopped
api:
image: ${DOCKERHUB_USERNAME}/fiscalos-api:${IMAGE_TAG:-latest}
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
- SeqOptions__ServerUrl=${SEQ_SERVER_URL}
- SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER}
- SeqOptions__ApiKey=${SEQ_API_KEY}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
-36
View File
@@ -1,36 +0,0 @@
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
ports:
- "80:80"
depends_on:
- api
restart: unless-stopped
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AppDbContextOptions__DatabaseFilePath=/data/fiscalos.db
- JwtOptions__Audience=${JWT_AUDIENCE}
- JwtOptions__Issuer=${JWT_ISSUER}
- JwtOptions__Secret=${JWT_SECRET}
- JwtOptions__ExpiryInMinutes=${JWT_EXPIRY_IN_MINUTES}
- FileKeyRingOptions__KeysDirectoryPath=/keys
- FileKeyRingOptions__PrimaryKeyId=${FILE_KEY_RING_PRIMARY_KEY_ID}
- PlaidClientOptions__ClientId=${PLAID_CLIENT_ID}
- PlaidClientOptions__Secret=${PLAID_SECRET}
- PlaidClientOptions__Webhook=${PLAID_WEBHOOK}
- SeqOptions__ServerUrl=${SEQ_SERVER_URL}
- SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER}
- SeqOptions__ApiKey=${SEQ_API_KEY}
volumes:
- ./data:/data
- ./keys:/keys
restart: unless-stopped
-18
View File
@@ -1,18 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
-4
View File
@@ -1,8 +1,4 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestPatch"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
-4
View File
@@ -1,4 +0,0 @@
[*.cs]
dotnet_code_quality.ca1711.allowed_suffixes = Queue
dotnet_diagnostic.CA1031.severity = none
+2 -11
View File
@@ -3,9 +3,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.1.2" />
<PackageVersion Include="Going.Plaid" Version="6.56.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -13,13 +12,5 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.1.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
</ItemGroup>
</Project>
</Project>
@@ -1,18 +0,0 @@
using FiscalOS.API.Accounts.Add;
namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions
{
private const string RouteGroupPrefix = "/accounts";
public static RouteGroupBuilder MapAccountsEndpoints(this WebApplication app)
{
var accountsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
accountsGroup.MapAddEndpoint();
return accountsGroup;
}
}
-73
View File
@@ -1,73 +0,0 @@
using Account = FiscalOS.Core.Accounts.Account;
namespace FiscalOS.API.Accounts.Add;
internal static class Endpoint
{
private const string Route = "/";
public static RouteHandlerBuilder MapAddEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] IEncryptor encryptor,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> queue,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Metadata is 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.ProviderAccountId))
.ThenInclude(a => a.Metadata)
.AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Institutions.Any() is false)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
var plaidInstitutionMetadata = (PlaidInstitutionMetadata?)user.Institutions.First().Metadata;
if (plaidInstitutionMetadata is null)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
});
}
if (user.Accounts.Any())
{
return Results.Conflict();
}
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName);
var account = Account.From(request.ProviderAccountName, accountMetadata);
user.AddAccount(account);
user.Institutions.First().AddAccount(account);
await appDbContext.SaveChangesAsync(ct);
await queue.EnqueueAsync(new(plaidInstitutionMetadata.ItemId), ct);
return Results.Ok();
}
}
-29
View File
@@ -1,29 +0,0 @@
namespace FiscalOS.API.Accounts.Add;
public record Request : IValidatableObject
{
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(ProviderInstitutionId))
{
var fieldName = nameof(ProviderInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(ProviderAccountId))
{
var fieldName = nameof(ProviderAccountId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(ProviderAccountName))
{
var fieldName = nameof(ProviderAccountName);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
}
-23
View File
@@ -1,23 +0,0 @@
using FiscalOS.API.Auth.Login;
using FiscalOS.API.Auth.Logout;
using FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Auth;
internal static class AuthExtensions
{
private const string RouteGroupPrefix = "/auth";
public static RouteGroupBuilder MapAuthEndpoints(this WebApplication app)
{
var authGroup = app.MapGroup(RouteGroupPrefix);
authGroup.MapLoginEndpoint();
authGroup.MapLogoutEndpoint();
authGroup.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
return authGroup;
}
}
-40
View File
@@ -1,40 +0,0 @@
namespace FiscalOS.API.Auth.Logout;
internal static class Endpoint
{
private const string Route = "/logout";
public static RouteHandlerBuilder MapLogoutEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var refreshToken = httpContext.GetRefreshTokenFromCookie();
var user = await appDbContext.Users
.Include(u => u.RefreshTokens
.Where(r => r.Token == refreshToken)
)
.Where(u => u.Id == userId)
.SingleOrDefaultAsync(ct);
if (user is null || user.RefreshTokens.Any() is false)
{
return Results.Unauthorized();
}
user.RefreshTokens.First().Revoke();
await appDbContext.SaveChangesAsync(ct);
httpContext.ExpireRefreshTokenCookie();
return Results.NoContent();
}
}
-60
View File
@@ -1,60 +0,0 @@
namespace FiscalOS.API.Auth.Refresh;
internal static class Endpoint
{
private const string Route = "/refresh";
public static RouteHandlerBuilder MapRefreshEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
[FromServices] TimeProvider timeProvider,
[FromServices] ITokenGenerator tokenGenerator
)
{
var requestUserId = httpContext.GetUserId();
var token = httpContext.GetRefreshTokenFromCookie();
var refreshToken = await appDbContext.RefreshTokens
.Include(t => t.User)
.SingleOrDefaultAsync(t => t.Token == token);
if (refreshToken is null || refreshToken.User is null)
{
return Results.BadRequest();
}
if (refreshToken.UserId != requestUserId)
{
await appDbContext.RefreshTokens
.Where(t => t.UserId == refreshToken.UserId)
.ExecuteUpdateAsync(t => t.SetProperty(t => t.Revoked, true));
await appDbContext.SaveChangesAsync();
return Results.Forbid();
}
var now = timeProvider.GetUtcNow();
if (refreshToken.Revoked || refreshToken.IsExpired(now))
{
return Results.BadRequest();
}
var accessToken = tokenGenerator.GenerateAccessToken(refreshToken.User);
var newRefreshToken = tokenGenerator.GenerateRefreshToken(refreshToken.User);
refreshToken.Revoke();
await appDbContext.RefreshTokens.AddAsync(newRefreshToken);
await appDbContext.SaveChangesAsync();
httpContext.SetRefreshTokenCookie(newRefreshToken);
return Results.Ok(Response.From(accessToken));
}
}
-22
View File
@@ -1,22 +0,0 @@
namespace FiscalOS.API.Auth.Refresh;
internal sealed record Response
{
public string AccessToken { get; init; }
[JsonConstructor]
private Response()
{
AccessToken = string.Empty;
}
private Response(string accessToken)
{
AccessToken = accessToken;
}
public static Response From(string accessToken)
{
return new(accessToken);
}
}
@@ -1,48 +0,0 @@
using FiscalOS.Infra.Common;
using Microsoft.AspNetCore.Diagnostics;
internal sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger
) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken
)
{
switch (exception)
{
case PlaidException plaidException:
logger.LogError(
plaidException,
"Unhandled Plaid exception. PlaidRequestId: {PlaidRequestId}, PlaidStatusCode: {PlaidStatusCode}, PlaidErrorCode: {PlaidErrorCode}, PlaidErrorType: {PlaidErrorType}, PlaidErrorMessage: {PlaidErrorMessage}",
plaidException.RequestId,
plaidException.StatusCode,
plaidException.ErrorCode,
plaidException.ErrorType,
plaidException.ErrorMessage
);
break;
default:
logger.LogError(exception, "An unhandled exception occurred");
break;
}
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = exception.GetType().Name,
Title = "Uh oh something has gone wrong.",
Detail = exception.Message
}
});
}
}
-36
View File
@@ -1,36 +0,0 @@
namespace FiscalOS.API.Common;
internal sealed record PagedQuery
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public static async ValueTask<PagedQuery> BindAsync(HttpContext context)
{
var pageNumber = int.TryParse(context.Request.Query["pageNumber"], out var pn) ? pn : 1;
var pageSize = int.TryParse(context.Request.Query["pageSize"], out var ps) ? ps : 1000;
return new PagedQuery
{
PageNumber = pageNumber,
PageSize = pageSize
};
}
public Dictionary<string, string[]> Validate()
{
var validationResults = new Dictionary<string, string[]>();
if (PageNumber <= 0)
{
validationResults[nameof(PageNumber)] = ["PageNumber must be greater than 0."];
}
if (PageSize <= 0 || PageSize > 1000)
{
validationResults[nameof(PageSize)] = ["PageSize must be between 1 and 1000."];
}
return validationResults;
}
}
-34
View File
@@ -1,34 +0,0 @@
namespace FiscalOS.API.Common;
internal sealed record PagedResponse<T>
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalItems { get; init; }
public int TotalPages { get; init; }
public T[] Items { get; init; } = [];
[JsonConstructor]
private PagedResponse()
{
}
public static PagedResponse<T> From(
int pageNumber,
int pageSize,
int totalItems,
T[] items
)
{
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
return new PagedResponse<T>
{
PageNumber = pageNumber,
PageSize = pageSize,
TotalItems = totalItems,
TotalPages = totalPages,
Items = items
};
}
}
+1 -1
View File
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -10,7 +11,6 @@
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.ServiceDefaults\FiscalOS.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -1,47 +0,0 @@
namespace FiscalOS.API.Http;
internal static class HttpContextExtensions
{
private const string RefreshTokenCookieName = "fiscalos_refresh_cookie";
public static Guid GetUserId(this HttpContext context)
{
var id = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
return Guid.TryParse(id, out var userId) ? userId : Guid.Empty;
}
public static void SetRefreshTokenCookie(this HttpContext context, RefreshToken token)
{
context.Response.Cookies.Append(
RefreshTokenCookieName,
token.Token,
new CookieOptions
{
HttpOnly = true,
Expires = token.ExpiresAt,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
}
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;
}
}
@@ -1,58 +0,0 @@
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Institutions.Connect;
internal static class Endpoint
{
private const string Route = "/connect";
public static RouteHandlerBuilder MapConnectEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IEncryptor encryptor,
CancellationToken ct
)
{
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
)
)
.ThenInclude(i => i.Metadata)
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Institutions.Any())
{
return Results.Conflict();
}
var (itemId, accessToken) = await plaidAccountService.ExchangeTokenAsync(request.PublicToken);
var item = await plaidAccountService.GetItemAsync(accessToken);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct);
var plaidMetadata = PlaidInstitutionMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken, itemId);
var institution = Institution.From(item.InstitutionName, plaidMetadata);
user.AddInstitution(institution);
await appDbContext.SaveChangesAsync(ct);
return Results.Ok();
}
}
@@ -1,22 +0,0 @@
namespace FiscalOS.API.Institutions.Connect;
public record Request : IValidatableObject
{
public string PublicToken { get; init; } = string.Empty;
public string PlaidInstitutionId { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(PublicToken))
{
var fieldName = nameof(PublicToken);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
{
var fieldName = nameof(PlaidInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
}
@@ -1,36 +0,0 @@
namespace FiscalOS.API.Institutions.Get;
internal static class Endpoint
{
private const string Route = "/";
public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapGet(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions)
.ThenInclude(i => i.Accounts)
.ThenInclude(a => a.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
{
return Results.Unauthorized();
}
var institutionDtos = user.Institutions
.Select(InstitutionDto.From)
.OrderBy(dto => dto.Name);
return Results.Ok(Response.From(institutionDtos));
}
}
@@ -1,68 +0,0 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Institutions.Get;
internal sealed record Response
{
public IEnumerable<InstitutionDto> Institutions { get; init; } = [];
[JsonConstructor]
private Response()
{
}
public static Response From(IEnumerable<InstitutionDto> institutions)
{
return new Response
{
Institutions = institutions,
};
}
}
internal sealed record AccountDto
{
public string Id { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
private AccountDto()
{
}
public static AccountDto From(Account account)
{
return new()
{
Id = account.Id.ToString(),
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
? plaidMetadata.PlaidId
: string.Empty,
Name = account.Name,
};
}
}
internal sealed record InstitutionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public IEnumerable<AccountDto> Accounts { get; init; } = [];
[JsonConstructor]
private InstitutionDto()
{
}
public static InstitutionDto From(Institution institution)
{
return new()
{
Id = institution.Id.ToString(),
Name = institution.Name,
Accounts = institution.Accounts.Select(AccountDto.From),
};
}
}
@@ -1,51 +0,0 @@
namespace FiscalOS.API.Institutions.GetAvailable;
internal static class Endpoint
{
private const string Route = "/{id}/available";
public static RouteHandlerBuilder MapGetAvailableEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapGet(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromRoute] Guid id,
[FromServices] AppDbContext appDbContext,
[FromServices] IPlaidAccountService plaidAccountService,
[FromServices] IEncryptor encryptor,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions.Where(i => i.Id == id))
.ThenInclude(i => i.Metadata)
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Institutions.Any() is false)
{
return Results.NotFound();
}
var institution = user.Institutions.First();
if (institution.Metadata is not PlaidInstitutionMetadata plaidMetadata)
{
return Results.BadRequest();
}
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
var accounts = await plaidAccountService.GetAccountsAsync(accessToken);
var accountsDtos = accounts.Select(a => AvailableAccountDto.From(plaidMetadata, a));
return Results.Ok(Response.From(accountsDtos));
}
}
@@ -1,50 +0,0 @@
namespace FiscalOS.API.Institutions.GetAvailable;
internal sealed record Response
{
public IEnumerable<AvailableAccountDto> Accounts { get; init; } = [];
[JsonConstructor]
private Response()
{
}
public static Response From(IEnumerable<AvailableAccountDto> accounts)
{
return new Response
{
Accounts = accounts
};
}
}
internal sealed record AvailableAccountDto
{
public string ProviderInstitutionId { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string ProviderName { get; init; } = string.Empty;
public decimal CurrentBalance { get; init; }
public decimal AvailableBalance { get; init; }
public string CurrencyCode { get; init; } = string.Empty;
[JsonConstructor]
private AvailableAccountDto()
{
}
public static AvailableAccountDto From(
PlaidInstitutionMetadata plaidMetadata,
Going.Plaid.Entity.Account plaidAccount
)
{
return new AvailableAccountDto
{
ProviderInstitutionId = plaidMetadata.PlaidId,
ProviderId = plaidAccount.AccountId,
ProviderName = plaidAccount.Name,
AvailableBalance = plaidAccount.Available,
CurrentBalance = plaidAccount.Current,
CurrencyCode = plaidAccount.CurrencyCode,
};
}
}
@@ -1,24 +0,0 @@
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
{
private const string RouteGroupPrefix = "/institutions";
public static RouteGroupBuilder MapInstitutionsEndpoints(this WebApplication app)
{
var institutionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
institutionsGroup.MapConnectEndpoint();
institutionsGroup.MapGetAvailableEndpoint();
institutionsGroup.MapLinkEndpoint();
institutionsGroup.MapGetEndpoint();
return institutionsGroup;
}
}
@@ -1,31 +0,0 @@
namespace FiscalOS.API.Institutions.Link;
internal static class Endpoint
{
private const string Route = "/link";
public static RouteHandlerBuilder MapLinkEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
[FromServices] IPlaidAccountService plaidAccountService
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Id == userId);
if (user is null)
{
return Results.Unauthorized();
}
var linkToken = await plaidAccountService.CreateLinkTokenAsync(user.Id.ToString());
return Results.Ok(Response.From(linkToken));
}
}
@@ -1,19 +0,0 @@
namespace FiscalOS.API.Institutions.Link;
public sealed record Response
{
public string LinkToken { get; init; } = string.Empty;
[JsonConstructor]
private Response()
{
}
public static Response From(string linkToken)
{
return new()
{
LinkToken = linkToken,
};
}
}
@@ -1,17 +1,17 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
internal static class Endpoint
{
private const string Route = "/login";
public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder)
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
{
return groupBuilder.MapPost(Route, HandleAsync);
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request loginRequest,
[FromBody] LoginRequest loginRequest,
[FromServices] AppDbContext appDbContext,
[FromServices] IPasswordHasher passwordHasher,
[FromServices] ITokenGenerator tokenService
@@ -32,13 +32,22 @@ internal static class Endpoint
}
var accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user);
user.AddRefreshToken(refreshToken);
await appDbContext.RefreshTokens.AddAsync(refreshToken);
await appDbContext.SaveChangesAsync();
httpContext.SetRefreshTokenCookie(refreshToken);
httpContext.Response.Cookies.Append(
"fiscalos_refresh_cookie",
refreshToken.Token,
new CookieOptions
{
HttpOnly = true,
Expires = refreshToken.ExpiresAt,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
return Results.Ok(Response.From(accessToken));
}
@@ -1,6 +1,7 @@
namespace FiscalOS.API.Auth.Login;
public record Request : IValidatableObject
namespace FiscalOS.API.Login;
public record LoginRequest : IValidatableObject
{
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
@@ -9,14 +10,12 @@ public record Request : IValidatableObject
{
if (string.IsNullOrWhiteSpace(Username))
{
var fieldName = nameof(Username);
yield return new($"The {fieldName} field is required.", [fieldName]);
yield return new("The Username field is required.", [nameof(Username)]);
}
if (string.IsNullOrWhiteSpace(Password))
{
var fieldName = nameof(Password);
yield return new($"The {fieldName} field is required.", [fieldName]);
yield return new("The Password field is required.", [nameof(Password)]);
}
}
}
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
internal sealed record Response
{
+8 -40
View File
@@ -1,51 +1,19 @@
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddValidation();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(static options =>
{
options.SerializerOptions.Converters.Add(new EnumConverterFactory());
options.SerializerOptions.Converters.Add(new WebhookBaseConverter());
});
builder.Services.AddOpenApi();
builder.Services.AddInfrastructure();
builder.Services.AddAuthentication(static o =>
{
o.DefaultAuthenticateScheme = Schemes.Default;
o.DefaultChallengeScheme = Schemes.Default;
})
.AddJwtBearer(Schemes.Default)
.AddJwtBearer(Schemes.AllowExpiredTokens);
builder.Services.AddAuthorizationBuilder()
.AddPolicy(Schemes.Default, static policy =>
{
policy.AuthenticationSchemes = [Schemes.Default];
policy.RequireAuthenticatedUser();
})
.AddPolicy(Schemes.AllowExpiredTokens, static policy =>
{
policy.AuthenticationSchemes = [Schemes.AllowExpiredTokens];
policy.RequireAuthenticatedUser();
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePages();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapDefaultEndpoints();
app.MapAuthEndpoints();
app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints();
app.MapTransactionsGroup();
app.UseHttpsRedirection();
app.MapLoginEndpoint();
app.Run();
@@ -18,6 +18,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
}
}
@@ -1,34 +0,0 @@
namespace FiscalOS.API.Transactions.DeleteById;
internal static class Endpoint
{
private const string Route = "/{id}";
public static RouteHandlerBuilder MapDeleteEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapDelete(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromRoute] Guid id,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var existingTransaction = await appDbContext.Transactions
.SingleOrDefaultAsync(t => t.UserId == userId && t.Id == id, ct);
if (existingTransaction is null)
{
return Results.NotFound();
}
appDbContext.Transactions.Remove(existingTransaction);
await appDbContext.SaveChangesAsync(ct);
return Results.NoContent();
}
}
@@ -1,62 +0,0 @@
namespace FiscalOS.API.Transactions.FireWebhook;
internal static class Endpoint
{
private const string Route = "/fire-webhook";
public static RouteHandlerBuilder MapFireWebhookEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] IEncryptor encryptor,
[FromServices] PlaidClient plaidClient,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Accounts.Where(a => a.Id.ToString() == request.AccountId))
.ThenInclude(static a => a.Institution!)
.ThenInclude(static i => i.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Accounts.Any() is false)
{
return Results.NotFound();
}
var plaidMetadata = (PlaidInstitutionMetadata?)user.Accounts.First().Institution?.Metadata;
if (plaidMetadata is null)
{
return Results.InternalServerError("No metadata associated with account");
}
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
var fireEventResponse = await plaidClient.SandboxItemFireWebhookAsync(new()
{
AccessToken = accessToken,
WebhookType = SandboxItemFireWebhookRequestWebhookTypeEnum.Transactions,
WebhookCode = SandboxItemFireWebhookRequestWebhookCodeEnum.SyncUpdatesAvailable,
});
if (fireEventResponse.IsSuccessStatusCode is false)
{
return Results.InternalServerError(fireEventResponse.Error?.ErrorMessage);
}
return Results.Ok(new { fireEventResponse.RequestId, fireEventResponse.WebhookFired });
}
}
@@ -1,7 +0,0 @@
namespace FiscalOS.API.Transactions.FireWebhook;
public sealed record Request(
string AccountId,
string WebhookType,
string WebhookCode
);
@@ -1,53 +0,0 @@
namespace FiscalOS.API.Transactions.Get;
internal static class Endpoint
{
private const string Route = "/";
public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapGet(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
PagedQuery pagedQuery,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var pagedQueryValidationResults = pagedQuery.Validate();
if (pagedQueryValidationResults.Count is not 0)
{
return Results.ValidationProblem(pagedQueryValidationResults);
}
var userId = httpContext.GetUserId();
var query = appDbContext.Transactions
.Where(t => t.UserId == userId);
var transactions = await query
.Skip((pagedQuery.PageNumber - 1) * pagedQuery.PageSize)
.Take(pagedQuery.PageSize)
// TODO: Migrate Date to DateTime
// to avoid in-memory sorting
.AsAsyncEnumerable()
.OrderByDescending(t => t.Date)
.ToListAsync(ct);
var count = await query
.Where(t => t.UserId == userId)
.CountAsync(ct);
var transactionDtos = transactions.Select(TransactionDto.From).ToArray();
var pagedResponse = PagedResponse<TransactionDto>.From(
pagedQuery.PageNumber,
pagedQuery.PageSize,
count,
transactionDtos
);
return Results.Ok(pagedResponse);
}
}
@@ -1,29 +0,0 @@
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.API.Transactions;
internal sealed record TransactionDto
{
public Guid Id { get; init; }
public string MerchantName { get; init; } = string.Empty;
public decimal Amount { get; init; }
public DateTimeOffset Date { get; init; }
public string Description { get; init; } = string.Empty;
[JsonConstructor]
private TransactionDto()
{
}
public static TransactionDto From(Transaction transaction)
{
return new TransactionDto
{
Id = transaction.Id,
MerchantName = transaction.MerchantName,
Amount = transaction.Amount,
Date = transaction.Date,
Description = transaction.Description
};
}
}
@@ -1,28 +0,0 @@
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
{
private const string RouteGroupPrefix = "/transactions";
public static RouteGroupBuilder MapTransactionsGroup(this WebApplication app)
{
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
transactionsGroup.MapGetEndpoint();
transactionsGroup.MapDeleteEndpoint();
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
if (app.Environment.IsProduction() is false)
{
transactionsGroup.MapFireWebhookEndpoint();
}
return transactionsGroup;
}
}
@@ -1,41 +0,0 @@
namespace FiscalOS.API.Transactions.Webhook;
internal static class Endpoint
{
private const string Route = "/webhook";
public static RouteHandlerBuilder MapWebhookEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
[FromBody] WebhookBase request,
[FromServices] ILogger<Program> logger,
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> queue,
CancellationToken ct
)
{
logger.LogInformation(
"Received {WebhookType} webhook with {WebhookCode} payload",
request.WebhookType,
request.WebhookCode
);
switch (request)
{
case SyncUpdatesAvailableWebhook webhook:
await queue.EnqueueAsync(new(webhook.ItemId), ct);
logger.LogInformation(
"Enqueued {WebhookType} webhook with {WebhookCode} payload",
request.WebhookType,
request.WebhookCode
);
break;
default:
break;
}
return Results.Ok();
}
}
+1 -19
View File
@@ -1,28 +1,10 @@
global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Accounts;
global using FiscalOS.API.Auth;
global using FiscalOS.API.Common;
global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions;
global using FiscalOS.API.Transactions;
global using FiscalOS.API.Login;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
global using FiscalOS.Infra.Transactions.Plaid;
global using FiscalOS.ServiceDefaults;
global using Going.Plaid;
global using Going.Plaid.Converters;
global using Going.Plaid.Entity;
global using Going.Plaid.Webhook;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
+1 -15
View File
@@ -14,19 +14,5 @@
"Issuer": "Issuer",
"Secret": "Secret",
"ExpiryInMinutes": "ExpiryInMinutes"
},
"FileKeyRingOptions": {
"KeysDirectoryPath": "KeysDirectoryPath",
"PrimaryKeyId": "PrimaryKeyId"
},
"PlaidClientOptions": {
"ClientId": "ClientId",
"Secret": "Secret",
"Webhook": "Webhook"
},
"SeqOptions": {
"ServerUrl": "ServerUrl",
"ApiKeyHeader": "ApiKeyHeader",
"ApiKey": "ApiKey"
}
}
}
+2 -56
View File
@@ -3,9 +3,7 @@ namespace FiscalOS.AdminCLI;
internal sealed class App(
IAnsiConsole console,
IPasswordHasher passwordHasher,
IServiceScopeFactory serviceScopeFactory,
IEncryptor encryptor,
IKeyRing keyRing
IServiceScopeFactory serviceScopeFactory
) : IHostedService
{
private readonly IAnsiConsole _console = console;
@@ -34,65 +32,13 @@ internal sealed class App(
);
var hashedPassword = passwordHasher.Hash(password);
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(cancellationToken);
var user = User.From(username, hashedPassword, userEncryptionKey);
var user = User.From(username, hashedPassword);
await appDbContext.Users.AddAsync(user, cancellationToken);
await appDbContext.SaveChangesAsync(cancellationToken);
_console.MarkupLine($"User [green]{username}[/] created successfully.");
break;
case Commands.GenerateKey:
var key = encryptor.GenerateKey();
var keyRingEntry = await keyRing.SaveKeyAsync(key);
_console.MarkupLine($"Key generated with id [green]{keyRingEntry.KeyId}[/] generated successfully.");
break;
case Commands.RemoveInstitution:
var institutionId = await _console.AskAsync<string>("Enter the [green]institution id[/] of the item you want to remove:", cancellationToken);
var institution = await appDbContext.Institutions
.Include(i => i.User)
.Include(i => i.Metadata)
.FirstOrDefaultAsync(cancellationToken);
if (institution is null)
{
_console.MarkupLine($"Institution with id [green]{institutionId}[/] not found.");
break;
}
if (institution.User is null)
{
_console.MarkupLine($"Institution with id [green]{institutionId}[/] has no user.");
break;
}
if (institution.Metadata is not PlaidInstitutionMetadata plaidInstitutionMetadata)
{
_console.MarkupLine($"Institution with id [green]{institutionId}[/] has no plaid metadata.");
break;
}
var accessToken = await encryptor.DecryptAsyncFor(institution.User, plaidInstitutionMetadata.EncryptedAccessToken, cancellationToken);
var plaidClient = scope.ServiceProvider.GetRequiredService<PlaidClient>();
var removeItemResponse = await plaidClient.ItemRemoveAsync(new()
{
AccessToken = accessToken,
});
if (removeItemResponse.IsSuccessStatusCode is false)
{
_console.MarkupLine($"Failed to remove item for institution id [green]{institutionId}[/]. Plaid error: {removeItemResponse.Error?.ErrorMessage}");
break;
}
appDbContext.Institutions.Remove(institution);
await appDbContext.SaveChangesAsync(cancellationToken);
_console.MarkupLine($"Institution with id [green]{institutionId}[/] removed successfully.");
break;
case Commands.Exit:
default:
break;
+1 -8
View File
@@ -3,14 +3,7 @@ namespace FiscalOS.AdminCLI;
internal static class Commands
{
public const string CreateUser = "Create User";
public const string GenerateKey = "Generate Key";
public const string RemoveInstitution = "Remove Institution";
public const string Exit = "Exit";
public static readonly string[] All = [
CreateUser,
GenerateKey,
RemoveInstitution,
Exit
];
public static readonly string[] All = [CreateUser, Exit];
}
@@ -2,7 +2,6 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>fiscaloscli</AssemblyName>
</PropertyGroup>
<ItemGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
await Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration(
static c => c.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
)
.ConfigureLogging(static c => c.ClearProviders())
-5
View File
@@ -1,14 +1,9 @@
global using FiscalOS.AdminCLI;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
global using Going.Plaid;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
@@ -1,9 +1,5 @@
{
"AppDbContextOptions": {
"DatabaseFilePath": "DatabaseFilePath"
},
"FileKeyRingOptions": {
"KeysDirectoryPath": "KeysDirectoryPath",
"PrimaryKeyId": "PrimaryKeyId"
}
}
-12
View File
@@ -1,12 +0,0 @@
using FiscalOS.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.FiscalOS_API>(ProjectNames.API)
.WithHttpHealthCheck("/health");
builder.AddViteApp(ProjectNames.Web, "../FiscalOS.Web")
.WithReference(api)
.WithHttpsEndpoint(port: 7061, env: "PORT");
builder.Build().Run();
@@ -1,19 +0,0 @@
<Project Sdk="Aspire.AppHost.Sdk/13.1.2">
<ItemGroup>
<ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>80a27726-36cc-4a4d-a55d-11af527158bc</UserSecretsId>
</PropertyGroup>
</Project>
-7
View File
@@ -1,7 +0,0 @@
namespace FiscalOS.AppHost;
public static class ProjectNames
{
public const string API = "api";
public const string Web = "web";
}
@@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17104;http://localhost:15068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23228",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22081"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19221",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18206",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20097"
}
}
}
}
-46
View File
@@ -1,46 +0,0 @@
namespace FiscalOS.Core.Accounts;
public sealed class Account : Entity
{
public Guid UserId { get; init; }
public Guid InstitutionId { get; init; }
public Institution? Institution { get; init; }
public string Name { get; init; } = string.Empty;
public AccountMetadata? Metadata { get; init; }
private readonly List<Balance> _balances = [];
public IEnumerable<Balance> Balances => _balances;
private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
private Account()
{
}
public static Account From(
string name,
AccountMetadata accountMetadata
)
{
return new()
{
Name = name,
Metadata = accountMetadata,
};
}
public void AddBalance(Balance balance)
{
ArgumentNullException.ThrowIfNull(balance, nameof(balance));
_balances.Add(balance);
}
public void AddTransaction(Transaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction, nameof(transaction));
_transactions.Add(transaction);
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Core.Accounts;
public abstract class AccountMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid AccountId { get; init; }
protected AccountMetadata(string type)
{
_type = type;
}
}
-29
View File
@@ -1,29 +0,0 @@
namespace FiscalOS.Core.Accounts;
public sealed class Balance : Entity
{
public Guid AccountId { get; init; }
public decimal Current { get; init; }
public decimal Available { get; init; }
public string CurrencyCode { get; init; } = string.Empty;
private Balance()
{
}
public static Balance From(
decimal current,
decimal available,
string currencyCode
)
{
ArgumentNullException.ThrowIfNull(currencyCode, nameof(currencyCode));
return new()
{
Current = current,
Available = available,
CurrencyCode = currencyCode,
};
}
}
-35
View File
@@ -1,35 +0,0 @@
namespace FiscalOS.Core.Accounts;
public sealed class Institution : Entity
{
public Guid UserId { get; init; }
public User? User { get; init; }
public string Name { get; init; } = string.Empty;
public InstitutionMetadata? Metadata { get; init; }
private readonly List<Account> _accounts = [];
public IEnumerable<Account> Accounts => _accounts;
private Institution()
{
}
public static Institution From(string? name, InstitutionMetadata metadata)
{
ArgumentNullException.ThrowIfNull(name, nameof(name));
ArgumentNullException.ThrowIfNull(metadata, nameof(metadata));
return new Institution()
{
Name = name,
Metadata = metadata
};
}
public void AddAccount(Account account)
{
ArgumentNullException.ThrowIfNull(account, nameof(account));
_accounts.Add(account);
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Core.Accounts;
public abstract class InstitutionMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid InstitutionId { get; init; }
protected InstitutionMetadata(string type)
{
_type = type;
}
}
+1 -25
View File
@@ -5,8 +5,7 @@ public sealed class RefreshToken : Entity
public string Token { get; init; } = string.Empty;
public DateTimeOffset ExpiresAt { get; init; }
public Guid UserId { get; init; }
public User? User { get; init; }
public bool Revoked { get; set; }
public User User { get; init; } = User.New();
private RefreshToken()
{
@@ -21,27 +20,4 @@ public sealed class RefreshToken : Entity
ExpiresAt = expiresAt,
};
}
public static RefreshToken From(User user, string token, DateTimeOffset expiresAt)
{
ArgumentNullException.ThrowIfNull(user, nameof(user));
return new()
{
User = user,
UserId = user.Id,
Token = token,
ExpiresAt = expiresAt,
};
}
public void Revoke()
{
Revoked = true;
}
public bool IsExpired(DateTimeOffset now)
{
return now >= ExpiresAt;
}
}
+2 -48
View File
@@ -4,20 +4,8 @@ public sealed class User : Entity
{
public string Username { get; init; } = string.Empty;
public string HashedPassword { get; init; } = string.Empty;
public string EncryptionKeyId { get; init; } = string.Empty;
public string EncryptedDataKey { get; init; } = string.Empty;
private readonly List<RefreshToken> _refreshTokens = [];
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
private readonly List<Institution> _institutions = [];
public IEnumerable<Institution> Institutions => _institutions;
private readonly List<Account> _accounts = [];
public IEnumerable<Account> Accounts => _accounts;
private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
public ICollection<RefreshToken> RefreshTokens { get; init; } = [];
private User()
{
@@ -28,46 +16,12 @@ public sealed class User : Entity
return new();
}
public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey)
public static User From(string username, string hashedPassword)
{
ArgumentNullException.ThrowIfNull(username, nameof(username));
ArgumentNullException.ThrowIfNull(hashedPassword, nameof(hashedPassword));
ArgumentNullException.ThrowIfNull(encryptedDataKey, nameof(encryptedDataKey));
return new()
{
Username = username,
HashedPassword = hashedPassword,
EncryptedDataKey = encryptedDataKey.EncryptedKey,
EncryptionKeyId = encryptedDataKey.KeyIdUsed,
};
}
public void AddRefreshToken(RefreshToken refreshToken)
{
ArgumentNullException.ThrowIfNull(refreshToken, nameof(refreshToken));
_refreshTokens.Add(refreshToken);
}
public void AddInstitution(Institution institution)
{
ArgumentNullException.ThrowIfNull(institution, nameof(institution));
_institutions.Add(institution);
}
public void AddAccount(Account account)
{
ArgumentNullException.ThrowIfNull(account, nameof(account));
_accounts.Add(account);
}
public void AddTransaction(Transaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction, nameof(transaction));
_transactions.Add(transaction);
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace FiscalOS.Core.Queuing;
public interface IAsyncQueue<T>
{
Task EnqueueAsync(T item, CancellationToken cancellationToken = default);
Task<T> DequeueAsync(CancellationToken cancellationToken = default);
ValueTask DisposeAsync();
}
@@ -1,6 +0,0 @@
namespace FiscalOS.Core.Queuing;
public interface IAsyncQueueProcessor<T>
{
Task ProcessAsync(T item, CancellationToken cancellationToken = default);
}
@@ -1,21 +0,0 @@
namespace FiscalOS.Core.Security;
public sealed record EncryptedDataKey
{
public string KeyIdUsed { get; init; }
public string EncryptedKey { get; init; }
private EncryptedDataKey(string keyIdUsed, string encryptedKey)
{
KeyIdUsed = keyIdUsed;
EncryptedKey = encryptedKey;
}
public static EncryptedDataKey From(string keyIdUsed, string encryptedKey)
{
ArgumentNullException.ThrowIfNull(keyIdUsed, nameof(keyIdUsed));
ArgumentNullException.ThrowIfNull(encryptedKey, nameof(encryptedKey));
return new(keyIdUsed, encryptedKey);
}
}
-11
View File
@@ -1,11 +0,0 @@
namespace FiscalOS.Core.Security;
public interface IEncryptor
{
string GenerateKey();
Task<EncryptedDataKey> GenerateEncryptedKeyAsync(CancellationToken ct);
Task<string> EncryptAsync(string plainText, CancellationToken ct);
Task<string> EncryptAsyncFor(User user, string plainText, CancellationToken ct);
Task<string> DecryptAsync(string cipherText, CancellationToken ct);
Task<string> DecryptAsyncFor(User user, string plainText, CancellationToken ct);
}
-8
View File
@@ -1,8 +0,0 @@
namespace FiscalOS.Core.Security;
public interface IKeyRing
{
KeyRingEntry GetKey(string keyId);
KeyRingEntry GetPrimaryKey();
Task<KeyRingEntry> SaveKeyAsync(string key);
}
@@ -1,21 +0,0 @@
namespace FiscalOS.Core.Security;
public sealed record KeyRingEntry
{
public string KeyId { get; init; }
public string Key { get; init; }
private KeyRingEntry(string keyId, string key)
{
KeyId = keyId;
Key = key;
}
public static KeyRingEntry From(string keyId, string key)
{
ArgumentNullException.ThrowIfNull(keyId, nameof(keyId));
ArgumentNullException.ThrowIfNull(key, nameof(key));
return new(keyId, key);
}
}
@@ -1,82 +0,0 @@
namespace FiscalOS.Core.Transactions;
public sealed class Transaction : Entity
{
public Guid UserId { get; private set; }
public User? User { get; init; }
public Guid AccountId { get; init; }
public Account? Account { get; init; }
public string MerchantName { get; private set; } = string.Empty;
public string Description { get; init; } = string.Empty;
public decimal Amount { get; init; }
public DateTimeOffset Date { get; init; }
public TransactionMetadata? Metadata { get; init; }
private Transaction()
{
}
public static Transaction From(
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, Guid.Empty, Guid.Empty, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid userId,
Guid accountId,
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, userId, accountId, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid id,
Guid userId,
Guid accountId,
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
ArgumentNullException.ThrowIfNull(merchantName, nameof(merchantName));
ArgumentNullException.ThrowIfNull(description, nameof(description));
if (amount.HasValue is false)
{
throw new ArgumentNullException(nameof(amount));
}
if (date.HasValue is false)
{
throw new ArgumentNullException(nameof(date));
}
return new()
{
Id = id,
UserId = userId,
AccountId = accountId,
MerchantName = merchantName,
Description = description,
Amount = amount.Value,
Date = date.Value,
Metadata = metadata,
};
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Core.Transactions;
public abstract class TransactionMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid TransactionId { get; init; }
protected TransactionMetadata(string type)
{
_type = type;
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Core.Identity;
-1
View File
@@ -1,4 +1,3 @@
[*.cs]
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA5404.severity = none
@@ -1,32 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidAccountMetadata : AccountMetadata
{
public const string TypeValue = Providers.Plaid;
public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty;
public string? Cursor { get; private set; }
private PlaidAccountMetadata() : base(TypeValue)
{
}
public static PlaidAccountMetadata From(
string plaidId,
string plaidName
)
{
return new()
{
PlaidId = plaidId,
PlaidName = plaidName,
};
}
public void SetCursor(string cursor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cursor, nameof(cursor));
Cursor = cursor;
}
}
@@ -1,119 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public interface IPlaidAccountService
{
Task<string> CreateLinkTokenAsync(string id);
Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken);
Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken);
Task<ItemWithConsentFields> GetItemAsync(string accessToken);
}
internal sealed class PlaidAccountService : IPlaidAccountService
{
private readonly IOptions<PlaidClientOptions> _options;
private readonly PlaidClient _client;
private PlaidAccountService(PlaidClient client, IOptions<PlaidClientOptions> options)
{
_client = client;
_options = options;
}
public static PlaidAccountService From(IServiceProvider sp)
{
var client = sp.GetRequiredService<PlaidClient>();
var options = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
return new(client, options);
}
public async Task<string> CreateLinkTokenAsync(string id)
{
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var linkTokenResponse = await _client.LinkTokenCreateAsync(new()
{
ClientName = $"{assemblyName}_{environmentName}",
Products = [Products.Transactions],
CountryCodes = [CountryCode.Us],
Language = Language.English,
User = new()
{
ClientUserId = id,
},
Webhook = _options.Value.Webhook,
}).ConfigureAwait(false);
if (linkTokenResponse.IsSuccessStatusCode is false)
{
throw new PlaidException(
"Unable to create link token",
linkTokenResponse.Error,
linkTokenResponse.RequestId,
(int?)linkTokenResponse.StatusCode
);
}
return linkTokenResponse.LinkToken;
}
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
{
var publicTokenResponse = await _client.ItemPublicTokenExchangeAsync(new()
{
PublicToken = publicToken,
}).ConfigureAwait(false);
if (publicTokenResponse.IsSuccessStatusCode is false)
{
throw new PlaidException(
"Unable to exchange public token for access token",
publicTokenResponse.Error,
publicTokenResponse.RequestId,
(int?)publicTokenResponse.StatusCode
);
}
return (publicTokenResponse.ItemId, publicTokenResponse.AccessToken);
}
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
{
var accountResponse = await _client.AccountsGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (accountResponse.IsSuccessStatusCode is false)
{
throw new PlaidException(
"Unable to retrieve accounts",
accountResponse.Error,
accountResponse.RequestId,
(int?)accountResponse.StatusCode
);
}
return [.. accountResponse.Accounts];
}
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
{
var itemResponse = await _client.ItemGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (itemResponse.IsSuccessStatusCode is false)
{
throw new PlaidException(
"Unable to retrieve item",
itemResponse.Error,
itemResponse.RequestId,
(int?)itemResponse.StatusCode
);
}
return itemResponse.Item;
}
}
@@ -1,33 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed record PlaidClientOptions
{
public string ClientId { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public string Webhook { get; init; } = string.Empty;
public IOptions<PlaidOptions> ToPlaidOptions()
{
return Options.Create<PlaidOptions>(new()
{
ClientId = ClientId,
Secret = Secret
});
}
}
public sealed record PlaidClientOptionsSetup : IConfigureOptions<PlaidClientOptions>
{
private const string SectionName = nameof(PlaidClientOptions);
private readonly IConfiguration _configuration;
public PlaidClientOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(PlaidClientOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
@@ -1,15 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public static class PlaidExtensions
{
#pragma warning disable CA1034
extension(Going.Plaid.Entity.Account act)
{
public string CurrencyCode => act.Balances.IsoCurrencyCode
?? act.Balances.UnofficialCurrencyCode
?? "Unknown";
public decimal Available => act.Balances.Available.GetValueOrDefault();
public decimal Current => act.Balances.Current.GetValueOrDefault();
}
}
@@ -1,35 +0,0 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidInstitutionMetadata : InstitutionMetadata
{
public const string TypeValue = Providers.Plaid;
public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty;
public string EncryptedAccessToken { get; init; } = string.Empty;
public string ItemId { get; init; } = string.Empty;
private PlaidInstitutionMetadata() : base(TypeValue)
{
}
public static PlaidInstitutionMetadata From(
string? plaidId,
string? plaidName,
string encryptedAccessToken,
string itemId
)
{
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName));
ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken));
ArgumentNullException.ThrowIfNull(itemId, nameof(itemId));
return new()
{
PlaidId = plaidId,
PlaidName = plaidName,
EncryptedAccessToken = encryptedAccessToken,
ItemId = itemId,
};
}
}
@@ -6,8 +6,6 @@ public sealed record JwtOptions
public string Audience { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public int ExpiryInMinutes { get; init; } = 5;
public SymmetricSecurityKey Key => new(Encoding.UTF8.GetBytes(Secret));
}
public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
@@ -24,33 +22,4 @@ public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
{
_configuration.GetSection(SectionName).Bind(options);
}
}
public sealed class JwtBearerOptionsSetup(IOptions<JwtOptions> jwtOptions) : IConfigureNamedOptions<JwtBearerOptions>
{
private readonly JwtOptions _jwtOptions = jwtOptions.Value;
public void Configure(string? name, JwtBearerOptions options)
{
options.TokenValidationParameters = new()
{
ValidIssuer = _jwtOptions.Issuer,
ValidAudience = _jwtOptions.Audience,
IssuerSigningKey = _jwtOptions.Key,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
};
if (name is Schemes.AllowExpiredTokens)
{
options.TokenValidationParameters.ValidateLifetime = false;
}
}
public void Configure(JwtBearerOptions options)
{
Configure(Options.DefaultName, options);
}
}
@@ -1,7 +0,0 @@
namespace FiscalOS.Infra.Authentication;
public static class Schemes
{
public const string Default = JwtBearerDefaults.AuthenticationScheme;
public const string AllowExpiredTokens = "AllowExpiredTokens";
}
@@ -33,6 +33,7 @@ public sealed class TokenGenerator : ITokenGenerator
public string GenerateAccessToken(User user)
{
var tokenHandler = new JwtSecurityTokenHandler();
var secretKeyBytes = Encoding.UTF8.GetBytes(_jwtOptions.Value.Secret);
var issuedAt = _timeProvider.GetUtcNow();
var expiresAt = issuedAt.AddMinutes(_jwtOptions.Value.ExpiryInMinutes);
List<Claim> claims = [
@@ -48,7 +49,7 @@ public sealed class TokenGenerator : ITokenGenerator
Issuer = _jwtOptions.Value.Issuer,
Audience = _jwtOptions.Value.Audience,
SigningCredentials = new(
_jwtOptions.Value.Key,
new SymmetricSecurityKey(secretKeyBytes),
SecurityAlgorithms.HmacSha256Signature
),
};
@@ -1,41 +0,0 @@
namespace FiscalOS.Infra.Authorization;
internal sealed class ProblemDetailsAuthResultHandler : IAuthorizationMiddlewareResultHandler
{
public Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult
)
{
if (authorizeResult.Succeeded)
{
return next(context);
}
context.Response.StatusCode = authorizeResult.Forbidden
? StatusCodes.Status403Forbidden
: StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/problem+json";
var problemDetails = authorizeResult.Forbidden
? new ProblemDetails
{
Title = "Forbidden",
Status = StatusCodes.Status403Forbidden,
Detail = "You do not have permission to access this resource."
}
: new ProblemDetails
{
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "Authentication is required to access this resource."
};
var json = JsonSerializer.Serialize(problemDetails);
return context.Response.WriteAsync(json);
}
}
@@ -1,53 +0,0 @@
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()
{
}
public PlaidException(string message) : base(message)
{
}
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;
}
}
}
-6
View File
@@ -1,6 +0,0 @@
namespace FiscalOS.Infra.Common;
public static class Providers
{
public const string Plaid = nameof(Plaid);
}
+8 -139
View File
@@ -1,31 +1,21 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.Infra.Data;
public sealed class AppDbContext(
IOptions<AppDbContextOptions> ctxOptions,
IFileSystem fileSystem
) : DbContext
public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbContext
{
private const string DataSourceKey = "Data Source=";
private readonly AppDbContextOptions _ctxOptions = ctxOptions.Value;
private readonly IFileSystem _fileSystem = fileSystem;
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Institution> Institutions => Set<Institution>();
public DbSet<Transaction> Transactions => Set<Transaction>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var dbPath = _fileSystem.Path.GetFullPath(_ctxOptions.DatabaseFilePath, AppContext.BaseDirectory);
var dbDirectory = _fileSystem.Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
var dbPath = _ctxOptions.GetFullyQualifiedDatabasePath();
var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
if (_fileSystem.Directory.Exists(dbDirectory) is false)
if (Directory.Exists(dbDirectory) is false)
{
_fileSystem.Directory.CreateDirectory(dbDirectory);
Directory.CreateDirectory(dbDirectory);
}
var connectionString = $"{DataSourceKey}{dbPath}";
@@ -55,145 +45,24 @@ public sealed class AppDbContext(
modelBuilder.Entity<User>(static eb =>
{
eb.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.Property(static u => u.HashedPassword);
eb.HasMany(static u => u.RefreshTokens)
.WithOne(static t => t.User)
.HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasMany(static u => u.Institutions)
.WithOne(static i => i.User)
.HasForeignKey(static i => i.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.HasMany(static u => u.Accounts)
.WithOne()
.HasForeignKey(static a => a.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.HashedPassword);
});
modelBuilder.Entity<RefreshToken>(static eb =>
{
eb.Property(static t => t.Id);
eb.Property(static t => t.ExpiresAt);
eb.Property(static t => t.UserId);
eb.Property(static t => t.Token);
eb.HasIndex(static t => t.Token).IsUnique();
});
modelBuilder.Entity<Institution>(static eb =>
{
eb.Property(static i => i.Name);
eb.HasOne(static i => i.Metadata)
.WithOne()
.HasForeignKey<InstitutionMetadata>(static m => m.InstitutionId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<InstitutionMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue<PlaidInstitutionMetadata>(PlaidInstitutionMetadata.TypeValue);
eb.Property(static m => m.InstitutionId);
eb.Property(static m => m.Type);
});
modelBuilder.Entity<PlaidInstitutionMetadata>(static eb =>
{
eb.HasBaseType<InstitutionMetadata>();
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
eb.Property(static m => m.EncryptedAccessToken);
eb.Property(static m => m.ItemId);
});
modelBuilder.Entity<Account>(static eb =>
{
eb.Property(static a => a.Name);
eb.HasOne(static a => a.Institution)
.WithMany(static i => i.Accounts)
.HasForeignKey(static a => a.InstitutionId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasOne(static a => a.Metadata)
.WithOne()
.HasForeignKey<AccountMetadata>(static m => m.AccountId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasMany(static a => a.Balances)
.WithOne()
.HasForeignKey(static b => b.AccountId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<AccountMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue<PlaidAccountMetadata>(PlaidAccountMetadata.TypeValue);
eb.Property(static m => m.AccountId);
eb.Property(static m => m.Type);
});
modelBuilder.Entity<PlaidAccountMetadata>(static eb =>
{
eb.HasBaseType<AccountMetadata>();
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
eb.Property(static m => m.Cursor);
});
modelBuilder.Entity<Balance>(static eb =>
{
eb.Property(static b => b.AccountId);
eb.Property(static b => b.Current);
eb.Property(static b => b.Available);
eb.Property(static b => b.CurrencyCode);
});
modelBuilder.Entity<Transaction>(static eb =>
{
eb.HasOne(static t => t.User)
.WithMany(static u => u.Transactions)
.HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasOne(static t => t.Account)
.WithMany(static a => a.Transactions)
.HasForeignKey(static t => t.AccountId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static t => t.MerchantName);
eb.Property(static t => t.Description);
eb.Property(static t => t.Amount);
eb.Property(static t => t.Date);
});
modelBuilder.Entity<TransactionMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue(PlaidTransactionMetadata.TypeValue);
eb.Property(static m => m.TransactionId);
eb.Property(static m => m.Type);
});
modelBuilder.Entity<PlaidTransactionMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue(PlaidTransactionMetadata.TypeValue);
eb.Property(static m => m.PlaidId);
});
}
}
@@ -3,6 +3,11 @@ namespace FiscalOS.Infra.Data;
public sealed record AppDbContextOptions
{
public string DatabaseFilePath { get; init; } = string.Empty;
public string GetFullyQualifiedDatabasePath()
{
return Path.GetFullPath(DatabaseFilePath, AppContext.BaseDirectory);
}
}
public sealed record AppDbContextOptionsSetup : IConfigureOptions<AppDbContextOptions>
@@ -1,3 +1,5 @@
using FiscalOS.Core.Data;
namespace FiscalOS.Infra.Data;
internal sealed class TimestampInterceptor : SaveChangesInterceptor
@@ -1,61 +1,16 @@
using Microsoft.AspNetCore.Hosting;
namespace FiscalOS.Infra.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
services.AddHttpClient();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IFileSystem, FileSystem>();
services.ConfigureOptions<JwtOptionsSetup>();
services.ConfigureOptions<JwtBearerOptionsSetup>();
services.AddSingleton<ITokenGenerator>(TokenGenerator.From);
services.AddSingleton<IPasswordHasher>(PasswordHasher.From);
services.ConfigureOptions<FileKeyRingOptionsSetup>();
services.AddSingleton<IKeyRing>(FileKeyRing.From);
services.AddSingleton<IEncryptor>(Encryptor.From);
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
services.ConfigureOptions<AppDbContextOptionsSetup>();
services.AddDbContext<AppDbContext>();
services.AddHostedService<MigrationService>();
services.ConfigureOptions<PlaidClientOptionsSetup>();
services.AddSingleton(static sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
var options = clientOptions.Value.ToPlaidOptions();
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);
services.AddSingleton<IPlaidTransactionService>(PlaidTransactionService.From);
services.AddScoped<IPlaidAddedTransactionHandler>(PlaidAddedTransactionHandler.From);
services.AddScoped<IPlaidModifiedTransactionHandler>(PlaidModifiedTransactionHandler.From);
services.AddScoped<IPlaidRemovedTransactionHandler>(PlaidRemovedTransactionHandler.From);
services.AddScoped<IPlaidTransactionProcessor>(PlaidTransactionProcessor.From);
services.AddScoped<IPlaidTransactionSyncer>(PlaidTransactionSyncer.From);
services.AddSingleton<IAsyncQueue<SyncUpdatesQueueItem>>(ChannelAsyncQueue<SyncUpdatesQueueItem>.From);
services.AddScoped<IAsyncQueueProcessor<SyncUpdatesQueueItem>>(SyncUpdatesProcessor.From);
services.AddHostedService(AsyncQueueHostedService<SyncUpdatesQueueItem>.From);
return services;
}
}
-2
View File
@@ -1,11 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Going.Plaid" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
</ItemGroup>
<ItemGroup>
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

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