Compare commits

..
Author SHA1 Message Date
Stevan Freeborn 8b779c1841 feat(infra): implement delete functionality for transactions with UI integration 2026-03-12 17:44:48 -05:00
Stevan Freeborn e47e08b9f0 feat(infra): add delete endpoint for transactions with corresponding integration tests 2026-03-12 17:29:41 -05:00
Stevan Freeborn f0c63c7ded fix(infra): simplify transaction processing validation by removing removed transactions check 2026-03-12 17:03:07 -05:00
Stevan Freeborn 66f4cd2959 fix(infra): add logging to PlaidTransactionProcessor for transaction sync processing 2026-03-12 16:42:44 -05:00
Stevan Freeborn 6a21e92aa6 fix(infra): include Metadata in transaction queries for added, modified, and removed transactions 2026-03-12 16:42:39 -05:00
Stevan Freeborn 88e8cfbf40 fix(infra): update PlaidClientOptions to retrieve environment variable for production check 2026-03-11 20:09:41 -05:00
Stevan Freeborn 559908a150 fix(infra): update transaction amount handling to use CanonicalAmount and ensure negative values in transaction tests 2026-03-11 20:01:23 -05:00
Stevan Freeborn e3acb2166d fix(api): update PlaidClientOptions to set environment to Production 2026-03-11 19:46:47 -05:00
Stevan Freeborn e6f70f4ab4 fix(api): update PlaidClientOptions to set environment to Development in production 2026-03-11 19:41:49 -05:00
Stevan Freeborn 6c8ee3e203 feat(api): enhance PlaidException for detailed error logging and improve GlobalExceptionHandler for specific Plaid errors 2026-03-11 19:28:15 -05:00
Stevan Freeborn cb7ed41296 fix(api): ensure exception handling middleware is correctly registered 2026-03-11 19:12:19 -05:00
Stevan Freeborn a168d318b2 chore: add SEQ_SERVER_URL and SEQ_API_KEY_HEADER to deployment environment variables 2026-03-11 18:58:39 -05:00
Stevan Freeborn 839d17de0d fix(web): remove disabled state styles for add institution button 2026-03-11 18:53:11 -05:00
Stevan Freeborn 9e1d04da80 feat(web): add styles for add account and institution buttons with disabled state 2026-03-11 18:52:33 -05:00
Stevan Freeborn a721c60ad9 feat(api,infra): implement GlobalExceptionHandler and enhance PlaidException for detailed error handling 2026-03-11 18:52:23 -05:00
Stevan Freeborn 64c64e8d95 feat(servicedefaults): add SeqOptions configuration and integrate with OpenTelemetry logging and tracing 2026-03-11 18:52:05 -05:00
Stevan Freeborn e30f19e939 fix(project): set appsettings.json as optional in Program.cs 2026-03-11 13:12:26 -05:00
Stevan Freeborn d33b1d28dd fix(editorconfig): disable CA1873 diagnostic severity 2026-03-11 12:55:11 -05:00
Stevan Freeborn 36e2f70da2 refactor(docker): remove unnecessary global.json copy from Dockerfile.api 2026-03-11 12:51:53 -05:00
Stevan Freeborn 9572097d38 refactor(docker): streamline Dockerfile by removing redundant .editorconfig copies and simplifying restore commands 2026-03-11 12:49:16 -05:00
Stevan Freeborn ff4d10edcc feat(docker): add .editorconfig files for API and AdminCLI projects 2026-03-11 12:41:54 -05:00
Stevan Freeborn 379f975e7f Merge pull request #17 from StevanFreeborn/stevanfreeborn/feat/display-transactions-in-web
feat: display transactions on the web
2026-03-11 12:32:41 -05:00
Stevan Freeborn 6bf7499981 feat(docker): update web service port mapping and remove api service port configuration 2026-03-11 12:31:24 -05:00
Stevan Freeborn cd69cea2ac refactor: remove Playwright dependencies and configuration, update test cases, and clean up code formatting
- Removed Playwright test dependencies from package.json and deleted playwright.config.ts.
- Updated LoginForm component and its tests for improved readability and consistency.
- Cleaned up App.vue and PublicLayout.vue by adjusting import formatting.
- Fixed missing newlines in CSS and other files for better formatting.
- Updated userStore to ensure proper object syntax.
- Refactored LoginView to enhance code clarity and maintainability.
2026-03-11 12:13:53 -05:00
Stevan Freeborn e3a75ccbc6 feat(api,web): update account and institution handling to use provider identifiers and enhance UI components for account addition 2026-03-11 10:51:28 -05:00
Stevan Freeborn 5eda5fbb76 feat(web): try to make pretty 2026-03-11 05:47:34 -05:00
Stevan Freeborn 4d1c01776f feat(web): enhance transaction display and account management features 2026-03-10 17:46:21 -05:00
Stevan Freeborn 3408f6ea4f feat(api,web): retrieve and display transactions 2026-03-10 07:42:51 -05:00
Stevan Freeborn e2d2058960 chore: update preferences for lambas 2026-03-10 07:42:15 -05:00
Stevan Freeborn 02147cffdb chore: add new test project to solution 2026-03-10 07:42:03 -05:00
Stevan Freeborn e13c83013b refactor(infra.tests): moved builders to common project 2026-03-10 07:41:43 -05:00
Stevan Freeborn c713a5448d tests(tests.common): add class library for sharing code amongst test projects 2026-03-10 07:41:23 -05:00
Stevan Freeborn 091fa6f01c feat: allow users to logout 2026-03-09 05:58:04 -05:00
Stevan Freeborn c8505081a5 chore: enable aspire to run with made mode 2026-03-09 05:58:04 -05:00
Stevan Freeborn 7afe0cf6c1 chore: add coverage directory to .gitignore 2026-03-09 04:29:01 -05:00
Stevan Freeborn 9e656b7745 refactor: remove unused using directive for Going.Plaid.Transactions in PlaidTransactionProcessorTests 2026-03-09 04:29:01 -05:00
Stevan Freeborn 76df36b18f tests(infra.tests): enhance AccountBuilder with metadata handling and expand PlaidTransactionSyncerTests 2026-03-09 04:29:01 -05:00
Stevan Freeborn 98d56fefd7 tests(infra,api): refactor to allow further config of TestApi 2026-03-09 04:29:01 -05:00
Stevan Freeborn df697ed37c tests(infra.tests): added builder classes for creating test entities 2026-03-09 04:29:01 -05:00
Stevan Freeborn 659b2bbf19 tests(infra.tests): wip on builder apis for test data 2026-03-09 04:29:01 -05:00
Stevan Freeborn 0bc810cab6 feat: implement transaction handling and add integration tests for Plaid transaction handlers 2026-03-09 04:29:01 -05:00
Stevan Freeborn 62a7dde443 feat: update account handling in add endpoint and tests 2026-03-09 04:29:01 -05:00
Stevan Freeborn 3866030eae feat: continued wip on supporting transactions 2026-03-09 04:29:01 -05:00
Stevan Freeborn 8d68bfc22a fix(infra): don't advance account cursor unless all transactions proceed successfully 2026-03-09 04:29:01 -05:00
Stevan Freeborn 8222698fa8 refactor(core): make parameters non-nullable 2026-03-09 04:29:01 -05:00
Stevan Freeborn 2a378f098d tests(api.tests): fix methods broken in refactor 2026-03-09 04:29:01 -05:00
Stevan Freeborn 00e54c27c6 feat: rename transaction table to transactions and update related references 2026-03-09 04:29:01 -05:00
Stevan Freeborn a9f1ced0d5 feat: add Plaid transaction handling and async queue processing
- introduced new migration to add Cursor column to AccountMetadata.
- updated AppDbContext model snapshot to reflect changes in the database schema.
- implemented async queue processing with ChannelAsyncQueue and AsyncQueueHostedService.
- created Plaid transaction handlers for added, modified, and removed transactions.
- developed PlaidTransactionService and PlaidTransactionSyncer for syncing transactions.
- added SyncUpdatesProcessor to handle sync updates from Plaid.
- updated tests to accommodate changes in institution and account metadata handling.
2026-03-09 04:29:01 -05:00
Stevan Freeborn 48e95e5242 refactor(api.tests): updated model names 2026-02-24 06:55:37 -06:00
Stevan Freeborn d0b0097032 feat(web): handle adding accounts to institution 2026-02-24 06:55:37 -06:00
Stevan Freeborn 81c45350e8 feat(api): include accounts in institution dto 2026-02-24 06:55:37 -06:00
Stevan Freeborn 2fa34fe5ab feat(infra): updated modeling to reflect new navigation property 2026-02-24 06:55:37 -06:00
Stevan Freeborn 0815620028 feat(core): added navigation property to institution for accounts 2026-02-24 06:55:37 -06:00
Stevan Freeborn d5b6fb3a38 tests(apphost.tests): added support for end to end testing using playwright 2026-02-23 05:56:34 -06:00
Stevan Freeborn fd2cd68d73 feat(web,api): added ability to add and then display list of institutions 2026-02-22 06:53:13 -06:00
Stevan Freeborn b4fbf16869 feat(web): finish implementing login 2026-02-21 07:32:28 -06:00
Stevan Freeborn d9130e75a4 feat(web): make things work
- setup basic login form
- setup basic add institution functionality
2026-02-21 07:32:28 -06:00
Stevan Freeborn e2537d77c4 feat(web): working on login style 2026-02-21 07:32:28 -06:00
Stevan Freeborn fcbc9e78c2 style(web): add fonts 2026-02-21 07:32:28 -06:00
Stevan Freeborn 9042cd4ead feat(web): use user store and auth service to handle logging in and persistenting user state 2026-02-21 07:32:28 -06:00
Stevan Freeborn 1c3de212ff chore(web): configure prettier to my preferences 2026-02-21 07:32:28 -06:00
Stevan Freeborn 72a534a8e0 feat(web): add composable for creating auth service 2026-02-21 07:32:28 -06:00
Stevan Freeborn 74e55b2f48 feat(web): implement user store 2026-02-21 07:32:28 -06:00
Stevan Freeborn a3dc58d19b feat(web): implement auth service 2026-02-21 07:32:28 -06:00
Stevan Freeborn 5a878bf4b3 feat(web): add small http client implementation around fetch 2026-02-21 07:32:28 -06:00
Stevan Freeborn dee331fb0d feat(web): implement initial logic to handle protected vs public routing 2026-02-21 07:32:28 -06:00
Stevan Freeborn df859b91b0 feat: add frontend with aspire integration 2026-02-17 06:19:05 -06:00
Stevan Freeborn d77e5d45f6 feat: aspirify the solution 2026-02-15 08:16:47 -06:00
Stevan Freeborn 2cca5c15a0 chore: run dotnet format 2026-02-15 08:16:47 -06:00
Stevan Freeborn ab88656c1e feat(api): implement endpoint for creating link tokens 2026-02-15 07:02:01 -06:00
Stevan Freeborn 6b2b19cb5b feat(infra): add create link token to plaid service 2026-02-15 07:02:01 -06:00
Stevan Freeborn 9c0f642f90 feat(api): support passing and persistenting currency code for balances 2026-02-14 06:46:05 -06:00
Stevan Freeborn 701b89d704 feat(infra): added modeling and migrations for currency code prop 2026-02-14 06:46:05 -06:00
Stevan Freeborn 49e4288eba feat(core): extend balance model to store currency code 2026-02-14 06:46:05 -06:00
Stevan Freeborn 497dedf6ac feat(api): return balance info to frontend 2026-02-14 06:46:05 -06:00
Stevan Freeborn 29d8758ede feat(infra): add extensions members for plaid accounts 2026-02-14 06:46:05 -06:00
Stevan Freeborn 6190470fe2 feat(api): initialize account with starting balance 2026-02-14 06:46:05 -06:00
Stevan Freeborn ed7e25961c feat(infra): add modeling to dbcontext for balance model with migrations 2026-02-14 06:46:05 -06:00
Stevan Freeborn e331030bb0 feat(core): add balance model 2026-02-14 06:46:05 -06:00
Stevan Freeborn 3b7830b1bc refactor(api): introduce DTOs for institutional account discovery
- Replace raw Plaid entity response with a dedicated Response wrapper
- Map Plaid accounts to AccountDto to decouple API from external SDK
models
- Update integration tests to assert against the new response structure
- Add JsonConstructors to DTO records for reliable deserialization
2026-02-14 06:46:05 -06:00
Stevan Freeborn dad2ceb450 feat(api,core,infra): refactor endpoint routing and add institution account discovery
- Move login and refresh endpoints under a unified /auth route group
- Relocate institution connection logic from /accounts to /institutions
- Implement GET /institutions/{id}/available to fetch real-time Plaid
accounts
- Introduce HttpRequestBuilder utility to streamline integration testing
- Enhance PlaidService with GetAccountsAsync to support account fetching
- Clean up global usings and project structure for better domain
isolation
2026-02-14 06:46:05 -06:00
Stevan Freeborn 32cf48bf36 refactor(infra,api.tests): fix my null lies 2026-02-13 12:38:18 -06:00
Stevan Freeborn 502f6f61c1 feat(api): validate account institution is connected and allow adding new account 2026-02-13 12:38:18 -06:00
Stevan Freeborn 6ca90ed25a refactor(api.tests): rename variable 2026-02-13 12:38:18 -06:00
Stevan Freeborn 5192c06627 refactor(api): use convenience methods 2026-02-13 12:38:18 -06:00
Stevan Freeborn 2fd55a55cd fix(infra): update db model to address property name correction 2026-02-13 12:38:18 -06:00
Stevan Freeborn a2e9fc3f30 fix(core): fix incorrectly named property 2026-02-13 12:38:18 -06:00
Stevan Freeborn a83dbbcc54 feat(api): add validation to prevent duplicate accounts from being added 2026-02-13 12:38:18 -06:00
Stevan Freeborn a07fd24ee9 feat(infra): added plaid specific metadata and updated context to model account 2026-02-13 12:38:18 -06:00
Stevan Freeborn 2fdb849142 feat(core): add models for account 2026-02-13 12:38:18 -06:00
Stevan Freeborn 1a41c3c0b6 feat(api): implement storing connected insitution in db 2026-02-13 04:37:47 -06:00
Stevan Freeborn c4a8aada6b feat(infra): implement plaid service 2026-02-13 04:37:47 -06:00
Stevan Freeborn dd08eee5a3 refactor(infra): remove unneeded qualifiers 2026-02-13 04:37:47 -06:00
Stevan Freeborn 7cdf87b860 tests(api.tests): pass cancellation token through delegate 2026-02-13 04:37:47 -06:00
Stevan Freeborn 8663daabae chore(api.tests): add example test settings file 2026-02-13 04:37:47 -06:00
Stevan Freeborn 0b7c33a024 fix(core): pass name of null param 2026-02-13 04:37:47 -06:00
Stevan Freeborn 823ba4dc5a chore: update example appsettings file 2026-02-13 04:37:47 -06:00
Stevan Freeborn 1d661ac409 feat(api): prevent user from connecting to same institution twice 2026-02-13 04:37:47 -06:00
Stevan Freeborn 069d019d80 feat(infra): update db context with modeling for institution and relationships 2026-02-13 04:37:47 -06:00
Stevan Freeborn 3f62a246ee feat(core): add models for institution 2026-02-13 04:37:47 -06:00
Stevan Freeborn 5da1f1decf refactor(api.tests): check for correct status code first in assertion 2026-02-13 04:37:47 -06:00
Stevan Freeborn 1f5e4a319a fix(api.tests): use a serializable dictionary for xunit test case data 2026-02-13 04:37:47 -06:00
Stevan Freeborn 683ca3fac4 feat(api): add validation for connect account request 2026-02-13 04:37:47 -06:00
Stevan Freeborn e88adfd2a7 refactor(api): derive field name from property being validated 2026-02-13 04:37:47 -06:00
Stevan Freeborn 6fc8bd0366 refactor(api): rename type 2026-02-13 04:37:47 -06:00
Stevan Freeborn ab23593359 refactor(api): remove whitespace 2026-02-13 04:37:47 -06:00
Stevan Freeborn 87a68669cf feat(api): add protected connect account endpoint 2026-02-13 04:37:47 -06:00
Stevan Freeborn b4744076b7 tests: update test ring implementation 2026-02-06 06:53:20 -06:00
Stevan Freeborn 5b1a5f003e tests: add mucho tests 2026-02-06 06:53:20 -06:00
Stevan Freeborn b2086d8185 feat(admincli): add generate key command 2026-02-06 06:53:20 -06:00
Stevan Freeborn 9b4f0c6b9e refactor(api): clean up usings 2026-02-06 06:53:20 -06:00
Stevan Freeborn 259daf1e5b chore: update example settings file 2026-02-06 06:53:20 -06:00
Stevan Freeborn e02a18df00 chore: added filesystem abstraction package 2026-02-06 06:53:20 -06:00
Stevan Freeborn c1932246f3 feat(infra): create initial encryption implementations 2026-02-06 06:53:20 -06:00
Stevan Freeborn 4eb833d2c8 chore: add services registrations 2026-02-06 06:53:20 -06:00
Stevan Freeborn af29df5d19 refactor(infra): move database path logic into app context 2026-02-06 06:53:20 -06:00
Stevan Freeborn 04e0a31b27 chore: remove unnecessary using 2026-02-06 06:53:20 -06:00
Stevan Freeborn 97c7847a89 chore: removed unnecessary usings 2026-02-06 06:53:20 -06:00
Stevan Freeborn f34fb5ce41 feat(core): define primary models and services for encryption 2026-02-06 06:53:20 -06:00
Stevan Freeborn 38c311bdd1 feat(core): extend user model with encryption related properties 2026-02-06 06:53:20 -06:00
Stevan Freeborn e9b1bd11bb feat(infra): added migrations for adding properties to user model 2026-02-06 06:53:20 -06:00
Stevan Freeborn bbe3779a53 tests: add remaining register integration tests 2026-02-03 21:04:25 -06:00
Stevan Freeborn 47692fc946 tests: add test for calling refresh with no access token 2026-02-03 21:04:25 -06:00
Stevan Freeborn 453aa3264d tests: check for proper cookie being set in response 2026-02-03 21:04:25 -06:00
Stevan Freeborn 21d23be2ef fix: remove unnecessary using 2026-02-03 21:04:25 -06:00
Stevan Freeborn 73598111b2 feat: enable problem details responses 2026-02-03 21:04:25 -06:00
Stevan Freeborn 1b89056258 tests: add jwt token builder for testing protected endpoints 2026-02-03 21:04:25 -06:00
Stevan Freeborn f12788fa37 fix: use key property 2026-02-03 21:04:25 -06:00
Stevan Freeborn 57c7a3a701 chore: configure auth schemes and add refresh token revocation migration 2026-02-03 21:04:25 -06:00
Stevan Freeborn 92b3c8e9c4 feat(api): add refresh endpoint and update global usings 2026-02-03 21:04:25 -06:00
Stevan Freeborn e12fa0111a feat(api): refactor login logic to use domain methods and cookie helpers 2026-02-03 21:04:25 -06:00
Stevan Freeborn ce5fd1fd45 refactor(core): enhance User and RefreshToken domain models 2026-02-03 21:04:25 -06:00
Stevan Freeborn 3fbd9402c3 chore: remove OpenApi and configure JWT authentication schemes 2026-02-03 21:04:25 -06:00
Stevan Freeborn a3264bd72d fix: add user id prop to refresh token 2026-02-03 21:04:25 -06:00
Stevan Freeborn e9f133bf43 fix: don't new up user for user prop 2026-02-03 21:04:25 -06:00
Stevan Freeborn 18bd510e75 chore: run dotnet format 2026-02-03 04:38:40 -06:00
Stevan Freeborn d9f0585c4c feat(infra): add indexes with uniqueness 2026-02-03 04:38:40 -06:00
Stevan Freeborn fcc1356d69 feat(admincli): add command for creating a user 2026-02-03 04:38:40 -06:00
Stevan Freeborn 0ce97cdec8 tests(api): add integration test for happy path when logging in 2026-02-03 04:38:40 -06:00
Stevan Freeborn 98de115259 feat(core): add password hashing and token generation interfaces 2026-02-03 04:38:40 -06:00
Stevan Freeborn 2a1a03ef6c test: update infra tests for new authentication namespaces 2026-02-03 04:38:40 -06:00
Stevan Freeborn a7eafa4966 test: add moq and code coverage support to test suite 2026-02-03 04:38:40 -06:00
Stevan Freeborn b05cc06dff feat(api): integrate abstracted token generation and update login endpoint 2026-02-03 04:38:40 -06:00
Stevan Freeborn a1b993bd62 feat(infra): move authentication implementations and add ITokenGenerator 2026-02-03 04:38:40 -06:00
Stevan Freeborn c4d0bf78ab refactor(core): encapsulate user and refresh token creation with factory methods 2026-02-03 04:38:40 -06:00
Stevan Freeborn e03cc0403a chore: add todo with psuedo code 2026-02-03 04:38:40 -06:00
Stevan Freeborn 1335f62ec3 feat(infra): update refresh token model 2026-02-03 04:38:40 -06:00
Stevan Freeborn eb2d9fb2d0 chore: add example jwt options in example settings file 2026-02-03 04:38:40 -06:00
Stevan Freeborn 36b293aa15 feat: implement token generation and issuing on successful login 2026-02-03 04:38:40 -06:00
Stevan Freeborn 38a03f56ca tests: refactor test suite to match new project structure 2026-02-03 04:38:40 -06:00
Stevan Freeborn c2b681d267 feat(api): integrate infra and implement password verification on login 2026-02-03 04:38:40 -06:00
Stevan Freeborn 91662f5629 feat: add timestamp interceptor and infrastructure service registration 2026-02-03 04:38:40 -06:00
Stevan Freeborn c79136fb26 refactor(infra): migrate data access and identity impelementations to infrastructure 2026-02-03 04:38:40 -06:00
Stevan Freeborn 7e67e02a68 refactor(core): move identity models from API to Core 2026-02-03 04:38:40 -06:00
Stevan Freeborn e66911810f feat(core): add base entity and password hashing abstractions 2026-02-03 04:38:40 -06:00
Stevan Freeborn 12ae1dc33a feat: initialize core, infra, and admin cli projects 2026-02-03 04:38:40 -06:00
Stevan Freeborn 647b185d40 feat: implement password hasher 2026-02-03 04:38:40 -06:00
Stevan Freeborn 00d7c977e7 chore: move editorconfig to correct location 2026-02-03 04:38:40 -06:00
Stevan Freeborn ccda58be07 tests: fix integration test setup 2026-02-03 04:38:40 -06:00
Stevan Freeborn 083b587493 fix: remove fake auth check 2026-02-03 04:38:40 -06:00
Stevan Freeborn c037b895f0 feat: wip on testing login flows 2026-02-03 04:38:40 -06:00
Stevan Freeborn 28e4692ca1 feat: setup db context with initial models 2026-02-03 04:38:40 -06:00
Stevan Freeborn 1f84f47f31 feat: add simple check for username and password 2026-02-03 04:38:40 -06:00
Stevan Freeborn 80b4e775d6 chore: add example appsettings file 2026-02-03 04:38:40 -06:00
Stevan Freeborn 463ff05831 refactor: use ivalidatableobject api 2026-02-03 04:38:40 -06:00
Stevan Freeborn c5a429683c chore: update editorconfig settings 2026-02-03 04:38:40 -06:00
Stevan Freeborn 42e590d9e6 feat: wip on login endpoint
- added basic REPR pattern
- added basic vert architecture
- implemented basica validation for single test case
2026-02-03 04:38:40 -06:00
270 changed files with 71341 additions and 317 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj",
"defaultWatchEnabled": "true"
}
+11
View File
@@ -0,0 +1,11 @@
.git/
.vscode/
.aspire/
Coverage/
tests/
docker/data/
docker/keys/
**/bin/
**/obj/
**/node_modules/
**/.env
+2 -1
View File
@@ -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 = true: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
+100
View File
@@ -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
+7
View File
@@ -3,10 +3,17 @@
##
## 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
@@ -0,0 +1,11 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "aspire",
"request": "launch",
"name": "Aspire: Launch default apphost",
"program": "${workspaceFolder}"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"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
@@ -1,116 +0,0 @@
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
@@ -0,0 +1,22 @@
<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
@@ -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"]
+13
View File
@@ -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
+30
View File
@@ -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
+36
View File
@@ -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
+18
View File
@@ -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;
}
}
+4
View File
@@ -1,4 +1,8 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestPatch"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
dotnet_code_quality.ca1711.allowed_suffixes = Queue
dotnet_diagnostic.CA1031.severity = none
+11 -2
View File
@@ -3,8 +3,9 @@
<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>
@@ -12,5 +13,13 @@
<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>
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,23 @@
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;
}
}
@@ -1,17 +1,17 @@
namespace FiscalOS.API.Login;
namespace FiscalOS.API.Auth.Login;
internal static class Endpoint
{
private const string Route = "/login";
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder)
{
return app.MapPost(Route, HandleAsync);
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] LoginRequest loginRequest,
[FromBody] Request loginRequest,
[FromServices] AppDbContext appDbContext,
[FromServices] IPasswordHasher passwordHasher,
[FromServices] ITokenGenerator tokenService
@@ -32,22 +32,13 @@ internal static class Endpoint
}
var accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user);
await appDbContext.RefreshTokens.AddAsync(refreshToken);
user.AddRefreshToken(refreshToken);
await appDbContext.SaveChangesAsync();
httpContext.Response.Cookies.Append(
"fiscalos_refresh_cookie",
refreshToken.Token,
new CookieOptions
{
HttpOnly = true,
Expires = refreshToken.ExpiresAt,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
httpContext.SetRefreshTokenCookie(refreshToken);
return Results.Ok(Response.From(accessToken));
}
@@ -1,7 +1,6 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
public record LoginRequest : IValidatableObject
public record Request : IValidatableObject
{
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
@@ -10,12 +9,14 @@ public record LoginRequest : IValidatableObject
{
if (string.IsNullOrWhiteSpace(Username))
{
yield return new("The Username field is required.", [nameof(Username)]);
var fieldName = nameof(Username);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(Password))
{
yield return new("The Password field is required.", [nameof(Password)]);
var fieldName = nameof(Password);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
}
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Login;
namespace FiscalOS.API.Auth.Login;
internal sealed record Response
{
+40
View File
@@ -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();
}
}
+60
View File
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,22 @@
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);
}
}
@@ -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
}
});
}
}
+36
View File
@@ -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;
}
}
+34
View File
@@ -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
};
}
}
+1 -1
View File
@@ -1,7 +1,6 @@
<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>
@@ -11,6 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.ServiceDefaults\FiscalOS.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
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;
}
}
@@ -0,0 +1,58 @@
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();
}
}
@@ -0,0 +1,22 @@
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]);
}
}
}
@@ -0,0 +1,36 @@
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));
}
}
@@ -0,0 +1,68 @@
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),
};
}
}
@@ -0,0 +1,51 @@
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));
}
}
@@ -0,0 +1,50 @@
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,
};
}
}
@@ -0,0 +1,24 @@
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;
}
}
@@ -0,0 +1,31 @@
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));
}
}
@@ -0,0 +1,19 @@
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,
};
}
}
+39 -7
View File
@@ -1,19 +1,51 @@
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddValidation();
builder.Services.AddOpenApi();
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.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();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePages();
app.UseExceptionHandler();
app.MapLoginEndpoint();
app.MapDefaultEndpoints();
app.MapAuthEndpoints();
app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints();
app.MapTransactionsGroup();
app.Run();
@@ -18,6 +18,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
}
}
}
@@ -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,62 @@
namespace FiscalOS.API.Transactions.FireWebhook;
internal static class Endpoint
{
private const string Route = "/fire-webhook";
public static RouteHandlerBuilder MapFireWebhookEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] IEncryptor encryptor,
[FromServices] PlaidClient plaidClient,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Accounts.Where(a => a.Id.ToString() == request.AccountId))
.ThenInclude(static a => a.Institution!)
.ThenInclude(static i => i.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Unauthorized();
}
if (user.Accounts.Any() is false)
{
return Results.NotFound();
}
var plaidMetadata = (PlaidInstitutionMetadata?)user.Accounts.First().Institution?.Metadata;
if (plaidMetadata is null)
{
return Results.InternalServerError("No metadata associated with account");
}
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
var fireEventResponse = await plaidClient.SandboxItemFireWebhookAsync(new()
{
AccessToken = accessToken,
WebhookType = SandboxItemFireWebhookRequestWebhookTypeEnum.Transactions,
WebhookCode = SandboxItemFireWebhookRequestWebhookCodeEnum.SyncUpdatesAvailable,
});
if (fireEventResponse.IsSuccessStatusCode is false)
{
return Results.InternalServerError(fireEventResponse.Error?.ErrorMessage);
}
return Results.Ok(new { fireEventResponse.RequestId, fireEventResponse.WebhookFired });
}
}
@@ -0,0 +1,7 @@
namespace FiscalOS.API.Transactions.FireWebhook;
public sealed record Request(
string AccountId,
string WebhookType,
string WebhookCode
);
@@ -0,0 +1,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
};
}
}
@@ -0,0 +1,28 @@
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;
}
}
@@ -0,0 +1,41 @@
namespace FiscalOS.API.Transactions.Webhook;
internal static class Endpoint
{
private const string Route = "/webhook";
public static RouteHandlerBuilder MapWebhookEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
[FromBody] WebhookBase request,
[FromServices] ILogger<Program> logger,
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> queue,
CancellationToken ct
)
{
logger.LogInformation(
"Received {WebhookType} webhook with {WebhookCode} payload",
request.WebhookType,
request.WebhookCode
);
switch (request)
{
case SyncUpdatesAvailableWebhook webhook:
await queue.EnqueueAsync(new(webhook.ItemId), ct);
logger.LogInformation(
"Enqueued {WebhookType} webhook with {WebhookCode} payload",
request.WebhookType,
request.WebhookCode
);
break;
default:
break;
}
return Results.Ok();
}
}
+19 -1
View File
@@ -1,10 +1,28 @@
global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Login;
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.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;
+15 -1
View File
@@ -14,5 +14,19 @@
"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"
}
}
}
+56 -2
View File
@@ -3,7 +3,9 @@ namespace FiscalOS.AdminCLI;
internal sealed class App(
IAnsiConsole console,
IPasswordHasher passwordHasher,
IServiceScopeFactory serviceScopeFactory
IServiceScopeFactory serviceScopeFactory,
IEncryptor encryptor,
IKeyRing keyRing
) : IHostedService
{
private readonly IAnsiConsole _console = console;
@@ -32,13 +34,65 @@ internal sealed class App(
);
var hashedPassword = passwordHasher.Hash(password);
var user = User.From(username, hashedPassword);
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(cancellationToken);
var user = User.From(username, hashedPassword, userEncryptionKey);
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;
+8 -1
View File
@@ -3,7 +3,14 @@ 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, Exit];
public static readonly string[] All = [
CreateUser,
GenerateKey,
RemoveInstitution,
Exit
];
}
@@ -2,6 +2,7 @@
<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")
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
)
.ConfigureLogging(static c => c.ClearProviders())
+5
View File
@@ -1,9 +1,14 @@
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,5 +1,9 @@
{
"AppDbContextOptions": {
"DatabaseFilePath": "DatabaseFilePath"
},
"FileKeyRingOptions": {
"KeysDirectoryPath": "KeysDirectoryPath",
"PrimaryKeyId": "PrimaryKeyId"
}
}
+12
View File
@@ -0,0 +1,12 @@
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();
@@ -0,0 +1,19 @@
<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
@@ -0,0 +1,7 @@
namespace FiscalOS.AppHost;
public static class ProjectNames
{
public const string API = "api";
public const string Web = "web";
}
@@ -0,0 +1,31 @@
{
"$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
@@ -0,0 +1,46 @@
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);
}
}
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,35 @@
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);
}
}
@@ -0,0 +1,15 @@
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;
}
}
+25 -1
View File
@@ -5,7 +5,8 @@ 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; } = User.New();
public User? User { get; init; }
public bool Revoked { get; set; }
private RefreshToken()
{
@@ -20,4 +21,27 @@ 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;
}
}
+48 -2
View File
@@ -4,8 +4,20 @@ 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;
public ICollection<RefreshToken> RefreshTokens { get; init; } = [];
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;
private User()
{
@@ -16,12 +28,46 @@ public sealed class User : Entity
return new();
}
public static User From(string username, string hashedPassword)
public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey)
{
ArgumentNullException.ThrowIfNull(username, nameof(username));
ArgumentNullException.ThrowIfNull(hashedPassword, nameof(hashedPassword));
ArgumentNullException.ThrowIfNull(encryptedDataKey, nameof(encryptedDataKey));
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
@@ -0,0 +1,8 @@
namespace FiscalOS.Core.Queuing;
public interface IAsyncQueue<T>
{
Task EnqueueAsync(T item, CancellationToken cancellationToken = default);
Task<T> DequeueAsync(CancellationToken cancellationToken = default);
ValueTask DisposeAsync();
}
@@ -0,0 +1,6 @@
namespace FiscalOS.Core.Queuing;
public interface IAsyncQueueProcessor<T>
{
Task ProcessAsync(T item, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,8 @@
namespace FiscalOS.Core.Security;
public interface IKeyRing
{
KeyRingEntry GetKey(string keyId);
KeyRingEntry GetPrimaryKey();
Task<KeyRingEntry> SaveKeyAsync(string key);
}
@@ -0,0 +1,21 @@
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);
}
}
@@ -0,0 +1,82 @@
namespace FiscalOS.Core.Transactions;
public sealed class Transaction : Entity
{
public Guid UserId { get; private set; }
public User? User { get; init; }
public Guid AccountId { get; init; }
public Account? Account { get; init; }
public string MerchantName { get; private set; } = string.Empty;
public string Description { get; init; } = string.Empty;
public decimal Amount { get; init; }
public DateTimeOffset Date { get; init; }
public TransactionMetadata? Metadata { get; init; }
private Transaction()
{
}
public static Transaction From(
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, Guid.Empty, Guid.Empty, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid userId,
Guid accountId,
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, userId, accountId, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid id,
Guid userId,
Guid accountId,
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
ArgumentNullException.ThrowIfNull(merchantName, nameof(merchantName));
ArgumentNullException.ThrowIfNull(description, nameof(description));
if (amount.HasValue is false)
{
throw new ArgumentNullException(nameof(amount));
}
if (date.HasValue is false)
{
throw new ArgumentNullException(nameof(date));
}
return new()
{
Id = id,
UserId = userId,
AccountId = accountId,
MerchantName = merchantName,
Description = description,
Amount = amount.Value,
Date = date.Value,
Metadata = metadata,
};
}
}
@@ -0,0 +1,15 @@
namespace FiscalOS.Core.Transactions;
public abstract class TransactionMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid TransactionId { get; init; }
protected TransactionMetadata(string type)
{
_type = type;
}
}
+4 -2
View File
@@ -1,3 +1,5 @@
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
+1
View File
@@ -1,3 +1,4 @@
[*.cs]
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA5404.severity = none
@@ -0,0 +1,32 @@
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;
}
}
@@ -0,0 +1,119 @@
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;
}
}
@@ -0,0 +1,33 @@
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);
}
}
@@ -0,0 +1,15 @@
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();
}
}
@@ -0,0 +1,35 @@
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,6 +6,8 @@ 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>
@@ -22,4 +24,33 @@ 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);
}
}
@@ -0,0 +1,7 @@
namespace FiscalOS.Infra.Authentication;
public static class Schemes
{
public const string Default = JwtBearerDefaults.AuthenticationScheme;
public const string AllowExpiredTokens = "AllowExpiredTokens";
}
@@ -33,7 +33,6 @@ 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 = [
@@ -49,7 +48,7 @@ public sealed class TokenGenerator : ITokenGenerator
Issuer = _jwtOptions.Value.Issuer,
Audience = _jwtOptions.Value.Audience,
SigningCredentials = new(
new SymmetricSecurityKey(secretKeyBytes),
_jwtOptions.Value.Key,
SecurityAlgorithms.HmacSha256Signature
),
};
@@ -0,0 +1,41 @@
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);
}
}
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,6 @@
namespace FiscalOS.Infra.Common;
public static class Providers
{
public const string Plaid = nameof(Plaid);
}
+139 -8
View File
@@ -1,21 +1,31 @@
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) : DbContext
public sealed class AppDbContext(
IOptions<AppDbContextOptions> ctxOptions,
IFileSystem fileSystem
) : 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 = _ctxOptions.GetFullyQualifiedDatabasePath();
var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
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.");
if (Directory.Exists(dbDirectory) is false)
if (_fileSystem.Directory.Exists(dbDirectory) is false)
{
Directory.CreateDirectory(dbDirectory);
_fileSystem.Directory.CreateDirectory(dbDirectory);
}
var connectionString = $"{DataSourceKey}{dbPath}";
@@ -45,24 +55,145 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
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.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.HasMany(static u => u.Institutions)
.WithOne(static i => i.User)
.HasForeignKey(static i => i.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.HashedPassword);
eb.HasMany(static u => u.Accounts)
.WithOne()
.HasForeignKey(static a => a.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
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,11 +3,6 @@ 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,5 +1,3 @@
using FiscalOS.Core.Data;
namespace FiscalOS.Infra.Data;
internal sealed class TimestampInterceptor : SaveChangesInterceptor
@@ -1,16 +1,61 @@
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.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
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.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,9 +1,11 @@
<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,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

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