├── .dockerignore
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ └── bug_report.md
└── workflows
│ ├── docker-image-ci-build.yml
│ ├── docker-image-pr-build.yml
│ └── run-tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── client
├── .docker
│ └── nginx
│ │ ├── conf
│ │ ├── default.conf.template
│ │ └── nginx-substitute.sh
│ │ └── init-scripts
│ │ └── 100-init-project-env-variables.sh
├── .gitignore
├── .yarnrc.yml
├── Dockerfile
├── README.md
├── eslint.config.js
├── index.html
├── package.json
├── postcss.config.cjs
├── public
│ └── budgetBoardIcon.svg
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── app
│ │ ├── Authorized
│ │ │ ├── Authorized.module.css
│ │ │ ├── Authorized.tsx
│ │ │ ├── Header
│ │ │ │ ├── Header.module.css
│ │ │ │ ├── Header.tsx
│ │ │ │ └── SyncButton
│ │ │ │ │ └── SyncButton.tsx
│ │ │ ├── Navbar
│ │ │ │ ├── Navbar.module.css
│ │ │ │ ├── Navbar.tsx
│ │ │ │ └── NavbarLink.tsx
│ │ │ └── PageContent
│ │ │ │ ├── Budgets
│ │ │ │ ├── Budgets.module.css
│ │ │ │ ├── Budgets.tsx
│ │ │ │ ├── BudgetsContent
│ │ │ │ │ ├── BudgetGroupHeader
│ │ │ │ │ │ ├── BudgetsGroupHeader.module.css
│ │ │ │ │ │ └── BudgetsGroupHeader.tsx
│ │ │ │ │ ├── BudgetTotalCard
│ │ │ │ │ │ ├── BudgetTotalCard.module.css
│ │ │ │ │ │ ├── BudgetTotalCard.tsx
│ │ │ │ │ │ └── BudgetTotalItem
│ │ │ │ │ │ │ ├── BudgetTotalItem.module.css
│ │ │ │ │ │ │ └── BudgetTotalItem.tsx
│ │ │ │ │ ├── BudgetsContent.tsx
│ │ │ │ │ ├── BudgetsGroup
│ │ │ │ │ │ ├── BudgetParentCard
│ │ │ │ │ │ │ ├── BudgetChildCard
│ │ │ │ │ │ │ │ ├── BudgetChildCard.module.css
│ │ │ │ │ │ │ │ └── BudgetChildCard.tsx
│ │ │ │ │ │ │ ├── BudgetParentCard.module.css
│ │ │ │ │ │ │ ├── BudgetParentCard.tsx
│ │ │ │ │ │ │ └── UnbudgetChildCard
│ │ │ │ │ │ │ │ ├── UnbudgetChildCard.module.css
│ │ │ │ │ │ │ │ └── UnbudgetChildCard.tsx
│ │ │ │ │ │ ├── BudgetsGroup.module.css
│ │ │ │ │ │ └── BudgetsGroup.tsx
│ │ │ │ │ ├── FixParentBudgetButton
│ │ │ │ │ │ └── FixParentBudgetButton.tsx
│ │ │ │ │ └── UnbudgetedGroup
│ │ │ │ │ │ ├── UnbudgetedCard
│ │ │ │ │ │ ├── UnbudgetedCard.module.css
│ │ │ │ │ │ ├── UnbudgetedCard.tsx
│ │ │ │ │ │ └── UnbudgetedChildCard
│ │ │ │ │ │ │ ├── UnbudgetedChildCard.module.css
│ │ │ │ │ │ │ └── UnbudgetedChildCard.tsx
│ │ │ │ │ │ ├── UnbudgetedGroup.module.css
│ │ │ │ │ │ └── UnbudgetedGroup.tsx
│ │ │ │ └── BudgetsToolbar
│ │ │ │ │ ├── AddBudget
│ │ │ │ │ ├── AddBudget.module.css
│ │ │ │ │ └── AddBudget.tsx
│ │ │ │ │ └── BudgetsToolbar.tsx
│ │ │ │ ├── Dashboard
│ │ │ │ ├── AccountsCard
│ │ │ │ │ ├── AccountsCard.module.css
│ │ │ │ │ ├── AccountsCard.tsx
│ │ │ │ │ ├── AccountsSettings
│ │ │ │ │ │ ├── AccountsSettings.module.css
│ │ │ │ │ │ ├── AccountsSettings.tsx
│ │ │ │ │ │ ├── AccountsSettingsModal
│ │ │ │ │ │ │ ├── AccountsSettingsModal.module.css
│ │ │ │ │ │ │ ├── AccountsSettingsModal.tsx
│ │ │ │ │ │ │ ├── DeletedAccounts
│ │ │ │ │ │ │ │ ├── DeletedAccountCard
│ │ │ │ │ │ │ │ │ ├── DeletedAccountCard.module.css
│ │ │ │ │ │ │ │ │ └── DeletedAccountCard.tsx
│ │ │ │ │ │ │ │ └── DeletedAccounts.tsx
│ │ │ │ │ │ │ └── InstitutionSettingsCard
│ │ │ │ │ │ │ │ ├── AccountSettingsCard
│ │ │ │ │ │ │ │ ├── AccountSettingsCard.tsx
│ │ │ │ │ │ │ │ └── DeleteAccountPopover
│ │ │ │ │ │ │ │ │ └── DeleteAccountPopover.tsx
│ │ │ │ │ │ │ │ ├── InstitutionSettingsCard.module.css
│ │ │ │ │ │ │ │ └── InstitutionSettingsCard.tsx
│ │ │ │ │ │ └── CreateAccountModal
│ │ │ │ │ │ │ └── CreateAccountModal.tsx
│ │ │ │ │ └── InstitutionItems
│ │ │ │ │ │ ├── AccountItem
│ │ │ │ │ │ └── AccountItem.tsx
│ │ │ │ │ │ ├── InstitutionItem.module.css
│ │ │ │ │ │ └── InstitutionItem.tsx
│ │ │ │ ├── Dashboard.module.css
│ │ │ │ ├── Dashboard.tsx
│ │ │ │ ├── Footer
│ │ │ │ │ ├── Footer.module.css
│ │ │ │ │ └── Footer.tsx
│ │ │ │ ├── NetWorthCard
│ │ │ │ │ ├── NetWorthCard.module.css
│ │ │ │ │ ├── NetWorthCard.tsx
│ │ │ │ │ └── NetWorthItem
│ │ │ │ │ │ ├── NetWorthItem.module.css
│ │ │ │ │ │ └── NetWorthItem.tsx
│ │ │ │ ├── SpendingTrendsCard
│ │ │ │ │ ├── SpendingTrendsCard.module.css
│ │ │ │ │ └── SpendingTrendsCard.tsx
│ │ │ │ └── UncategorizedTransactionsCard
│ │ │ │ │ ├── UncategorizedTransaction
│ │ │ │ │ ├── UncategorizedTransaction.module.css
│ │ │ │ │ └── UncategorizedTransaction.tsx
│ │ │ │ │ ├── UncategorizedTransactionsCard.module.css
│ │ │ │ │ └── UncategorizedTransactionsCard.tsx
│ │ │ │ ├── Goals
│ │ │ │ ├── CompletedGoalsAccordion
│ │ │ │ │ ├── CompletedGoalCard
│ │ │ │ │ │ └── CompletedGoalCard.tsx
│ │ │ │ │ └── CompletedGoalsAccordion.tsx
│ │ │ │ ├── GoalCard
│ │ │ │ │ ├── EditableGoalMonthlyAmountCell
│ │ │ │ │ │ ├── EditableGoalMonthlyAmountCell.module.css
│ │ │ │ │ │ └── EditableGoalMonthlyAmountCell.tsx
│ │ │ │ │ ├── EditableGoalNameCell
│ │ │ │ │ │ ├── EditableGoalNameCell.module.css
│ │ │ │ │ │ └── EditableGoalNameCell.tsx
│ │ │ │ │ ├── EditableGoalTargetAmountCell
│ │ │ │ │ │ ├── EditableGoalTargetAmountCell.module.css
│ │ │ │ │ │ └── EditableGoalTargetAmountCell.tsx
│ │ │ │ │ ├── EditableGoalTargetDateCell
│ │ │ │ │ │ ├── EditableGoalTargetDateCell.module.css
│ │ │ │ │ │ └── EditableGoalTargetDateCell.tsx
│ │ │ │ │ ├── GoalCard.module.css
│ │ │ │ │ ├── GoalCard.tsx
│ │ │ │ │ └── GoalDetails
│ │ │ │ │ │ └── GoalDetails.tsx
│ │ │ │ ├── Goals.module.css
│ │ │ │ ├── Goals.tsx
│ │ │ │ └── GoalsHeader
│ │ │ │ │ ├── AddGoalModal
│ │ │ │ │ ├── AddGoalModal.tsx
│ │ │ │ │ ├── PayGoalForm
│ │ │ │ │ │ └── PayGoalForm.tsx
│ │ │ │ │ └── SaveGoalForm
│ │ │ │ │ │ └── SaveGoalForm.tsx
│ │ │ │ │ ├── GoalsHeader.module.css
│ │ │ │ │ └── GoalsHeader.tsx
│ │ │ │ ├── PageContent.module.css
│ │ │ │ ├── PageContent.tsx
│ │ │ │ ├── Settings
│ │ │ │ ├── DarkModeToggle.tsx
│ │ │ │ ├── LinkSimpleFin.tsx
│ │ │ │ ├── ResetPassword.tsx
│ │ │ │ ├── Settings.module.css
│ │ │ │ ├── Settings.tsx
│ │ │ │ └── UserSettings.tsx
│ │ │ │ ├── Transactions
│ │ │ │ ├── TransactionCards.tsx
│ │ │ │ │ ├── TransactionCard
│ │ │ │ │ │ ├── EditableCategoryCell
│ │ │ │ │ │ │ ├── EditableCategoryCell.module.css
│ │ │ │ │ │ │ └── EditableCategoryCell.tsx
│ │ │ │ │ │ ├── SelectedTransactionCard
│ │ │ │ │ │ │ └── SelectedTransactionCard.tsx
│ │ │ │ │ │ ├── SplitTransaction
│ │ │ │ │ │ │ └── SplitTransaction.tsx
│ │ │ │ │ │ ├── TransactionCard.module.css
│ │ │ │ │ │ ├── TransactionCard.tsx
│ │ │ │ │ │ └── UnselectedTransactionCard
│ │ │ │ │ │ │ └── UnselectedTransactionCard.tsx
│ │ │ │ │ └── TransactionCards.tsx
│ │ │ │ ├── Transactions.module.css
│ │ │ │ ├── Transactions.tsx
│ │ │ │ └── TransactionsHeader
│ │ │ │ │ ├── CreateTransactionModal
│ │ │ │ │ └── CreateTransactionModal.tsx
│ │ │ │ │ ├── FilterCard
│ │ │ │ │ ├── FilterCard.module.css
│ │ │ │ │ └── FilterCard.tsx
│ │ │ │ │ ├── ImportTransactionsModal
│ │ │ │ │ ├── AccountMapping
│ │ │ │ │ │ └── AccountMapping.tsx
│ │ │ │ │ ├── ColumnsOptions
│ │ │ │ │ │ └── ColumnsOptions.tsx
│ │ │ │ │ ├── ColumnsSelect
│ │ │ │ │ │ └── ColumnsSelect.tsx
│ │ │ │ │ ├── CsvOptions
│ │ │ │ │ │ └── CsvOptions.tsx
│ │ │ │ │ ├── DuplicateTransactionTable
│ │ │ │ │ │ └── DuplicateTransactionTable.tsx
│ │ │ │ │ ├── ImportTransactionsModal.tsx
│ │ │ │ │ └── TransactionsTable
│ │ │ │ │ │ └── TransactionsTable.tsx
│ │ │ │ │ ├── SortMenu
│ │ │ │ │ ├── SortMenu.module.css
│ │ │ │ │ ├── SortMenu.tsx
│ │ │ │ │ └── SortMenuHelpers.ts
│ │ │ │ │ ├── TransactionsHeader.module.css
│ │ │ │ │ ├── TransactionsHeader.tsx
│ │ │ │ │ └── TransactionsSettings
│ │ │ │ │ ├── CustomCategories
│ │ │ │ │ ├── AddCategory
│ │ │ │ │ │ └── AddCategory.tsx
│ │ │ │ │ ├── CustomCategories.module.css
│ │ │ │ │ ├── CustomCategories.tsx
│ │ │ │ │ └── CustomCategoryCard
│ │ │ │ │ │ ├── CustomCategoryCard.module.css
│ │ │ │ │ │ └── CustomCategoryCard.tsx
│ │ │ │ │ ├── DeletedTransactionCard
│ │ │ │ │ ├── DeletedTransactionsCard.module.css
│ │ │ │ │ └── DeletedTransactionsCard.tsx
│ │ │ │ │ └── TransactionsSettings.tsx
│ │ │ │ └── Trends
│ │ │ │ ├── AssetsTab
│ │ │ │ ├── AssetsTab.module.css
│ │ │ │ └── AssetsTab.tsx
│ │ │ │ ├── LiabilitiesTab
│ │ │ │ ├── LiabilitiesTab.module.css
│ │ │ │ └── LiabilitiesTab.tsx
│ │ │ │ ├── NetCashFlowTab
│ │ │ │ ├── NetCashFlowTab.module.css
│ │ │ │ └── NetCashFlowTab.tsx
│ │ │ │ ├── NetWorthTab
│ │ │ │ ├── NetWorthTab.module.css
│ │ │ │ └── NetWorthTab.tsx
│ │ │ │ ├── SpendingTab
│ │ │ │ ├── SpendingTab.module.css
│ │ │ │ └── SpendingTab.tsx
│ │ │ │ ├── Trends.module.css
│ │ │ │ └── Trends.tsx
│ │ └── Unauthorized
│ │ │ ├── Login.tsx
│ │ │ ├── Register.tsx
│ │ │ ├── ResetPassword.tsx
│ │ │ ├── Welcome.module.css
│ │ │ └── Welcome.tsx
│ ├── assets
│ │ ├── budget-board-logo.tsx
│ │ └── budgetBoardLogo.svg
│ ├── components
│ │ ├── AccountSelectInput.tsx
│ │ ├── AccountsSelectHeader
│ │ │ └── AccountsSelectHeader.tsx
│ │ ├── AuthProvider
│ │ │ ├── AuthProvider.tsx
│ │ │ ├── AuthorizedRoute.tsx
│ │ │ └── UnauthorizedRoute.tsx
│ │ ├── CategorySelect.tsx
│ │ ├── Charts
│ │ │ ├── BalanceChart
│ │ │ │ └── BalanceChart.tsx
│ │ │ ├── NetCashFlowChart
│ │ │ │ └── NetCashFlowChart.tsx
│ │ │ ├── NetWorthChart
│ │ │ │ └── NetWorthChart.tsx
│ │ │ └── SpendingChart
│ │ │ │ └── SpendingChart.tsx
│ │ ├── MonthToolcards
│ │ │ ├── MonthToolcard
│ │ │ │ ├── MonthToolcard.module.css
│ │ │ │ └── MonthToolcard.tsx
│ │ │ ├── MonthToolcards.module.css
│ │ │ └── MonthToolcards.tsx
│ │ ├── SortButton.tsx
│ │ └── Sortable
│ │ │ ├── Sortable.tsx
│ │ │ ├── SortableHandle.tsx
│ │ │ └── SortableItem.tsx
│ ├── helpers
│ │ ├── accounts.ts
│ │ ├── balances.ts
│ │ ├── budgets.ts
│ │ ├── category.ts
│ │ ├── charts.ts
│ │ ├── currency.ts
│ │ ├── datetime.ts
│ │ ├── goals.ts
│ │ ├── requests.ts
│ │ ├── transactions.ts
│ │ └── utils.ts
│ ├── index.css
│ ├── main.tsx
│ ├── models
│ │ ├── account.ts
│ │ ├── applicationUser.ts
│ │ ├── balance.ts
│ │ ├── budget.ts
│ │ ├── category.ts
│ │ ├── goal.ts
│ │ ├── institution.ts
│ │ ├── transaction.ts
│ │ └── userSettings.ts
│ ├── shared
│ │ └── projectEnvVariables.ts
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
├── compose.override.yml
├── compose.yml
├── img
├── budget-board-budgets.png
├── budget-board-dashboard.png
└── logo.svg
└── server
├── .gitignore
├── BudgetBoard.Database
├── BudgetBoard.Database.csproj
├── Data
│ └── UserDataContext.cs
├── Migrations
│ ├── 20231218061703_InitialDatabase.Designer.cs
│ ├── 20231218061703_InitialDatabase.cs
│ ├── 20240114181918_ChangeAmountToDecimal.Designer.cs
│ ├── 20240114181918_ChangeAmountToDecimal.cs
│ ├── 20240120050640_UpdateTypeType.Designer.cs
│ ├── 20240120050640_UpdateTypeType.cs
│ ├── 20240123044246_AddSubCategory.Designer.cs
│ ├── 20240123044246_AddSubCategory.cs
│ ├── 20240124031230_ChangeToOneWord.Designer.cs
│ ├── 20240124031230_ChangeToOneWord.cs
│ ├── 20240217002101_AddAccessToken.Designer.cs
│ ├── 20240217002101_AddAccessToken.cs
│ ├── 20240316022816_Add-Sync-ID.Designer.cs
│ ├── 20240316022816_Add-Sync-ID.cs
│ ├── 20240316042033_Add-Sync-ID-account.Designer.cs
│ ├── 20240316042033_Add-Sync-ID-account.cs
│ ├── 20240316195318_Add-Account-CurrentBalance.Designer.cs
│ ├── 20240316195318_Add-Account-CurrentBalance.cs
│ ├── 20240317163328_Add-LastSync.Designer.cs
│ ├── 20240317163328_Add-LastSync.cs
│ ├── 20240331223918_AddBudgets.Designer.cs
│ ├── 20240331223918_AddBudgets.cs
│ ├── 20240331225133_AddBudgetsFix.Designer.cs
│ ├── 20240331225133_AddBudgetsFix.cs
│ ├── 20240401020645_AddBudgetsDeleted.Designer.cs
│ ├── 20240401020645_AddBudgetsDeleted.cs
│ ├── 20240401040512_FixBudget.Designer.cs
│ ├── 20240401040512_FixBudget.cs
│ ├── 20240409004620_AddHideTransactionBool.Designer.cs
│ ├── 20240409004620_AddHideTransactionBool.cs
│ ├── 20240502020354_AddHideAccount.Designer.cs
│ ├── 20240502020354_AddHideAccount.cs
│ ├── 20240506015343_AddDeleted.Designer.cs
│ ├── 20240506015343_AddDeleted.cs
│ ├── 20240519182542_AddGoals.Designer.cs
│ ├── 20240519182542_AddGoals.cs
│ ├── 20240519204749_AddCategory.Designer.cs
│ ├── 20240519204749_AddCategory.cs
│ ├── 20240520034903_UpdateGoal.Designer.cs
│ ├── 20240520034903_UpdateGoal.cs
│ ├── 20240524040826_UpdateGoalMonthlyContribution.Designer.cs
│ ├── 20240524040826_UpdateGoalMonthlyContribution.cs
│ ├── 20240525030443_UpdateGoalParams.Designer.cs
│ ├── 20240525030443_UpdateGoalParams.cs
│ ├── 20240601035923_AddIdentity.Designer.cs
│ ├── 20240601035923_AddIdentity.cs
│ ├── 20240821025811_AddBalanceDate.Designer.cs
│ ├── 20240821025811_AddBalanceDate.cs
│ ├── 20241119004148_AddBalances.Designer.cs
│ ├── 20241119004148_AddBalances.cs
│ ├── 20241213043817_AddInstitution.Designer.cs
│ ├── 20241213043817_AddInstitution.cs
│ ├── 20241214190502_AddAccountIndex.Designer.cs
│ ├── 20241214190502_AddAccountIndex.cs
│ ├── 20241223045954_RemoveAccountBalanceProperties.Designer.cs
│ ├── 20241223045954_RemoveAccountBalanceProperties.cs
│ ├── 20241224001212_UpdateBudgetFloatToDecimal.Designer.cs
│ ├── 20241224001212_UpdateBudgetFloatToDecimal.cs
│ ├── 20250105233617_AddTransactionCategory.Designer.cs
│ ├── 20250105233617_AddTransactionCategory.cs
│ ├── 20250106051133_DropDeleted.Designer.cs
│ ├── 20250106051133_DropDeleted.cs
│ ├── 20250109054806_RemoveNullable.Designer.cs
│ ├── 20250109054806_RemoveNullable.cs
│ ├── 20250222202242_RemoveUserUid.Designer.cs
│ ├── 20250222202242_RemoveUserUid.cs
│ ├── 20250322013118_AddDeletedToInstitution.Designer.cs
│ ├── 20250322013118_AddDeletedToInstitution.cs
│ ├── 20250404235803_AddAccountSource.Designer.cs
│ ├── 20250404235803_AddAccountSource.cs
│ ├── 20250415230451_AddGoalCompleted.Designer.cs
│ ├── 20250415230451_AddGoalCompleted.cs
│ ├── 20250523225400_AddInterestRate.Designer.cs
│ ├── 20250523225400_AddInterestRate.cs
│ ├── 20250528000456_AddUserSettings.Designer.cs
│ ├── 20250528000456_AddUserSettings.cs
│ └── UserDataContextModelSnapshot.cs
└── Models
│ ├── Account.cs
│ ├── ApplicationUser.cs
│ ├── Balance.cs
│ ├── Budget.cs
│ ├── Category.cs
│ ├── Goal.cs
│ ├── Institution.cs
│ ├── Transaction.cs
│ └── UserSettings.cs
├── BudgetBoard.Service
├── AccountService.cs
├── ApplicationUserService.cs
├── BalanceService.cs
├── BudgetBoard.Service.csproj
├── BudgetService.cs
├── GoalService.cs
├── Helpers
│ └── TransactionCategoriesHelpers.cs
├── InsitutionService.cs
├── Interfaces
│ ├── IAccountService.cs
│ ├── IApplicationUserService.cs
│ ├── IBalanceService.cs
│ ├── IBudgetService.cs
│ ├── IGoalService.cs
│ ├── IInstitutionService.cs
│ ├── ISimpleFinService.cs
│ ├── ITransactionCategoryService.cs
│ ├── ITransactionService.cs
│ └── IUserSettingsService.cs
├── Models
│ ├── Account.cs
│ ├── ApplicationUser.cs
│ ├── Balance.cs
│ ├── Budget.cs
│ ├── BudgetBoardServiceException.cs
│ ├── Category.cs
│ ├── Goal.cs
│ ├── Institution.cs
│ ├── SimpleFin.cs
│ ├── Transaction.cs
│ ├── TransactionCategoriesConstants.cs
│ └── UserSettings.cs
├── SimpleFinService.cs
├── TransactionCategoryService.cs
├── TransactionService.cs
└── UserSettingsService.cs
├── BudgetBoard.Tests
├── AccountServiceTests.cs
├── ApplicationUserTests.cs
├── BalanceServiceTests.cs
├── BudgetBoard.IntegrationTests.csproj
├── BudgetServiceTests.cs
├── Fakers
│ ├── AccountFaker.cs
│ ├── ApplicationUserFaker.cs
│ ├── BalanceFaker.cs
│ ├── BudgetFaker.cs
│ ├── GoalFaker.cs
│ ├── InstitutionFaker.cs
│ ├── TransactionCategoryFaker.cs
│ ├── TransactionFaker.cs
│ └── UserSettingsFaker.cs
├── GlobalUsings.cs
├── GoalServiceTests.cs
├── InstitutionServiceTests.cs
├── SimpleFinServiceTests.cs
├── TestHelper.cs
├── TransactionCategoryServiceTests.cs
├── TransactionServiceTests.cs
└── UserSettingsServiceTests.cs
├── BudgetBoard.WebAPI
├── BudgetBoard.WebAPI.csproj
├── Controllers
│ ├── AccountController.cs
│ ├── ApplicationUserController.cs
│ ├── BalanceController.cs
│ ├── BudgetController.cs
│ ├── GoalController.cs
│ ├── InstitutionController.cs
│ ├── SimpleFinController.cs
│ ├── TransactionCategoryController.cs
│ ├── TransactionController.cs
│ └── UserSettingsController.cs
├── Jobs
│ └── SyncBackgroundJob.cs
├── Overrides
│ ├── IdentityApiEndpointRouteBuilderExtensions.cs
│ └── IdentityApiEndpointRouteBuilderOptions.cs
├── Program.cs
├── Properties
│ └── launchSettings.json
├── Utils
│ ├── EmailSender.cs
│ └── Helpers.cs
├── appsettings.Development.json
├── appsettings.json
└── libman.json
├── BudgetBoard.sln
├── Dockerfile
└── editorconfig
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.classpath
2 | **/.dockerignore
3 | **/.env
4 | **/.git
5 | **/.gitignore
6 | **/.project
7 | **/.settings
8 | **/.toolstarget
9 | **/.vs
10 | **/.vscode
11 | **/*.*proj.user
12 | **/*.dbmdl
13 | **/*.jfm
14 | **/bin
15 | **/charts
16 | **/docker-compose*
17 | **/compose*
18 | **/Dockerfile*
19 | **/node_modules
20 | **/npm-debug.log
21 | **/obj
22 | **/secrets.dev.yaml
23 | **/values.dev.yaml
24 | LICENSE
25 | README.md
26 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
4 | # Custom for Visual Studio
5 | *.cs diff=csharp
6 |
7 | # Standard to msysgit
8 | *.doc diff=astextplain
9 | *.DOC diff=astextplain
10 | *.docx diff=astextplain
11 | *.DOCX diff=astextplain
12 | *.dot diff=astextplain
13 | *.DOT diff=astextplain
14 | *.pdf diff=astextplain
15 | *.PDF diff=astextplain
16 | *.rtf diff=astextplain
17 | *.RTF diff=astextplain
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: bug
6 | assignees: teelur
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image-pr-build.yml:
--------------------------------------------------------------------------------
1 | name: Build Docker Images
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | strategy:
10 | fail-fast: false
11 | matrix:
12 | dockerfile:
13 | - ./client
14 | - ./server
15 | include:
16 | - dockerfile: ./client
17 | image: ghcr.io/teelur/budget-board/client
18 | - dockerfile: ./server
19 | image: ghcr.io/teelur/budget-board/server
20 |
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v4
24 |
25 | - name: Set up Docker Buildx
26 | uses: docker/setup-buildx-action@v3
27 |
28 | - name: Set up QEMU
29 | uses: docker/setup-qemu-action@v3
30 |
31 | - name: Extract metadata (tags, labels) for Docker
32 | id: meta
33 | uses: docker/metadata-action@v5
34 | with:
35 | images: ${{ matrix.image }}
36 |
37 | - name: Build the Docker image
38 | id: push
39 | uses: docker/build-push-action@v6
40 | with:
41 | context: ${{ matrix.dockerfile }}
42 | push: false
43 | platforms: linux/amd64,linux/arm64
44 | tags: ${{ steps.meta.outputs.tags }}
45 | labels: ${{ steps.meta.outputs.labels }}
46 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | name: Run Dotnet Unit Tests
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | test-dotnet:
8 | runs-on: ubuntu-latest
9 | strategy:
10 | fail-fast: false
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v4
15 |
16 | - name: Setup dotnet
17 | uses: actions/setup-dotnet@v4
18 | with:
19 | dotnet-version: "9.0.x"
20 |
21 | - name: Install dependencies
22 | working-directory: ./server
23 | run: dotnet restore
24 |
25 | - name: Build
26 | working-directory: ./server
27 | run: dotnet build --no-restore
28 |
29 | - name: Test with the dotnet CLI
30 | working-directory: ./server
31 | run: dotnet test --no-build
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Windows image file caches
2 | Thumbs.db
3 | ehthumbs.db
4 |
5 | # Folder config file
6 | Desktop.ini
7 |
8 | # Recycle Bin used on file shares
9 | $RECYCLE.BIN/
10 |
11 | # Windows Installer files
12 | *.cab
13 | *.msi
14 | *.msm
15 | *.msp
16 |
17 | # Windows shortcuts
18 | *.lnk
19 |
20 | # Excluded directories/files
21 | .vscode
22 | **/db.sqlite3
23 | **/__pycache__/
24 | .env*
25 | **/node_modules/
26 | nginx.conf
27 |
28 | # Yarn
29 | client/.yarn/install-state.gz
30 |
31 | # Distribution / packaging
32 | .Python
33 | env/
34 | build/
35 |
36 | # =========================
37 | # Operating System Files
38 | # =========================
39 |
40 | # OSX
41 | # =========================
42 |
43 | .DS_Store
44 | .AppleDouble
45 | .LSOverride
46 |
47 | # Thumbnails
48 | ._*
49 |
50 | # Files that might appear in the root of a volume
51 | .DocumentRevisions-V100
52 | .fseventsd
53 | .Spotlight-V100
54 | .TemporaryItems
55 | .Trashes
56 | .VolumeIcon.icns
57 |
58 | # Directories potentially created on remote AFP share
59 | .AppleDB
60 | .AppleDesktop
61 | Network Trash Folder
62 | Temporary Items
63 | .apdisk
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | ---
6 |
7 | [](https://github.com/teelur/budget-board/actions/workflows/docker-image-ci-build.yml)
8 | 
9 |
10 | A simple app for tracking monthly spending and working towards financial goals.
11 |
12 | ## About The Project
13 |
14 | I created this app to be a self-hosted alternative to the now-shut-down personal finance app Mint.
15 |
16 | ### Features
17 |
18 | - Automatically sync your bank account data with [SimpleFIN](https://www.simplefin.org/)
19 | - Dashboard to view account data at a glance
20 | - Organize transactions into categories and subcategories
21 | - Create monthly budgets for categories and subcategories
22 | - Create and track goals for savings/loan payoff
23 | - Graphs to view trends over time (Spending, Assets, Liabilities, Net Worth, and more)
24 |
25 | ### Feedback
26 |
27 | - [Notice a bug?](https://github.com/teelur/budget-board/issues/new/choose)
28 | - [Have a feature request?](https://github.com/teelur/budget-board/discussions/categories/feature-requests)
29 | - [Have any feedback?](https://github.com/teelur/budget-board/discussions/categories/feedback)
30 | - [Have a question?](https://github.com/teelur/budget-board/discussions/categories/q-a)
31 |
32 | ### Screenshots
33 |
34 |
35 |
36 |
37 | ## Configuration
38 |
39 | ### Setting up Docker Compose
40 |
41 | This project is deployed using Docker Compose.
42 |
43 | The `compose.yml` and `compose.override.yml` files are used to deploy the app.
44 | Both files are able to deploy the app as is, but it is recommended to at least update the database password.
45 |
46 | See the [wiki](https://github.com/teelur/budget-board/wiki/Deploying-via-Docker-Compose) for more details about configuring the compose override file.
47 |
48 | ### Deploy
49 |
50 | Deploy the app by running the following command:
51 |
52 | ```
53 | docker compose up -d
54 | ```
55 |
56 | You can now access the app at `localhost:6253`.
57 |
58 | Check out the [wiki](https://github.com/teelur/budget-board/wiki) for more details about configuration and using the app :)
59 |
--------------------------------------------------------------------------------
/client/.docker/nginx/conf/default.conf.template:
--------------------------------------------------------------------------------
1 | upstream backend_server {
2 | # This should be the name of the server container and it's configured port
3 | server budget-board-server:8080;
4 | }
5 |
6 | server {
7 | listen ${PORT};
8 |
9 | # You should configure this to the domain that points to this app.
10 | server_name ${VITE_API_URL};
11 |
12 | access_log /var/log/nginx/nginx.vhost.access.log;
13 | error_log /var/log/nginx/nginx.vhost.error.log;
14 |
15 | location / {
16 | # This would be the directory where your React app's static files are stored.
17 | root /usr/share/nginx/html;
18 | try_files ${DOLLAR}uri /index.html;
19 | }
20 |
21 | location /api/ {
22 | # This will redirect a request from this container to our backend container.
23 | proxy_pass http://backend_server/api/;
24 | proxy_set_header Host ${DOLLAR}proxy_host:${DOLLAR}proxy_port;
25 | # These are used to identify the original reverse proxied request
26 | proxy_set_header X-Forwarded-For ${DOLLAR}proxy_add_x_forwarded_for;
27 | proxy_set_header X-Forwarded-Host ${DOLLAR}http_host;
28 | proxy_set_header X-Forwarded-Proto ${DOLLAR}scheme;
29 | }
30 | }
--------------------------------------------------------------------------------
/client/.docker/nginx/conf/nginx-substitute.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # Make sure your end of line is LF. Otherwise this will cause issues.
3 | export DOLLAR="$"
4 | envsubst < default.conf.template > /etc/nginx/conf.d/default.conf
5 | nginx -g "daemon off;"
--------------------------------------------------------------------------------
/client/.docker/nginx/init-scripts/100-init-project-env-variables.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -ex
4 |
5 | # Find the file where environment variables need to be replaced.
6 | projectEnvVariables=$(ls -t /usr/share/nginx/html/assets/projectEnvVariables*.js | head -n1)
7 |
8 | # Replace environment variables
9 | envsubst < "$projectEnvVariables" > ./projectEnvVariables_temp
10 | cp ./projectEnvVariables_temp "$projectEnvVariables"
11 | rm ./projectEnvVariables_temp
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | *.tsbuildinfo
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
--------------------------------------------------------------------------------
/client/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:23 AS build-stage
2 |
3 | WORKDIR /app
4 |
5 | RUN corepack enable
6 |
7 | COPY ["./package.json", "./yarn.lock", "./.yarnrc.yml", "./"]
8 | RUN yarn install
9 |
10 | COPY . .
11 | RUN yarn run build
12 |
13 | # production stage
14 | FROM nginx:stable-alpine AS production-stage
15 |
16 | COPY --from=build-stage /app/dist /usr/share/nginx/html
17 | COPY .docker/nginx/init-scripts/ /docker-entrypoint.d/
18 |
19 | COPY .docker/nginx/conf/nginx-substitute.sh .
20 | COPY .docker/nginx/conf/default.conf.template .
21 |
22 | EXPOSE ${PORT}
23 | ENTRYPOINT [ "/bin/sh", "nginx-substitute.sh"]
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default tseslint.config({
18 | languageOptions: {
19 | // other options...
20 | parserOptions: {
21 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
22 | tsconfigRootDir: import.meta.dirname,
23 | },
24 | },
25 | })
26 | ```
27 |
28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29 | - Optionally add `...tseslint.configs.stylisticTypeChecked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31 |
32 | ```js
33 | // eslint.config.js
34 | import react from 'eslint-plugin-react'
35 |
36 | export default tseslint.config({
37 | // Set the react version
38 | settings: { react: { version: '18.3' } },
39 | plugins: {
40 | // Add the react plugin
41 | react,
42 | },
43 | rules: {
44 | // other rules...
45 | // Enable its recommended rules
46 | ...react.configs.recommended.rules,
47 | ...react.configs['jsx-runtime'].rules,
48 | },
49 | })
50 | ```
51 |
--------------------------------------------------------------------------------
/client/eslint.config.js:
--------------------------------------------------------------------------------
1 | import mantine from "eslint-config-mantine";
2 | import tseslint from "typescript-eslint";
3 |
4 | export default tseslint.config(...mantine, {
5 | ignores: ["**/*.{mjs,cjs,js,d.ts,d.mts}", "./.storybook/main.ts"],
6 | });
7 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Budget Board
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/client/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | "postcss-preset-mantine": {},
4 | "postcss-simple-vars": {
5 | variables: {
6 | "mantine-breakpoint-xs": "36em",
7 | "mantine-breakpoint-sm": "48em",
8 | "mantine-breakpoint-md": "62em",
9 | "mantine-breakpoint-lg": "75em",
10 | "mantine-breakpoint-xl": "88em",
11 | },
12 | },
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/client/public/budgetBoardIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/client/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | width: 100%;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import "./App.css";
2 | import "@mantine/core/styles.css";
3 | import "@mantine/notifications/styles.css";
4 | import "@mantine/notifications/styles.layer.css";
5 | import "@mantine/dates/styles.css";
6 | import "@mantine/charts/styles.css";
7 |
8 | import { BrowserRouter, Route, Routes } from "react-router";
9 | import { createTheme, MantineProvider } from "@mantine/core";
10 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
11 | import { Notifications } from "@mantine/notifications";
12 |
13 | import Welcome from "~/app/Unauthorized/Welcome";
14 | import AuthProvider from "~/components/AuthProvider/AuthProvider";
15 | import AuthorizedRoute from "~/components/AuthProvider/AuthorizedRoute";
16 | import UnauthorizedRoute from "~/components/AuthProvider/UnauthorizedRoute";
17 | import Authorized from "~/app/Authorized/Authorized";
18 |
19 | // Your theme configuration is merged with default theme
20 | const theme = createTheme({
21 | defaultRadius: "xs",
22 | primaryColor: "indigo",
23 | });
24 |
25 | const queryClient = new QueryClient({
26 | defaultOptions: {
27 | queries: {
28 | staleTime: 30000,
29 | },
30 | },
31 | });
32 |
33 | function App() {
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
47 | }
48 | />
49 |
53 |
54 |
55 | }
56 | />
57 |
58 |
59 | {/* */}
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | export default App;
67 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Authorized.module.css:
--------------------------------------------------------------------------------
1 | .navbar {
2 | background-color: light-dark(
3 | var(--mantine-primary-color-3),
4 | var(--mantine-color-dark-9)
5 | );
6 | }
7 |
8 | .header {
9 | background-color: light-dark(
10 | var(--mantine-color-gray-2),
11 | var(--mantine-color-dark-8)
12 | );
13 | }
14 |
15 | .main {
16 | display: flex;
17 | flex-direction: column;
18 | background-color: light-dark(
19 | var(--mantine-color-gray-1),
20 | var(--mantine-color-dark-8)
21 | );
22 | }
23 |
24 | .burger {
25 | &:hover {
26 | background-color: light-dark(
27 | var(--mantine-color-gray-0),
28 | var(--mantine-color-dark-5)
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Authorized.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./Authorized.module.css";
2 |
3 | import {
4 | AppShell,
5 | AppShellHeader,
6 | AppShellMain,
7 | AppShellNavbar,
8 | } from "@mantine/core";
9 | import Navbar from "./Navbar/Navbar";
10 | import React from "react";
11 | import PageContent, { Pages } from "./PageContent/PageContent";
12 | import Header from "./Header/Header";
13 | import { useDisclosure } from "@mantine/hooks";
14 |
15 | const Authorized = (): React.ReactNode => {
16 | const [currentPage, setCurrentPage] = React.useState(Pages.Dashboard);
17 | const [isNavbarOpen, { toggle }] = useDisclosure();
18 |
19 | const onPageSelect = (page: Pages): void => {
20 | setCurrentPage(page);
21 | toggle();
22 | };
23 |
24 | return (
25 |
38 |
39 |
40 |
41 |
42 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default Authorized;
57 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Header/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | padding: 6px 6px;
3 | height: 100%;
4 | justify-content: space-between;
5 | align-items: center;
6 | flex-wrap: nowrap;
7 | }
8 |
9 | .logo {
10 | flex: 0 1 auto;
11 | }
12 |
13 | .syncButton {
14 | flex: 1 0 auto;
15 | justify-content: flex-end;
16 | }
17 |
18 | .burger {
19 | &:hover {
20 | background-color: light-dark(
21 | var(--mantine-color-gray-0),
22 | var(--mantine-color-dark-5)
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import BudgetBoardLogo from "~/assets/budget-board-logo";
2 | import classes from "./Header.module.css";
3 |
4 | import { Burger, Flex, Group, useComputedColorScheme } from "@mantine/core";
5 | import SyncButton from "./SyncButton/SyncButton";
6 |
7 | interface HeaderProps {
8 | isNavbarOpen: boolean;
9 | toggleNavbar: () => void;
10 | }
11 |
12 | const Header = (props: HeaderProps): React.ReactNode => {
13 | const computedColorScheme = useComputedColorScheme();
14 | return (
15 |
16 |
17 |
24 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default Header;
37 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Header/SyncButton/SyncButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@mantine/core";
2 | import { notifications } from "@mantine/notifications";
3 | import { useMutation, useQueryClient } from "@tanstack/react-query";
4 | import { AxiosResponse, AxiosError } from "axios";
5 | import React from "react";
6 | import { AuthContext } from "~/components/AuthProvider/AuthProvider";
7 | import { translateAxiosError } from "~/helpers/requests";
8 |
9 | const SyncButton = (): React.ReactNode => {
10 | const { request } = React.useContext(AuthContext);
11 |
12 | const queryClient = useQueryClient();
13 | const doSyncMutation = useMutation({
14 | mutationFn: async () =>
15 | await request({ url: "/api/simplefin/sync", method: "GET" }),
16 | onSuccess: async (data: AxiosResponse) => {
17 | await queryClient.invalidateQueries({ queryKey: ["transactions"] });
18 | await queryClient.invalidateQueries({ queryKey: ["institutions"] });
19 | await queryClient.invalidateQueries({ queryKey: ["accounts"] });
20 | await queryClient.invalidateQueries({ queryKey: ["goals"] });
21 | if ((data.data?.length ?? 0) > 0) {
22 | {
23 | data.data.map((error: string) =>
24 | notifications.show({ color: "red", message: error })
25 | );
26 | }
27 | }
28 | },
29 | onError: (error: AxiosError) => {
30 | notifications.show({ color: "red", message: translateAxiosError(error) });
31 | },
32 | });
33 |
34 | return (
35 |
41 | );
42 | };
43 |
44 | export default SyncButton;
45 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Navbar/Navbar.module.css:
--------------------------------------------------------------------------------
1 | .burger {
2 | align-self: flex-end;
3 | margin: var(--mantine-spacing-sm) var(--mantine-spacing-sm);
4 |
5 | &:hover {
6 | background-color: light-dark(
7 | var(--mantine-color-gray-0),
8 | var(--mantine-color-dark-5)
9 | );
10 | }
11 | }
12 |
13 | .link {
14 | padding: var(--mantine-spacing-sm) var(--mantine-spacing-sm);
15 | height: 50px;
16 | border-radius: var(--mantine-radius-md);
17 | color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
18 |
19 | &:hover {
20 | background-color: light-dark(
21 | var(--mantine-color-gray-0),
22 | var(--mantine-color-dark-5)
23 | );
24 | }
25 |
26 | &[data-active] {
27 | &,
28 | &:hover {
29 | background-color: light-dark(
30 | var(--mantine-primary-color-4),
31 | var(--mantine-color-dark-6)
32 | );
33 | color: light-dark(
34 | var(--mantine-color-gray-3),
35 | var(--mantine-color-dark-0)
36 | );
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/Navbar/NavbarLink.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, UnstyledButton, Text, Group } from "@mantine/core";
2 |
3 | import classes from "./Navbar.module.css";
4 |
5 | interface NavbarLinkProps {
6 | icon: React.ReactNode;
7 | label: string;
8 | active?: boolean;
9 | onClick?: () => void;
10 | }
11 |
12 | const NavbarLink = (props: NavbarLinkProps): React.ReactNode => {
13 | return (
14 |
19 |
24 |
25 | {props.icon}
26 | {props.label}
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default NavbarLink;
34 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/Budgets.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | max-width: 1400px;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetGroupHeader/BudgetsGroupHeader.module.css:
--------------------------------------------------------------------------------
1 | .categoryHeader {
2 | font-size: 1rem;
3 | font-weight: 600;
4 |
5 | @container (max-width: 300px) {
6 | font-size: 0.8rem;
7 | }
8 | }
9 |
10 | .dataHeader {
11 | font-size: 1rem;
12 | font-weight: 500;
13 |
14 | @container (max-width: 300px) {
15 | font-size: 0.8rem;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetGroupHeader/BudgetsGroupHeader.tsx:
--------------------------------------------------------------------------------
1 | import cardClasses from "../BudgetsGroup/BudgetParentCard/BudgetParentCard.module.css";
2 | import groupClasses from "./BudgetsGroupHeader.module.css";
3 |
4 | import { Group, Text } from "@mantine/core";
5 | import React from "react";
6 |
7 | interface BudgetGroupHeaderProps {
8 | groupName: string;
9 | }
10 |
11 | const BudgetsGroupHeader = (props: BudgetGroupHeaderProps): React.ReactNode => {
12 | return (
13 |
14 |
15 | {props.groupName}
16 |
17 |
18 | );
19 | };
20 |
21 | export default BudgetsGroupHeader;
22 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetTotalCard/BudgetTotalCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | display: flex;
4 | flex-direction: column;
5 | gap: 0.5rem;
6 | }
7 |
8 | .group {
9 | display: flex;
10 | gap: 0.5rem;
11 | padding: 0.5rem;
12 | background-color: light-dark(
13 | var(--mantine-color-gray-4),
14 | var(--mantine-color-dark-7)
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetTotalCard/BudgetTotalItem/BudgetTotalItem.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | gap: 0;
3 | }
4 |
5 | .dataContainer {
6 | gap: 0;
7 | justify-content: space-between;
8 | }
9 |
10 | .text {
11 | font-size: 1rem;
12 | font-weight: 600;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetsGroup/BudgetParentCard/BudgetChildCard/BudgetChildCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | outline: 1px solid var(--mantine-color-default-border);
3 | background-color: light-dark(
4 | var(--mantine-color-gray-1),
5 | var(--mantine-color-dark-8)
6 | );
7 | }
8 |
9 | .budgetCard {
10 | &:hover {
11 | outline: 1px solid var(--mantine-primary-color-filled);
12 | }
13 | }
14 |
15 | .title {
16 | font-size: 1rem;
17 |
18 | @container (max-width: 380px) {
19 | font-size: 1rem;
20 | }
21 | }
22 |
23 | .text {
24 | font-size: 1rem;
25 |
26 | @container (max-width: 380px) {
27 | font-size: 0.9rem;
28 | }
29 | }
30 |
31 | .textSmall {
32 | font-size: 0.9rem;
33 |
34 | @container (max-width: 300px) {
35 | font-size: 0.8rem;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetsGroup/BudgetParentCard/BudgetParentCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | outline: 1px solid var(--mantine-color-default-border);
3 | background-color: light-dark(
4 | var(--mantine-color-gray-1),
5 | var(--mantine-color-dark-8)
6 | );
7 | }
8 |
9 | .budgetCard {
10 | &:hover {
11 | outline: 1px solid var(--mantine-primary-color-filled);
12 | }
13 | }
14 |
15 | .title {
16 | font-size: 1.2rem;
17 |
18 | @container (max-width: 380px) {
19 | font-size: 1rem;
20 | }
21 | }
22 |
23 | .text {
24 | font-size: 1.1rem;
25 |
26 | @container (max-width: 380px) {
27 | font-size: 1rem;
28 | }
29 | }
30 |
31 | .textSmall {
32 | font-size: 1rem;
33 |
34 | @container (max-width: 300px) {
35 | font-size: 0.8rem;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetsGroup/BudgetParentCard/UnbudgetChildCard/UnbudgetChildCard.module.css:
--------------------------------------------------------------------------------
1 | .text {
2 | font-size: 1rem;
3 |
4 | @container (max-width: 380px) {
5 | font-size: 0.9rem;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetsGroup/BudgetsGroup.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | gap: 0.5rem;
3 | align-items: center;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/BudgetsGroup/BudgetsGroup.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./BudgetsGroup.module.css";
2 |
3 | import { Stack, Text } from "@mantine/core";
4 | import { IBudget } from "~/models/budget";
5 | import React from "react";
6 | import { ICategory, ICategoryNode } from "~/models/category";
7 | import {
8 | buildCategoryToBudgetsMap,
9 | buildCategoryToLimitsMap,
10 | } from "~/helpers/budgets";
11 | import BudgetParentCard from "./BudgetParentCard/BudgetParentCard";
12 |
13 | interface BudgetsGroupProps {
14 | budgets: IBudget[];
15 | categoryToTransactionsTotalMap: Map;
16 | categoryTree: ICategoryNode[];
17 | categories: ICategory[];
18 | selectedDate?: Date;
19 | }
20 |
21 | const BudgetsGroup = (props: BudgetsGroupProps): React.ReactNode => {
22 | const categoryToBudgetsMap = buildCategoryToBudgetsMap(props.budgets);
23 | const categoryToLimitsMap = buildCategoryToLimitsMap(
24 | props.budgets,
25 | props.categoryTree
26 | );
27 |
28 | return (
29 |
30 | {props.budgets.length > 0 ? (
31 | props.categoryTree.map((category) => {
32 | if (
33 | categoryToBudgetsMap.has(category.value.toLocaleLowerCase()) ||
34 | category.subCategories.some((subCategory) =>
35 | categoryToBudgetsMap.has(subCategory.value.toLocaleLowerCase())
36 | )
37 | ) {
38 | return (
39 |
49 | );
50 | }
51 | return null;
52 | })
53 | ) : (
54 | No budgets.
55 | )}
56 |
57 | );
58 | };
59 |
60 | export default BudgetsGroup;
61 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/UnbudgetedGroup/UnbudgetedCard/UnbudgetedCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: light-dark(
3 | var(--mantine-color-gray-2),
4 | var(--mantine-color-dark-4)
5 | );
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/UnbudgetedGroup/UnbudgetedCard/UnbudgetedChildCard/UnbudgetedChildCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: light-dark(
3 | var(--mantine-color-gray-2),
4 | var(--mantine-color-dark-4)
5 | );
6 | }
7 |
8 | .text {
9 | font-size: 0.9rem;
10 |
11 | @container (max-width: 380px) {
12 | font-size: 0.8rem;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsContent/UnbudgetedGroup/UnbudgetedGroup.module.css:
--------------------------------------------------------------------------------
1 | .accordion {
2 | outline: 1px solid
3 | light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
4 | background-color: light-dark(
5 | var(--mantine-color-white),
6 | var(--mantine-color-dark-6)
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Budgets/BudgetsToolbar/AddBudget/AddBudget.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | }
4 |
5 | .formContainer {
6 | display: flex;
7 | flex-direction: row;
8 | gap: 0.5rem;
9 | }
10 |
11 | .submitContainer {
12 | align-self: stretch;
13 | }
14 | .submitButton {
15 | height: 100%;
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | width: 100%;
3 | height: auto;
4 | display: flex;
5 | flex-direction: column;
6 | gap: 12px;
7 | background-color: light-dark(
8 | var(--mantine-color-gray-0),
9 | var(--mantine-color-dark-6)
10 | );
11 | }
12 |
13 | .settingsIcon {
14 | color: var(--mantine-color-text);
15 |
16 | &:hover {
17 | color: var(--mantine-color-primary);
18 | }
19 | }
20 |
21 | .headerContainer {
22 | justify-content: space-between;
23 | align-items: center;
24 | }
25 |
26 | .accountsContainer {
27 | gap: 0.5rem;
28 | align-items: center;
29 | width: 100%;
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettings.module.css:
--------------------------------------------------------------------------------
1 | .settingsIcon {
2 | color: var(--mantine-color-text);
3 |
4 | &:hover {
5 | color: var(--mantine-color-primary);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettings.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./AccountsSettings.module.css";
2 |
3 | import { ActionIcon, useModalsStack } from "@mantine/core";
4 | import { SettingsIcon } from "lucide-react";
5 | import React from "react";
6 | import AccountsSettingsModal from "./AccountsSettingsModal/AccountsSettingsModal";
7 | import { IInstitution } from "~/models/institution";
8 | import { IAccount } from "~/models/account";
9 | import CreateAccountModal from "./CreateAccountModal/CreateAccountModal";
10 |
11 | interface AccountsSettingsProps {
12 | sortedFilteredInstitutions: IInstitution[];
13 | accounts: IAccount[];
14 | }
15 |
16 | const AccountsSettings = (props: AccountsSettingsProps): React.ReactNode => {
17 | const stack = useModalsStack(["settings", "createAccount"]);
18 |
19 | return (
20 |
21 |
stack.open("createAccount")}
25 | {...stack.register("settings")}
26 | />
27 |
28 | stack.open("settings")}
32 | >
33 |
34 |
35 |
36 | );
37 | };
38 | export default AccountsSettings;
39 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettingsModal/AccountsSettingsModal.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | flex-direction: row;
3 | flex: 1 1 auto;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettingsModal/DeletedAccounts/DeletedAccountCard/DeletedAccountCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: 12px;
7 | width: 100%;
8 | background-color: light-dark(
9 | var(--mantine-color-gray-2),
10 | var(--mantine-color-dark-7)
11 | );
12 | }
13 |
14 | .column {
15 | flex: 1;
16 | }
17 |
18 | .button {
19 | flex: 0;
20 | justify-content: flex-end;
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettingsModal/DeletedAccounts/DeletedAccountCard/DeletedAccountCard.tsx:
--------------------------------------------------------------------------------
1 | import { AuthContext } from "~/components/AuthProvider/AuthProvider";
2 | import classes from "./DeletedAccountCard.module.css";
3 |
4 | import { getDaysSinceDate } from "~/helpers/datetime";
5 | import { Button, Card, Group, Text } from "@mantine/core";
6 | import { IAccount } from "~/models/account";
7 | import { Undo2Icon } from "lucide-react";
8 | import React from "react";
9 | import { useMutation, useQueryClient } from "@tanstack/react-query";
10 | import { notifications } from "@mantine/notifications";
11 | import { translateAxiosError } from "~/helpers/requests";
12 | import { AxiosError } from "axios";
13 |
14 | interface DeletedAccountCardProps {
15 | deletedAccount: IAccount;
16 | }
17 |
18 | const DeletedAccountCard = (
19 | props: DeletedAccountCardProps
20 | ): React.ReactNode => {
21 | const { request } = React.useContext(AuthContext);
22 |
23 | const queryClient = useQueryClient();
24 | const doRestoreAccount = useMutation({
25 | mutationFn: async (id: string) =>
26 | await request({
27 | url: "/api/account/restore",
28 | method: "POST",
29 | params: { guid: id },
30 | }),
31 | onSuccess: async () => {
32 | // Refetch the accounts and institutions queries immediatly after the account is restored
33 | await queryClient.refetchQueries({ queryKey: ["institutions"] });
34 | await queryClient.refetchQueries({ queryKey: ["accounts"] });
35 | },
36 | onError: (error: AxiosError) => {
37 | notifications.show({ color: "red", message: translateAxiosError(error) });
38 | },
39 | });
40 |
41 | return (
42 |
43 | {props.deletedAccount.name}
44 |
45 | {`${getDaysSinceDate(
46 | props.deletedAccount.deleted!
47 | )} days since deleted`}
48 |
49 |
50 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default DeletedAccountCard;
62 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettingsModal/DeletedAccounts/DeletedAccounts.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion, Stack } from "@mantine/core";
2 | import { IAccount } from "~/models/account";
3 | import React from "react";
4 | import DeletedAccountCard from "./DeletedAccountCard/DeletedAccountCard";
5 |
6 | interface DeletedAccountsProps {
7 | deletedAccounts: IAccount[];
8 | }
9 |
10 | const DeletedAccounts = (props: DeletedAccountsProps): React.ReactNode => {
11 | const sortedDeletedAccounts = props.deletedAccounts.sort(
12 | (a, b) =>
13 | new Date(b.deleted ?? 0).getTime() - new Date(a.deleted ?? 0).getTime()
14 | );
15 | return (
16 |
17 |
18 | Deleted Accounts
19 |
20 |
21 | {sortedDeletedAccounts.map((deletedAccount) => (
22 |
26 | ))}
27 |
28 |
29 |
30 |
31 | );
32 | };
33 |
34 | export default DeletedAccounts;
35 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/AccountsSettings/AccountsSettingsModal/InstitutionSettingsCard/InstitutionSettingsCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | background-color: light-dark(
3 | var(--mantine-color-gray-2),
4 | var(--mantine-color-dark-5)
5 | );
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/InstitutionItems/AccountItem/AccountItem.tsx:
--------------------------------------------------------------------------------
1 | import { convertNumberToCurrency } from "~/helpers/currency";
2 | import { Group, Stack, Text } from "@mantine/core";
3 | import { AccountSource, IAccount } from "~/models/account";
4 | import React from "react";
5 | import { AuthContext } from "~/components/AuthProvider/AuthProvider";
6 | import { useQuery } from "@tanstack/react-query";
7 | import { IUserSettings } from "~/models/userSettings";
8 | import { AxiosResponse } from "axios";
9 |
10 | interface AccountItemProps {
11 | account: IAccount;
12 | }
13 |
14 | const AccountItem = (props: AccountItemProps): React.ReactNode => {
15 | const { request } = React.useContext(AuthContext);
16 |
17 | const userSettingsQuery = useQuery({
18 | queryKey: ["userSettings"],
19 | queryFn: async (): Promise => {
20 | const res: AxiosResponse = await request({
21 | url: "/api/userSettings",
22 | method: "GET",
23 | });
24 |
25 | if (res.status === 200) {
26 | return res.data as IUserSettings;
27 | }
28 |
29 | return undefined;
30 | },
31 | });
32 |
33 | return (
34 |
35 |
36 | {props.account.name}
37 | {userSettingsQuery.isPending ? null : (
38 |
47 | {convertNumberToCurrency(
48 | props.account.currentBalance,
49 | true,
50 | userSettingsQuery.data?.currency ?? "USD"
51 | )}
52 |
53 | )}
54 |
55 | {props.account.source !== AccountSource.Manual && (
56 |
60 | {"Last updated: "}
61 | {props.account.balanceDate
62 | ? new Date(props.account.balanceDate).toLocaleString()
63 | : "Never!"}
64 |
65 | )}
66 |
67 | );
68 | };
69 |
70 | export default AccountItem;
71 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/InstitutionItems/InstitutionItem.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | width: 100%;
3 | background-color: light-dark(
4 | var(--mantine-color-gray-2),
5 | var(--mantine-color-dark-7)
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/AccountsCard/InstitutionItems/InstitutionItem.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./InstitutionItem.module.css";
2 |
3 | import { filterVisibleAccounts } from "~/helpers/accounts";
4 | import { Card, Divider, Stack, Title } from "@mantine/core";
5 | import { IAccount } from "~/models/account";
6 | import { IInstitution } from "~/models/institution";
7 | import { DndContext } from "@dnd-kit/core";
8 | import { SortableContext } from "@dnd-kit/sortable";
9 |
10 | import React from "react";
11 | import AccountItem from "./AccountItem/AccountItem";
12 |
13 | interface InstitutionItemProps {
14 | institution: IInstitution;
15 | }
16 |
17 | const InstitutionItem = (props: InstitutionItemProps): React.ReactNode => {
18 | const sortedFilteredAccounts = filterVisibleAccounts(
19 | props.institution.accounts
20 | ).sort((a, b) => a.index - b.index);
21 |
22 | return (
23 |
24 |
25 | {props.institution.name}
26 |
27 |
28 |
29 |
30 |
31 | {sortedFilteredAccounts.map((account: IAccount) => (
32 |
33 | ))}
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default InstitutionItem;
42 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/Dashboard.module.css:
--------------------------------------------------------------------------------
1 | .dashboardContainer {
2 | width: 100%;
3 | max-width: 1400px;
4 | height: 100%;
5 | flex: 1;
6 | }
7 |
8 | .mainContent {
9 | display: flex;
10 | flex-direction: row;
11 | justify-content: center;
12 | align-items: flex-start;
13 | flex-wrap: nowrap;
14 | gap: 12px;
15 | flex: 1;
16 |
17 | @media (max-width: $mantine-breakpoint-sm) {
18 | flex-direction: column;
19 | align-items: center;
20 | }
21 | }
22 |
23 | .accountColumn {
24 | display: flex;
25 | flex-direction: column;
26 | justify-content: flex-start;
27 | align-items: center;
28 | width: 40%;
29 | max-width: 400px;
30 | height: 100%;
31 |
32 | @media (max-width: $mantine-breakpoint-sm) {
33 | width: 100%;
34 | max-width: 100%;
35 | }
36 | }
37 |
38 | .feedColumn {
39 | flex-grow: 1;
40 | display: flex;
41 | flex-direction: column;
42 | justify-content: flex-start;
43 | align-items: center;
44 | height: 100%;
45 | width: 60%;
46 | gap: 12px;
47 |
48 | @media (max-width: $mantine-breakpoint-sm) {
49 | width: 100%;
50 | max-width: 100%;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./Dashboard.module.css";
2 |
3 | import { Flex, Stack } from "@mantine/core";
4 | import React from "react";
5 | import AccountsCard from "./AccountsCard/AccountsCard";
6 | import NetWorthCard from "./NetWorthCard/NetWorthCard";
7 | import Footer from "./Footer/Footer";
8 | import SpendingTrendsCard from "./SpendingTrendsCard/SpendingTrendsCard";
9 | import UncategorizedTransactionsCard from "./UncategorizedTransactionsCard/UncategorizedTransactionsCard";
10 |
11 | const Dashboard = (): React.ReactNode => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default Dashboard;
30 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/Footer/Footer.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | gap: 0.5rem;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./Footer.module.css";
2 |
3 | import { ActionIcon, Group, Text } from "@mantine/core";
4 | import React from "react";
5 | import { SiGithub } from "@icons-pack/react-simple-icons";
6 |
7 | const Footer = (): React.ReactNode => {
8 | return (
9 |
10 |
11 | {import.meta.env.VITE_VERSION}
12 |
13 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default Footer;
27 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/NetWorthCard/NetWorthCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | width: 100%;
3 | height: auto;
4 | background-color: light-dark(
5 | var(--mantine-color-gray-0),
6 | var(--mantine-color-dark-6)
7 | );
8 | }
9 |
10 | .content {
11 | gap: 6px;
12 | }
13 |
14 | .group {
15 | display: flex;
16 | gap: 0px;
17 | padding: 0.5rem 1rem;
18 | background-color: light-dark(
19 | var(--mantine-color-gray-2),
20 | var(--mantine-color-dark-7)
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/NetWorthCard/NetWorthItem/NetWorthItem.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: center;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/NetWorthCard/NetWorthItem/NetWorthItem.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./NetWorthItem.module.css";
2 |
3 | import { Group, Text } from "@mantine/core";
4 | import { IAccount } from "~/models/account";
5 | import { convertNumberToCurrency } from "~/helpers/currency";
6 | import {
7 | getAccountsOfTypes,
8 | sumAccountsTotalBalance,
9 | } from "~/helpers/accounts";
10 | import React from "react";
11 | import { AuthContext } from "~/components/AuthProvider/AuthProvider";
12 | import { useQuery } from "@tanstack/react-query";
13 | import { IUserSettings } from "~/models/userSettings";
14 | import { AxiosResponse } from "axios";
15 |
16 | interface NetWorthItemProps {
17 | title: string;
18 | types?: string[];
19 | accounts: IAccount[];
20 | }
21 |
22 | const NetWorthItem = (props: NetWorthItemProps): React.ReactNode => {
23 | const { request } = React.useContext(AuthContext);
24 |
25 | const userSettingsQuery = useQuery({
26 | queryKey: ["userSettings"],
27 | queryFn: async (): Promise => {
28 | const res: AxiosResponse = await request({
29 | url: "/api/userSettings",
30 | method: "GET",
31 | });
32 |
33 | if (res.status === 200) {
34 | return res.data as IUserSettings;
35 | }
36 |
37 | return undefined;
38 | },
39 | });
40 |
41 | const summedAccountsTotalBalance = sumAccountsTotalBalance(
42 | props.types
43 | ? getAccountsOfTypes(props.accounts, props.types)
44 | : props.accounts
45 | );
46 |
47 | return (
48 |
49 | {props.title}
50 | {userSettingsQuery.isPending ? null : (
51 |
60 | {convertNumberToCurrency(
61 | summedAccountsTotalBalance,
62 | true,
63 | userSettingsQuery.data?.currency ?? "USD"
64 | )}
65 |
66 | )}
67 |
68 | );
69 | };
70 |
71 | export default NetWorthItem;
72 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/SpendingTrendsCard/SpendingTrendsCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | padding: 1rem;
4 | }
5 |
6 | .header {
7 | align-items: center;
8 | gap: 0px;
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/UncategorizedTransactionsCard/UncategorizedTransaction/UncategorizedTransaction.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | container-type: inline-size;
4 | background-color: light-dark(
5 | var(--mantine-color-gray-1),
6 | var(--mantine-color-dark-7)
7 | );
8 |
9 | &:hover {
10 | outline: 1px solid var(--mantine-primary-color-filled);
11 | }
12 | }
13 |
14 | .container {
15 | width: 100%;
16 | gap: 0.5rem;
17 | align-items: center;
18 | flex-direction: row;
19 |
20 | @container (max-width: 650px) {
21 | flex-direction: column;
22 | }
23 | }
24 |
25 | .leftSubContainer {
26 | gap: 0.5rem;
27 | align-items: center;
28 | flex: 1 1 auto;
29 |
30 | @container (max-width: 650px) {
31 | width: 100%;
32 | }
33 | }
34 |
35 | .rightSubContainer {
36 | gap: 0.5rem;
37 | align-items: center;
38 |
39 | @container (max-width: 650px) {
40 | width: 100%;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Dashboard/UncategorizedTransactionsCard/UncategorizedTransactionsCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | padding: 6px;
4 | }
5 |
6 | .scrollArea {
7 | padding: 6px;
8 | width: 100%;
9 | }
10 |
11 | .transactionList {
12 | gap: 6px;
13 | padding: 6px;
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/CompletedGoalsAccordion/CompletedGoalsAccordion.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion, Stack } from "@mantine/core";
2 | import React from "react";
3 | import { IGoalResponse } from "~/models/goal";
4 | import CompletedGoalCard from "./CompletedGoalCard/CompletedGoalCard";
5 |
6 | interface CompletedGoalsAccordionProps {
7 | compeltedGoals: IGoalResponse[];
8 | }
9 |
10 | const CompletedGoalsAccordion = (
11 | props: CompletedGoalsAccordionProps
12 | ): React.ReactNode => {
13 | return (
14 |
15 |
16 | Completed Goals
17 |
18 |
19 | {props.compeltedGoals.map((completedGoal) => (
20 |
21 | ))}
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default CompletedGoalsAccordion;
30 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/EditableGoalMonthlyAmountCell/EditableGoalMonthlyAmountCell.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | flex: 1 1 auto;
3 | justify-content: flex-end;
4 | align-items: center;
5 | gap: 0.25rem;
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/EditableGoalNameCell/EditableGoalNameCell.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | flex: 1 1 auto;
3 | align-items: center;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/EditableGoalNameCell/EditableGoalNameCell.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./EditableGoalNameCell.module.css";
2 |
3 | import { Flex, TextInput, Text } from "@mantine/core";
4 | import { IGoalResponse, IGoalUpdateRequest } from "~/models/goal";
5 | import React from "react";
6 |
7 | interface EditableGoalNameCellProps {
8 | goal: IGoalResponse;
9 | isSelected: boolean;
10 | editCell: (newGoal: IGoalUpdateRequest) => void;
11 | }
12 |
13 | const EditableGoalNameCell = (
14 | props: EditableGoalNameCellProps
15 | ): React.ReactNode => {
16 | const [goalNameValue, setGoalNameValue] = React.useState(
17 | props.goal.name
18 | );
19 |
20 | const onGoalNameBlur = (): void => {
21 | if (goalNameValue && goalNameValue.length > 0) {
22 | const newGoal: IGoalUpdateRequest = {
23 | ...props.goal,
24 | name: goalNameValue,
25 | };
26 | if (props.editCell != null) {
27 | props.editCell(newGoal);
28 | }
29 | } else {
30 | setGoalNameValue(props.goal.name);
31 | }
32 | };
33 |
34 | return (
35 |
36 | {props.isSelected ? (
37 | setGoalNameValue(e.currentTarget.value)}
40 | onBlur={onGoalNameBlur}
41 | onClick={(e) => e.stopPropagation()}
42 | />
43 | ) : (
44 |
45 | {props.goal.name}
46 |
47 | )}
48 |
49 | );
50 | };
51 |
52 | export default EditableGoalNameCell;
53 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/EditableGoalTargetAmountCell/EditableGoalTargetAmountCell.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | flex: 1 1 auto;
3 | justify-content: flex-end;
4 | align-items: center;
5 | gap: 0.25rem;
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/EditableGoalTargetDateCell/EditableGoalTargetDateCell.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | flex: 1 1 auto;
3 | align-items: center;
4 | gap: 0.25rem;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/EditableGoalTargetDateCell/EditableGoalTargetDateCell.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./EditableGoalTargetDateCell.module.css";
2 |
3 | import { Flex, Text } from "@mantine/core";
4 | import { DatePickerInput, DateValue } from "@mantine/dates";
5 | import { IGoalResponse, IGoalUpdateRequest } from "~/models/goal";
6 | import React from "react";
7 |
8 | interface EditableGoalTargetDateCellProps {
9 | goal: IGoalResponse;
10 | isSelected: boolean;
11 | editCell: (newGoal: IGoalUpdateRequest) => void;
12 | }
13 |
14 | const EditableGoalTargetDateCell = (
15 | props: EditableGoalTargetDateCellProps
16 | ): React.ReactNode => {
17 | const [goalTargetDateValue, setGoalTargetDateValue] = React.useState(
18 | new Date(props.goal.completeDate)
19 | );
20 |
21 | const onDatePick = (date: DateValue): void => {
22 | if (date === null) {
23 | return;
24 | }
25 | setGoalTargetDateValue(date);
26 | const newGoal: IGoalUpdateRequest = {
27 | ...props.goal,
28 | completeDate: date,
29 | };
30 | if (props.editCell != null) {
31 | props.editCell(newGoal);
32 | }
33 | };
34 |
35 | return (
36 |
37 | Projected:
38 | {props.isSelected && props.goal.isCompleteDateEditable ? (
39 | {
41 | e.stopPropagation();
42 | }}
43 | >
44 |
49 |
50 | ) : (
51 |
52 | {new Date(props.goal.completeDate).toLocaleDateString("en-US", {
53 | year: "numeric",
54 | month: "long",
55 | })}
56 |
57 | )}
58 |
59 | );
60 | };
61 |
62 | export default EditableGoalTargetDateCell;
63 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/GoalCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | width: 100%;
3 | padding: 0.5rem;
4 |
5 | &:hover {
6 | outline: 1px solid var(--mantine-primary-color-filled);
7 | }
8 | }
9 |
10 | .stack {
11 | gap: 0.1rem;
12 | width: 100%;
13 | }
14 |
15 | .topFlex {
16 | display: flex;
17 | direction: row;
18 | flex-wrap: wrap;
19 | margin-bottom: 0.5rem;
20 | justify-content: space-between;
21 | }
22 |
23 | .bottomFlex {
24 | display: flex;
25 | direction: row;
26 | flex-wrap: wrap;
27 | justify-content: space-between;
28 | }
29 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalCard/GoalDetails/GoalDetails.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Flex, Popover, Stack, Text } from "@mantine/core";
2 | import { IAccount } from "~/models/account";
3 | import { IGoalResponse } from "~/models/goal";
4 | import React from "react";
5 |
6 | interface GoalDetailsProps {
7 | goal: IGoalResponse;
8 | }
9 |
10 | const GoalDetails = (props: GoalDetailsProps): React.ReactNode => {
11 | return (
12 | e.stopPropagation()}>
13 |
14 |
15 |
18 |
19 |
20 |
21 |
22 | Accounts
23 |
24 |
25 | {props.goal.accounts.map((account: IAccount) => (
26 |
27 | {account.name}
28 |
29 | ))}
30 |
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default GoalDetails;
39 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/Goals.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | max-width: 1400px;
4 | }
5 |
6 | .goals {
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalsHeader/AddGoalModal/AddGoalModal.tsx:
--------------------------------------------------------------------------------
1 | import { Modal, Select } from "@mantine/core";
2 | import { GoalType } from "~/models/goal";
3 | import React from "react";
4 | import SaveGoalForm from "./SaveGoalForm/SaveGoalForm";
5 | import PayGoalForm from "./PayGoalForm/PayGoalForm";
6 |
7 | const goalTypes: { label: string; value: string }[] = [
8 | { label: "Grow my funds", value: GoalType.SaveGoal },
9 | { label: "Pay off debt", value: GoalType.PayGoal },
10 | ];
11 |
12 | interface AddGoalModalProps {
13 | isOpen: boolean;
14 | onClose: () => void;
15 | }
16 |
17 | const AddGoalModal = (props: AddGoalModalProps): React.ReactNode => {
18 | const [selectedGoalType, setSelectedGoalType] = React.useState(
19 | null
20 | );
21 | return (
22 |
34 |
43 | );
44 | };
45 |
46 | export default AddGoalModal;
47 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalsHeader/GoalsHeader.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | justify-content: flex-end;
4 | align-items: center;
5 | gap: 0.5rem;
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Goals/GoalsHeader/GoalsHeader.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./GoalsHeader.module.css";
2 |
3 | import { ActionIcon, Button, Group } from "@mantine/core";
4 | import { useDisclosure } from "@mantine/hooks";
5 | import { PlusIcon } from "lucide-react";
6 | import React from "react";
7 | import AddGoalModal from "./AddGoalModal/AddGoalModal";
8 |
9 | interface GoalsHeaderProps {
10 | includeInterest: boolean;
11 | toggleIncludeInterest: () => void;
12 | }
13 |
14 | const GoalsHeader = (props: GoalsHeaderProps): React.ReactNode => {
15 | const [isOpen, { toggle }] = useDisclosure();
16 |
17 | return (
18 |
19 |
20 |
27 |
28 |
29 |
30 |
31 | );
32 | };
33 |
34 | export default GoalsHeader;
35 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/PageContent.module.css:
--------------------------------------------------------------------------------
1 | .pageContentContainer {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | justify-content: flex-start;
6 | width: 100%;
7 | flex: 1;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/PageContent.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./PageContent.module.css";
2 |
3 | import { Flex } from "@mantine/core";
4 | import Budgets from "./Budgets/Budgets";
5 | import Dashboard from "./Dashboard/Dashboard";
6 | import Goals from "./Goals/Goals";
7 | import Settings from "./Settings/Settings";
8 | import Transactions from "./Transactions/Transactions";
9 | import Trends from "./Trends/Trends";
10 |
11 | export enum Pages {
12 | Dashboard,
13 | Transactions,
14 | Budgets,
15 | Goals,
16 | Trends,
17 | Settings,
18 | }
19 |
20 | interface PageContentProps {
21 | currentPage: Pages;
22 | }
23 |
24 | const PageContent = (props: PageContentProps): React.ReactNode => {
25 | const getPageContent = (page: Pages): React.ReactNode => {
26 | switch (page) {
27 | case Pages.Dashboard:
28 | return ;
29 | case Pages.Transactions:
30 | return ;
31 | case Pages.Budgets:
32 | return ;
33 | case Pages.Goals:
34 | return ;
35 | case Pages.Trends:
36 | return ;
37 | case Pages.Settings:
38 | return ;
39 | }
40 | };
41 |
42 | return (
43 |
44 | {getPageContent(props.currentPage)}
45 |
46 | );
47 | };
48 |
49 | export default PageContent;
50 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Settings/DarkModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | MantineColorScheme,
3 | NativeSelect,
4 | useMantineColorScheme,
5 | } from "@mantine/core";
6 |
7 | const DarkModeToggle = () => {
8 | const darkModeOptions = [
9 | { label: "Auto", value: "auto" },
10 | { label: "Light", value: "light" },
11 | { label: "Dark", value: "dark" },
12 | ];
13 | const { colorScheme, setColorScheme } = useMantineColorScheme();
14 |
15 | return (
16 |
21 | setColorScheme(event.currentTarget.value as MantineColorScheme)
22 | }
23 | />
24 | );
25 | };
26 |
27 | export default DarkModeToggle;
28 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Settings/Settings.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | max-width: 800px;
4 | padding: 40px;
5 | }
6 | .card {
7 | padding: 30px;
8 | gap: 20px;
9 | }
10 |
11 | .cardSection {
12 | display: flex;
13 | flex-direction: column;
14 | gap: 20px;
15 | }
16 |
17 | .form {
18 | display: flex;
19 | flex-direction: column;
20 | gap: 10px;
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Settings/Settings.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./Settings.module.css";
2 |
3 | import { Stack, Title } from "@mantine/core";
4 | import DarkModeToggle from "./DarkModeToggle";
5 | import LinkSimpleFin from "./LinkSimpleFin";
6 | import React from "react";
7 | import ResetPassword from "./ResetPassword";
8 | import UserSettings from "./UserSettings";
9 |
10 | const Settings = (): React.ReactNode => {
11 | return (
12 |
13 | Settings
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Settings;
23 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionCards.tsx/TransactionCard/EditableCategoryCell/EditableCategoryCell.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: start;
5 | }
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionCards.tsx/TransactionCard/TransactionCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | padding: 0.5rem;
3 | container-type: inline-size;
4 | width: 100%;
5 |
6 | &:hover {
7 | outline: 1px solid var(--mantine-primary-color-filled);
8 | }
9 | }
10 |
11 | .container {
12 | width: 100%;
13 | gap: 0.5rem;
14 | align-items: center;
15 | flex-direction: row;
16 |
17 | @container (max-width: 830px) {
18 | flex-direction: column;
19 | }
20 | }
21 |
22 | .subcontainer {
23 | gap: 0.5rem;
24 | align-items: center;
25 |
26 | @container (max-width: 830px) {
27 | width: 100%;
28 | }
29 | }
30 |
31 | .dateContainer {
32 | display: flex;
33 | flex-direction: column;
34 | align-items: start;
35 | }
36 |
37 | .merchantContainer {
38 | flex: 1 1 auto;
39 | }
40 |
41 | .categoryContainer {
42 | display: flex;
43 | flex-direction: column;
44 | align-items: start;
45 | }
46 |
47 | .amountContainer {
48 | display: flex;
49 | flex-direction: row;
50 | align-items: start;
51 | }
52 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionCards.tsx/TransactionCard/TransactionCard.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./TransactionCard.module.css";
2 |
3 | import { Card } from "@mantine/core";
4 | import { useDisclosure } from "@mantine/hooks";
5 | import { ITransaction } from "~/models/transaction";
6 | import React from "react";
7 | import { ICategory } from "~/models/category";
8 | import UnselectedTransactionCard from "./UnselectedTransactionCard/UnselectedTransactionCard";
9 | import SelectedTransactionCard from "./SelectedTransactionCard/SelectedTransactionCard";
10 |
11 | interface TransactionCardProps {
12 | transaction: ITransaction;
13 | categories: ICategory[];
14 | }
15 |
16 | const TransactionCard = (props: TransactionCardProps): React.ReactNode => {
17 | const [isSelected, { toggle }] = useDisclosure();
18 |
19 | return (
20 |
28 | {isSelected ? (
29 |
33 | ) : (
34 |
38 | )}
39 |
40 | );
41 | };
42 |
43 | export default TransactionCard;
44 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/Transactions.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | max-width: 1400px;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/Transactions.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./Transactions.module.css";
2 |
3 | import { Stack } from "@mantine/core";
4 | import TransactionsHeader from "./TransactionsHeader/TransactionsHeader";
5 | import React from "react";
6 | import { SortDirection } from "~/components/SortButton";
7 | import { defaultTransactionCategories, Filters } from "~/models/transaction";
8 | import { Sorts } from "./TransactionsHeader/SortMenu/SortMenuHelpers";
9 | import TransactionCards from "./TransactionCards.tsx/TransactionCards";
10 | import { AuthContext } from "~/components/AuthProvider/AuthProvider";
11 | import { useQuery } from "@tanstack/react-query";
12 | import { ICategoryResponse } from "~/models/category";
13 |
14 | const Transactions = (): React.ReactNode => {
15 | const [sort, setSort] = React.useState(Sorts.Date);
16 | const [sortDirection, setSortDirection] = React.useState(
17 | SortDirection.Decending
18 | );
19 | const [filters, setFilters] = React.useState(new Filters());
20 |
21 | const { request } = React.useContext(AuthContext);
22 | const transactionCategoriesQuery = useQuery({
23 | queryKey: ["transactionCategories"],
24 | queryFn: async () => {
25 | const res = await request({
26 | url: "/api/transactionCategory",
27 | method: "GET",
28 | });
29 |
30 | if (res.status === 200) {
31 | return res.data as ICategoryResponse[];
32 | }
33 |
34 | return undefined;
35 | },
36 | });
37 |
38 | const transactionCategoriesWithCustom = defaultTransactionCategories.concat(
39 | transactionCategoriesQuery.data ?? []
40 | );
41 |
42 | return (
43 |
44 |
53 |
58 |
59 | );
60 | };
61 |
62 | export default Transactions;
63 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/FilterCard/FilterCard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | }
4 | .group {
5 | display: flex;
6 | justify-content: space-between;
7 | align-items: center;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/FilterCard/FilterCard.tsx:
--------------------------------------------------------------------------------
1 | import CategorySelect from "~/components/CategorySelect";
2 | import classes from "./FilterCard.module.css";
3 |
4 | import AccountSelectInput from "~/components/AccountSelectInput";
5 | import { Card, Flex, Stack, Title } from "@mantine/core";
6 | import { DatePickerInput } from "@mantine/dates";
7 | import { Filters } from "~/models/transaction";
8 | import React from "react";
9 | import { ICategory } from "~/models/category";
10 |
11 | interface FilterCardProps {
12 | isOpen: boolean;
13 | categories: ICategory[];
14 | filters: Filters;
15 | setFilters: (newFilters: Filters) => void;
16 | }
17 |
18 | const FilterCard = (props: FilterCardProps): React.ReactNode => {
19 | if (!props.isOpen) {
20 | return null;
21 | }
22 |
23 | return (
24 |
25 |
26 | Filters
27 |
33 |
39 | props.setFilters({
40 | ...props.filters,
41 | dateRange,
42 | })
43 | }
44 | />
45 | {
49 | props.setFilters({
50 | ...props.filters,
51 | accounts: newAccountIds,
52 | });
53 | }}
54 | hideHidden
55 | />
56 |
61 | props.setFilters({ ...props.filters, category: val })
62 | }
63 | withinPortal
64 | />
65 |
66 |
67 |
68 | );
69 | };
70 |
71 | export default FilterCard;
72 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/ImportTransactionsModal/ColumnsSelect/ColumnsSelect.tsx:
--------------------------------------------------------------------------------
1 | import { Divider, Group, Select, Stack } from "@mantine/core";
2 |
3 | interface ColumnsSelectProps {
4 | columns: string[];
5 | date: string | null;
6 | description: string | null;
7 | category: string | null;
8 | amount: string | null;
9 | account: string | null;
10 | setColumn: (column: string, value: string) => void;
11 | }
12 |
13 | const ColumnsSelect = (props: ColumnsSelectProps): React.ReactNode => {
14 | return (
15 |
16 |
17 |
18 |
54 |
55 | );
56 | };
57 |
58 | export default ColumnsSelect;
59 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/ImportTransactionsModal/CsvOptions/CsvOptions.tsx:
--------------------------------------------------------------------------------
1 | import { Divider, FileInput, Group, Stack, TextInput } from "@mantine/core";
2 | import React from "react";
3 |
4 | interface CsvOptionsProps {
5 | fileField: File | null;
6 | setFileField: (file: File | null) => void;
7 | delimiterField: string;
8 | setDelimiterField: (delimiter: string) => void;
9 | resetData: () => void;
10 | }
11 |
12 | const CsvOptions = (props: CsvOptionsProps): React.ReactNode => {
13 | const [delimiterValue, setDelimiterValue] = React.useState(
14 | props.delimiterField
15 | );
16 |
17 | React.useEffect(() => {
18 | setDelimiterValue(props.delimiterField);
19 | }, [props.delimiterField]);
20 |
21 | return (
22 |
23 |
24 |
25 | {
32 | if (value == null) {
33 | props.resetData();
34 | }
35 | props.setFileField(value);
36 | }}
37 | w="100%"
38 | miw={180}
39 | />
40 | setDelimiterValue(event.currentTarget.value)}
44 | onBlur={() => props.setDelimiterField(delimiterValue)}
45 | maxLength={1}
46 | minLength={1}
47 | maw={70}
48 | />
49 |
50 |
51 | );
52 | };
53 |
54 | export default CsvOptions;
55 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/SortMenu/SortMenu.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | gap: 0.5rem;
3 | }
4 |
5 | .sortButtons {
6 | gap: 0.5rem;
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/SortMenu/SortMenu.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./SortMenu.module.css";
2 |
3 | import SortButton, { SortDirection } from "~/components/SortButton";
4 | import { Flex, Group, Text } from "@mantine/core";
5 | import React from "react";
6 | import { SortOption, SortOptions, Sorts } from "./SortMenuHelpers";
7 |
8 | interface SortMenuProps {
9 | currentSort: Sorts;
10 | setCurrentSort: (newCurrentSort: Sorts) => void;
11 | sortDirection: SortDirection;
12 | setSortDirection: (newSortDirection: SortDirection) => void;
13 | }
14 |
15 | const SortMenu = (props: SortMenuProps): React.ReactNode => {
16 | const ToggleSortDirection = (sortDirection: SortDirection): SortDirection => {
17 | switch (sortDirection) {
18 | case SortDirection.None:
19 | return SortDirection.Ascending;
20 | case SortDirection.Ascending:
21 | return SortDirection.Decending;
22 | case SortDirection.Decending:
23 | return SortDirection.Ascending;
24 | }
25 | };
26 |
27 | return (
28 |
33 | Sort By
34 |
35 | {SortOptions.map((sortOption: SortOption) => (
36 | {
46 | if (sortOption.value !== props.currentSort) {
47 | // When selecting a new sort option, should always go to decending.
48 | props.setSortDirection(SortDirection.Decending);
49 | } else {
50 | props.setSortDirection(
51 | ToggleSortDirection(props.sortDirection)
52 | );
53 | }
54 | props.setCurrentSort(sortOption.value);
55 | }}
56 | />
57 | ))}
58 |
59 |
60 | );
61 | };
62 |
63 | export default SortMenu;
64 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/SortMenu/SortMenuHelpers.ts:
--------------------------------------------------------------------------------
1 | export enum Sorts {
2 | Date,
3 | Merchant,
4 | Category,
5 | Amount,
6 | }
7 |
8 | export interface SortOption {
9 | value: Sorts;
10 | label: string;
11 | }
12 |
13 | export const SortOptions: SortOption[] = [
14 | {
15 | value: Sorts.Date,
16 | label: "Date",
17 | },
18 | {
19 | value: Sorts.Merchant,
20 | label: "Merchant",
21 | },
22 | {
23 | value: Sorts.Category,
24 | label: "Category",
25 | },
26 | {
27 | value: Sorts.Amount,
28 | label: "Amount",
29 | },
30 | ];
31 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/TransactionsHeader.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | }
4 |
5 | .header {
6 | width: 100%;
7 | display: flex;
8 | flex-direction: row;
9 | justify-content: space-between;
10 | align-items: center;
11 | margin-bottom: 20px;
12 | }
13 |
14 | .buttonGroup {
15 | gap: 0.5rem;
16 | justify-content: flex-end;
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/TransactionsSettings/CustomCategories/CustomCategories.module.css:
--------------------------------------------------------------------------------
1 | .nameContainer {
2 | width: 40%;
3 | }
4 |
5 | .parentContainer {
6 | width: 40%;
7 | }
8 |
9 | .deleteContainer {
10 | justify-content: flex-end;
11 | flex-grow: 1;
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/TransactionsSettings/CustomCategories/CustomCategories.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./CustomCategories.module.css";
2 |
3 | import { Flex, Group, Stack, Text } from "@mantine/core";
4 | import React from "react";
5 | import AddCategory from "./AddCategory/AddCategory";
6 | import { AuthContext } from "~/components/AuthProvider/AuthProvider";
7 | import { useQuery } from "@tanstack/react-query";
8 | import { ICategoryResponse } from "~/models/category";
9 | import CustomCategoryCard from "./CustomCategoryCard/CustomCategoryCard";
10 | import { defaultTransactionCategories } from "~/models/transaction";
11 |
12 | const CustomCategories = (): React.ReactNode => {
13 | const { request } = React.useContext(AuthContext);
14 | const transactionCategoriesQuery = useQuery({
15 | queryKey: ["transactionCategories"],
16 | queryFn: async () => {
17 | const res = await request({
18 | url: "/api/transactionCategory",
19 | method: "GET",
20 | });
21 |
22 | if (res.status === 200) {
23 | return res.data as ICategoryResponse[];
24 | }
25 |
26 | return undefined;
27 | },
28 | });
29 |
30 | const transactionCategoriesWithCustom = defaultTransactionCategories.concat(
31 | transactionCategoriesQuery.data ?? []
32 | );
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
40 | Name
41 |
42 |
43 | Parent
44 |
45 |
46 |
47 |
48 |
49 | {(transactionCategoriesQuery.data ?? []).length > 0 ? (
50 | transactionCategoriesQuery.data?.map(
51 | (category: ICategoryResponse) => (
52 |
53 | )
54 | )
55 | ) : (
56 | No custom categories.
57 | )}
58 |
59 |
60 | );
61 | };
62 |
63 | export default CustomCategories;
64 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/TransactionsSettings/CustomCategories/CustomCategoryCard/CustomCategoryCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | padding: 0.5rem;
3 | background-color: light-dark(
4 | var(--mantine-color-gray-3),
5 | var(--mantine-color-dark-5)
6 | );
7 | }
8 |
9 | .group {
10 | align-items: center;
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Transactions/TransactionsHeader/TransactionsSettings/DeletedTransactionCard/DeletedTransactionsCard.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | padding: 0.5rem;
3 | background-color: light-dark(
4 | var(--mantine-color-gray-3),
5 | var(--mantine-color-dark-5)
6 | );
7 | }
8 |
9 | .container {
10 | flex-wrap: nowrap;
11 | align-items: center;
12 | justify-content: space-between;
13 | }
14 |
15 | .transactionDetails {
16 | gap: 0rem;
17 | }
18 |
19 | .daysSinceDeleted {
20 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
21 | }
22 |
23 | .buttonGroup {
24 | align-self: stretch;
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/AssetsTab/AssetsTab.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | gap: 1rem;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/LiabilitiesTab/LiabilitiesTab.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | gap: 1rem;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/NetCashFlowTab/NetCashFlowTab.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/NetWorthTab/NetWorthTab.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | gap: 1rem;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/SpendingTab/SpendingTab.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem;
3 | gap: 1rem;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/Trends.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | max-width: 1400px;
4 | padding: 12px;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/app/Authorized/PageContent/Trends/Trends.tsx:
--------------------------------------------------------------------------------
1 | import classes from "./Trends.module.css";
2 |
3 | import { Stack, Tabs } from "@mantine/core";
4 | import React from "react";
5 | import SpendingTab from "./SpendingTab/SpendingTab";
6 | import NetCashFlowTab from "./NetCashFlowTab/NetCashFlowTab";
7 | import AssetsTab from "./AssetsTab/AssetsTab";
8 | import LiabilitiesTab from "./LiabilitiesTab/LiabilitiesTab";
9 | import NetWorthTab from "./NetWorthTab/NetWorthTab";
10 |
11 | const Trends = (): React.ReactNode => {
12 | return (
13 |
14 |
15 |
16 | Spending
17 | Net Cash Flow
18 | Assets
19 | Liabilities
20 | Net Worth
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default Trends;
43 |
--------------------------------------------------------------------------------
/client/src/app/Unauthorized/Welcome.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | justify-content: center;
6 | width: 100%;
7 | gap: 1rem;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/app/Unauthorized/Welcome.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BudgetBoardLogo from "~/assets/budget-board-logo";
3 | import {
4 | Container,
5 | Text,
6 | Stack,
7 | Group,
8 | Anchor,
9 | useComputedColorScheme,
10 | Card,
11 | } from "@mantine/core";
12 | import Register from "./Register";
13 | import Login from "./Login";
14 | import ResetPassword from "./ResetPassword";
15 |
16 | export enum LoginCardState {
17 | Login,
18 | ResetPassword,
19 | Register,
20 | }
21 |
22 | const Welcome = (): React.ReactNode => {
23 | const [loginCardState, setLoginCardState] = React.useState(
24 | LoginCardState.Login
25 | );
26 | const [userEmail, setUserEmail] = React.useState("");
27 | const computedColorScheme = useComputedColorScheme();
28 |
29 | const getCardState = (): React.ReactNode => {
30 | switch (loginCardState) {
31 | case LoginCardState.Login:
32 | return (
33 |
37 | );
38 | case LoginCardState.ResetPassword:
39 | return (
40 |
44 | );
45 | case LoginCardState.Register:
46 | return ;
47 | default:
48 | return <>There was an error.>;
49 | }
50 | };
51 |
52 | return (
53 |
54 |
55 | Welcome to
56 |
60 | A simple app for managing monthly budgets.
61 |
62 |
63 | {getCardState()}
64 |
65 | {loginCardState !== LoginCardState.Register && (
66 |
67 | Don't have an account?
68 | setLoginCardState(LoginCardState.Register)}
71 | >
72 | Register here
73 |
74 |
75 | )}
76 |
77 | );
78 | };
79 |
80 | export default Welcome;
81 |
--------------------------------------------------------------------------------
/client/src/components/AuthProvider/AuthorizedRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { AuthContext } from "./AuthProvider";
3 | import { Navigate } from "react-router";
4 | import { Center, Loader } from "@mantine/core";
5 |
6 | interface AuthRouteProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | const AuthorizedRoute = (props: AuthRouteProps): React.ReactNode => {
11 | const { accessToken, loading } = useContext(AuthContext);
12 |
13 | if (loading) {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | if (accessToken) {
22 | return props.children;
23 | }
24 |
25 | return ;
26 | };
27 |
28 | export default AuthorizedRoute;
29 |
--------------------------------------------------------------------------------
/client/src/components/AuthProvider/UnauthorizedRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { AuthContext } from "./AuthProvider";
3 | import { Navigate } from "react-router";
4 | import { Center, Loader } from "@mantine/core";
5 |
6 | interface UnauthorizedRouteProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | const UnauthorizedRoute = (props: UnauthorizedRouteProps): React.ReactNode => {
11 | const { accessToken, loading } = useContext(AuthContext);
12 |
13 | if (loading) {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | if (!accessToken) {
22 | return props.children;
23 | }
24 |
25 | return ;
26 | };
27 |
28 | export default UnauthorizedRoute;
29 |
--------------------------------------------------------------------------------
/client/src/components/MonthToolcards/MonthToolcard/MonthToolcard.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | height: 62px;
3 | width: 60px;
4 | grow: 0;
5 | shrink: 0;
6 | padding: 0.125rem;
7 | gap: 0;
8 | background-color: light-dark(
9 | var(--mantine-color-white),
10 | var(--mantine-color-dark-8)
11 | );
12 |
13 | &:hover {
14 | outline: 1px solid var(--mantine-primary-color-filled);
15 | }
16 | }
17 |
18 | .rootSelected {
19 | height: 62px;
20 | width: 60px;
21 | grow: 0;
22 | shrink: 0;
23 | padding: 0.125rem;
24 | gap: 0;
25 | background-color: var(--mantine-primary-color-light);
26 | outline: 1px solid var(--mantine-color-dark-2);
27 |
28 | &:hover {
29 | outline: 1px solid var(--mantine-primary-color-filled);
30 | }
31 | }
32 |
33 | .content {
34 | gap: 0;
35 | user-select: none;
36 | }
37 |
38 | .indicator {
39 | height: 20px;
40 | width: 100%;
41 | }
42 |
--------------------------------------------------------------------------------
/client/src/components/MonthToolcards/MonthToolcard/MonthToolcard.tsx:
--------------------------------------------------------------------------------
1 | import { months } from "~/helpers/utils";
2 | import classes from "./MonthToolcard.module.css";
3 |
4 | import { Card, Paper, Stack, Text } from "@mantine/core";
5 | import { CashFlowValue } from "~/models/budget";
6 |
7 | interface MonthToolcardProps {
8 | date: Date;
9 | cashFlowValue: CashFlowValue;
10 | isSelected: boolean;
11 | isPending?: boolean;
12 | handleClick: (date: Date) => void;
13 | }
14 |
15 | const MonthToolcard = (props: MonthToolcardProps): React.ReactNode => {
16 | const getLightColor = (
17 | cashFlowValue: CashFlowValue,
18 | isSelected: boolean,
19 | isPending?: boolean
20 | ): string => {
21 | if (isSelected) {
22 | if (isPending) {
23 | return "var(--mantine-color-dimmed)";
24 | }
25 |
26 | switch (cashFlowValue) {
27 | case CashFlowValue.Positive:
28 | return "green";
29 | case CashFlowValue.Neutral:
30 | return "var(--mantine-color-dimmed)";
31 | case CashFlowValue.Negative:
32 | return "red";
33 | }
34 | }
35 | return "var(--mantine-color-dimmed)";
36 | };
37 |
38 | return (
39 | props.handleClick(props.date)}
44 | >
45 |
46 |
51 |
52 | {months.at(props.date.getMonth())?.substring(0, 3)}
53 |
54 |
55 | {props.date.getFullYear()}
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default MonthToolcard;
63 |
--------------------------------------------------------------------------------
/client/src/components/MonthToolcards/MonthToolcards.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | flex-direction: row;
4 | flex-wrap: nowrap;
5 | gap: 0.5rem;
6 | }
7 |
8 | .pageButton {
9 | height: 62px;
10 | width: 32px;
11 | flex: 0 0;
12 | }
13 |
14 | .monthCards {
15 | display: flex;
16 | flex-direction: row-reverse;
17 | flex-grow: 1;
18 | flex-wrap: nowrap;
19 | justify-content: space-around;
20 | gap: 0;
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/components/SortButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@mantine/core";
2 | import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react";
3 | import React from "react";
4 |
5 | export enum SortDirection {
6 | None,
7 | Ascending,
8 | Decending,
9 | }
10 |
11 | interface SortButtonProps {
12 | label: string;
13 | sortDirection: SortDirection;
14 | onClick: () => void;
15 | [x: string]: any;
16 | }
17 |
18 | const SortButton = ({
19 | label,
20 | sortDirection,
21 | onClick,
22 | ...props
23 | }: SortButtonProps): React.ReactNode => {
24 | const getSortedIcon = (): React.ReactNode => {
25 | let sortedIcon = ;
26 | switch (sortDirection) {
27 | case SortDirection.Ascending:
28 | sortedIcon = ;
29 | break;
30 | case SortDirection.Decending:
31 | sortedIcon = ;
32 | break;
33 | default:
34 | break;
35 | }
36 |
37 | return sortedIcon;
38 | };
39 |
40 | return (
41 |
44 | );
45 | };
46 |
47 | export default SortButton;
48 |
--------------------------------------------------------------------------------
/client/src/components/Sortable/Sortable.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DndContext,
3 | KeyboardSensor,
4 | MouseSensor,
5 | TouchSensor,
6 | useSensor,
7 | useSensors,
8 | } from "@dnd-kit/core";
9 | import { arrayMove, SortableContext } from "@dnd-kit/sortable";
10 | import {
11 | restrictToVerticalAxis,
12 | restrictToParentElement,
13 | } from "@dnd-kit/modifiers";
14 |
15 | import React from "react";
16 |
17 | interface SortableProps {
18 | children: React.ReactNode;
19 | values: any[];
20 | onValueChange?: (values: any[]) => void;
21 | onMove?: (args: { activeIndex: number; overIndex: number }) => void;
22 | }
23 |
24 | const Sortable = (props: SortableProps): React.ReactNode => {
25 | const sensors = useSensors(
26 | useSensor(MouseSensor),
27 | useSensor(TouchSensor),
28 | useSensor(KeyboardSensor)
29 | );
30 |
31 | return (
32 | {
36 | if (over && active.id !== over?.id) {
37 | const activeIndex = props.values.findIndex(
38 | (item) => item.id === active.id
39 | );
40 | const overIndex = props.values.findIndex(
41 | (item) => item.id === over.id
42 | );
43 |
44 | if (props.onMove) {
45 | props.onMove({ activeIndex, overIndex });
46 | } else {
47 | props.onValueChange?.(
48 | arrayMove(props.values, activeIndex, overIndex)
49 | );
50 | }
51 | }
52 | }}
53 | >
54 | {props.children}
55 |
56 | );
57 | };
58 |
59 | export default Sortable;
60 |
--------------------------------------------------------------------------------
/client/src/components/Sortable/SortableHandle.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSortableItem } from "./SortableItem";
3 |
4 | interface SortableHandleProps {
5 | children?: React.ReactNode;
6 | [x: string]: any;
7 | }
8 |
9 | const SortableHandle = ({
10 | children,
11 | ...props
12 | }: SortableHandleProps): React.ReactNode => {
13 | const { attributes, listeners, isDragging } = useSortableItem();
14 |
15 | return (
16 |
22 | {children}
23 |
24 | );
25 | };
26 |
27 | export default SortableHandle;
28 |
--------------------------------------------------------------------------------
/client/src/components/Sortable/SortableItem.tsx:
--------------------------------------------------------------------------------
1 | import { DraggableSyntheticListeners, UniqueIdentifier } from "@dnd-kit/core";
2 | import { useSortable } from "@dnd-kit/sortable";
3 | import { CSS } from "@dnd-kit/utilities";
4 | import React from "react";
5 |
6 | interface SortableItemContextProps {
7 | attributes: React.HTMLAttributes;
8 | listeners: DraggableSyntheticListeners | undefined;
9 | isDragging?: boolean;
10 | }
11 |
12 | const SortableItemContext = React.createContext({
13 | attributes: {},
14 | listeners: undefined,
15 | isDragging: false,
16 | });
17 |
18 | export function useSortableItem() {
19 | const context = React.useContext(SortableItemContext);
20 |
21 | if (!context) {
22 | throw new Error("useSortableItem must be used within a SortableItem");
23 | }
24 |
25 | return context;
26 | }
27 |
28 | interface SortableItemProps {
29 | children: React.ReactNode;
30 | value: UniqueIdentifier;
31 | }
32 |
33 | const SortableItem = (props: SortableItemProps): React.ReactNode => {
34 | const {
35 | attributes,
36 | listeners,
37 | setNodeRef,
38 | transform,
39 | transition,
40 | isDragging,
41 | } = useSortable({ id: props.value });
42 |
43 | const context = React.useMemo(
44 | () => ({
45 | attributes,
46 | listeners,
47 | isDragging,
48 | }),
49 | [attributes, listeners, isDragging]
50 | );
51 |
52 | const style = {
53 | opacity: isDragging ? 0.5 : 1,
54 | transform: CSS.Translate.toString(transform),
55 | transition,
56 | };
57 |
58 | return (
59 |
60 |
65 | {props.children}
66 |
67 |
68 | );
69 | };
70 |
71 | export default SortableItem;
72 |
--------------------------------------------------------------------------------
/client/src/helpers/accounts.ts:
--------------------------------------------------------------------------------
1 | import { IAccount } from "~/models/account";
2 |
3 | /**
4 | * Filters out accounts that are either hidden or marked as deleted.
5 | *
6 | * This function iterates through the provided array of accounts and excludes any account
7 | * that satisfies either of the following conditions:
8 | * - The account is marked as hidden (hideAccount is truthy).
9 | * - The account has a non-null deleted field.
10 | *
11 | * @param {IAccount[]} accounts - An array of account objects to filter.
12 | * @returns {IAccount[]} An array containing only the visible accounts.
13 | */
14 | export const filterVisibleAccounts = (accounts: IAccount[]): IAccount[] =>
15 | accounts.filter((a: IAccount) => !(a.hideAccount || a.deleted !== null));
16 |
17 | /**
18 | * Calculates and returns the total current balance from an array of account objects.
19 | *
20 | * @param accounts - An array of objects implementing the IAccount interface.
21 | * @returns The sum of the currentBalance properties of all accounts. If the accounts array is empty, the function returns 0.
22 | */
23 | export const sumAccountsTotalBalance = (accounts: IAccount[]) => {
24 | if (accounts.length > 0) {
25 | return accounts.reduce((n, { currentBalance }) => n + currentBalance, 0);
26 | }
27 |
28 | return 0;
29 | };
30 |
31 | /**
32 | * Returns accounts matching any of the given types.
33 | *
34 | * @param {IAccount[]} accounts - List of account objects.
35 | * @param {string[]} types - List of types or subtypes to match.
36 | * @returns {IAccount[]} Filtered list of matching accounts.
37 | */
38 | export const getAccountsOfTypes = (
39 | accounts: IAccount[],
40 | types: string[]
41 | ): IAccount[] =>
42 | accounts.filter((a) => types?.includes(a.type) || types?.includes(a.subtype));
43 |
--------------------------------------------------------------------------------
/client/src/helpers/currency.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Converts a number to a formatted USD currency string.
3 | *
4 | * This function formats the provided number as a USD currency string using the Intl.NumberFormat API.
5 | * It allows including cents in the formatted output if specified.
6 | * Adding 0 to the number ensures that negative zero (-0) is avoided.
7 | *
8 | * @param {number} number - The numeric value to convert.
9 | * @param {boolean} [shouldIncludeCents] - Optional boolean flag to include cents in the output.
10 | * @returns {string} The number formatted as USD currency.
11 | */
12 | export const convertNumberToCurrency = (
13 | number: number,
14 | shouldIncludeCents: boolean,
15 | currency: string
16 | ) => {
17 | // Adding 0 to avoid -0 for the output.
18 | return new Intl.NumberFormat("en-US", {
19 | style: "currency",
20 | currency,
21 | maximumFractionDigits: shouldIncludeCents ? 2 : 0,
22 | minimumFractionDigits: shouldIncludeCents ? 2 : 0,
23 | }).format(number + 0);
24 | };
25 |
26 | /**
27 | * Returns the currency symbol for a given currency code.
28 | *
29 | * @param currency - The ISO 4217 currency code (e.g., "USD", "EUR").
30 | * @returns The corresponding currency symbol if defined, otherwise returns the currency code itself.
31 | *
32 | * @example
33 | * getCurrencySymbol("USD"); // Returns "$"
34 | * getCurrencySymbol("EUR"); // Returns "€"
35 | * getCurrencySymbol("INR"); // Returns "INR"
36 | */
37 | export const getCurrencySymbol = (currency?: string): string => {
38 | switch (currency) {
39 | case "USD":
40 | return "$";
41 | case "EUR":
42 | return "€";
43 | case "GBP":
44 | return "£";
45 | case "JPY":
46 | return "¥";
47 | case "CNY":
48 | return "CN¥";
49 | case "AUD":
50 | return "A$";
51 | case "CAD":
52 | return "CA$";
53 | case "NZD":
54 | return "NZ$";
55 | case null:
56 | case undefined:
57 | return ""; // Return an empty string if no currency is provided
58 | default:
59 | return currency; // Return the currency code if no symbol is defined
60 | }
61 | };
62 |
--------------------------------------------------------------------------------
/client/src/helpers/goals.ts:
--------------------------------------------------------------------------------
1 | import { IGoalResponse } from "~/models/goal";
2 | import { ITransaction } from "~/models/transaction";
3 |
4 | /**
5 | * Determines the target amount for a financial goal based on the specified amount and initial amount.
6 | *
7 | * If the initial amount is less than zero, it indicates a debt goal,
8 | * and the target amount becomes the absolute value of that negative initial amount (representing debt payoff).
9 | * Otherwise, it simply uses the passed-in amount.
10 | *
11 | * @param {number} amount - The user-defined goal amount.
12 | * @param {number} initialAmount - The initial balance or debt amount.
13 | * @returns {number} The target amount for the goal.
14 | */
15 | export const getGoalTargetAmount = (
16 | amount: number,
17 | initialAmount: number
18 | ): number => {
19 | if (initialAmount < 0) {
20 | return Math.abs(initialAmount);
21 | }
22 | return amount;
23 | };
24 |
25 | /**
26 | * Summarizes transactions for a specific goal in a given month.
27 | *
28 | * The function filters the provided transactions to include only those belonging
29 | * to accounts associated with the specified goal, then sums up their amounts.
30 | *
31 | * @param {IGoalResponse} goal - The goal object containing the list of associated accounts.
32 | * @param {ITransaction[]} transactionsForMonth - Transactions to evaluate for the given month.
33 | * @returns {number} The total sum of transaction amounts matching the goal's accounts.
34 | */
35 | export const sumTransactionsForGoalForMonth = (
36 | goal: IGoalResponse,
37 | transactionsForMonth: ITransaction[]
38 | ): number =>
39 | transactionsForMonth
40 | .filter((t) => goal.accounts.some((a) => a.id === t.accountID))
41 | .reduce((n, { amount }) => n + amount, 0);
42 |
--------------------------------------------------------------------------------
/client/src/helpers/requests.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError, AxiosResponse } from "axios";
2 |
3 | export interface ValidationError {
4 | title: string;
5 | type: string;
6 | status: number;
7 | errors: object;
8 | }
9 |
10 | /**
11 | * Retrieves a string error message from the provided Axios response.
12 | *
13 | * @param {AxiosResponse | undefined} error - The Axios response from which to extract the error message.
14 | * @returns {string} A string error message if available, or a default error message.
15 | */
16 | const getErrorString = (error: AxiosResponse | undefined): string => {
17 | if (typeof error?.data === "string" && error?.data.length > 0) {
18 | return error.data;
19 | }
20 | return "There was an error with your request.";
21 | };
22 |
23 | /**
24 | * Translates an AxiosError object into a human-readable error message.
25 | *
26 | * @param {AxiosError} error - The error object from an Axios request.
27 | * @returns {string} A human-readable error message.
28 | *
29 | * The function handles specific Axios error codes:
30 | * - "ERR_BAD_REQUEST"
31 | * - "ERR_NETWORK"
32 | * - "ERR_BAD_RESPONSE"
33 | *
34 | * For these error types, it extracts a detailed error message from the response data using getErrorString.
35 | * If none of these specific error codes are matched, it returns a generic unspecified error message.
36 | */
37 | export const translateAxiosError = (error: AxiosError): string => {
38 | if (error.code === "ERR_BAD_REQUEST") {
39 | return getErrorString(error.response);
40 | } else if (error.code === "ERR_NETWORK") {
41 | return getErrorString(error.response);
42 | } else if (error.code === "ERR_BAD_RESPONSE") {
43 | return getErrorString(error.response);
44 | }
45 | return "An unspecified error occurred.";
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/helpers/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Checks if two strings are equal ignoring case.
3 | *
4 | * @param {string} string1 - The first string to compare.
5 | * @param {string} string2 - The second string to compare.
6 | * @returns {boolean} True if the strings are equal (ignoring case), false otherwise.
7 | */
8 | export const areStringsEqual = (string1: string, string2: string): boolean =>
9 | string1.toUpperCase() === string2.toUpperCase();
10 |
11 | /**
12 | * Calculates the progress percentage of an amount towards a total.
13 | *
14 | * @param {number} amount - The current amount achieved.
15 | * @param {number} total - The total goal amount.
16 | * @returns {number} The progress as a percentage, capped at 100 if exceeded.
17 | */
18 | export const getProgress = (amount: number, total: number): number => {
19 | const percentage = (amount / total) * 100;
20 | return percentage > 100 ? 100 : percentage;
21 | };
22 |
23 | /**
24 | * Returns the full name of a month given its index.
25 | */
26 | export const months = [...Array(12).keys()].map((key) =>
27 | new Date(0, key).toLocaleString("en", { month: "long" })
28 | );
29 |
30 | /**
31 | * Rounds a number away from zero. For positive numbers, it behaves like Math.round.
32 | * For negative numbers, it rounds away from zero instead of towards zero.
33 | *
34 | * @param {number} value - The number to round.
35 | */
36 | export const roundAwayFromZero = (value: number): number =>
37 | value >= 0 ? Math.round(value) : Math.round(value * -1) * -1;
38 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | place-items: center;
28 | min-width: 320px;
29 | min-height: 100vh;
30 | }
31 |
32 | h1 {
33 | font-size: 3.2em;
34 | line-height: 1.1;
35 | }
36 |
37 | button {
38 | border-radius: 8px;
39 | border: 1px solid transparent;
40 | padding: 0.6em 1.2em;
41 | font-size: 1em;
42 | font-weight: 500;
43 | font-family: inherit;
44 | background-color: #1a1a1a;
45 | cursor: pointer;
46 | transition: border-color 0.25s;
47 | }
48 | button:hover {
49 | border-color: #646cff;
50 | }
51 | button:focus,
52 | button:focus-visible {
53 | outline: 4px auto -webkit-focus-ring-color;
54 | }
55 |
56 | @media (prefers-color-scheme: light) {
57 | :root {
58 | color: #213547;
59 | background-color: #ffffff;
60 | }
61 | a:hover {
62 | color: #747bff;
63 | }
64 | button {
65 | background-color: #f9f9f9;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import "./index.css";
4 | import App from "~/App";
5 |
6 | createRoot(document.getElementById("root")!).render(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/client/src/models/applicationUser.ts:
--------------------------------------------------------------------------------
1 | export interface IApplicationUser {
2 | id: string;
3 | accessToken: boolean;
4 | lastSync: Date;
5 | }
6 |
7 | export interface IUserInfoResponse {
8 | email: string;
9 | isEmailConfirmed: boolean;
10 | }
11 |
12 | export const defaultGuid: string = '00000000-0000-0000-0000-000000000000';
13 |
--------------------------------------------------------------------------------
/client/src/models/balance.ts:
--------------------------------------------------------------------------------
1 | export interface IBalance {
2 | id: string;
3 | amount: number;
4 | dateTime: Date;
5 | accountID: string;
6 | }
7 |
8 | export interface IBalanceCreateRequest {
9 | amount: number;
10 | dateTime: Date;
11 | accountID: string;
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/models/budget.ts:
--------------------------------------------------------------------------------
1 | export interface IBudgetCreateRequest {
2 | date: Date;
3 | category: string;
4 | limit: number;
5 | }
6 |
7 | export interface IBudgetUpdateRequest {
8 | id: string;
9 | limit: number;
10 | }
11 |
12 | export interface IBudget {
13 | id: string;
14 | date: Date;
15 | category: string;
16 | limit: number;
17 | userId: string;
18 | }
19 |
20 | export enum CashFlowValue {
21 | Positive,
22 | Neutral,
23 | Negative,
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/models/category.ts:
--------------------------------------------------------------------------------
1 | export interface ICategory {
2 | value: string;
3 | parent: string;
4 | }
5 |
6 | export interface ICategoryCreateRequest extends ICategory {}
7 |
8 | export interface ICategoryUpdateRequest extends ICategory {
9 | id: string;
10 | }
11 |
12 | export interface ICategoryResponse extends ICategory {
13 | id: string;
14 | userId: string;
15 | }
16 |
17 | export interface ICategoryNode extends ICategory {
18 | subCategories: ICategoryNode[];
19 | }
20 |
21 | export class CategoryNode implements ICategoryNode {
22 | subCategories: ICategoryNode[];
23 | value: string;
24 | parent: string;
25 |
26 | constructor(category?: ICategory) {
27 | this.value = category?.value ?? "";
28 | this.parent = category?.parent ?? "";
29 | this.subCategories = [];
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/client/src/models/goal.ts:
--------------------------------------------------------------------------------
1 | import { IAccount } from "./account";
2 |
3 | export interface IGoalCreateRequest {
4 | name: string;
5 | completeDate: Date | null;
6 | amount: number;
7 | initialAmount: number | null;
8 | monthlyContribution: number | null;
9 | accountIds: string[];
10 | }
11 |
12 | export interface IGoalUpdateRequest {
13 | id: string;
14 | name: string;
15 | completeDate: Date | null;
16 | isCompleteDateEditable: boolean;
17 | amount: number;
18 | monthlyContribution: number | null;
19 | isMonthlyContributionEditable: boolean;
20 | }
21 |
22 | export interface IGoalResponse {
23 | id: string;
24 | name: string;
25 | completeDate: Date;
26 | isCompleteDateEditable: boolean;
27 | amount: number;
28 | initialAmount: number;
29 | monthlyContribution: number;
30 | isMonthlyContributionEditable: boolean;
31 | interestRate: number | null;
32 | completed: Date | null;
33 | accounts: IAccount[];
34 | userID: string;
35 | }
36 |
37 | export enum GoalType {
38 | None = "",
39 | SaveGoal = "saveGoal",
40 | PayGoal = "payGoal",
41 | }
42 |
43 | export enum GoalCondition {
44 | TimedGoal = "timedGoal",
45 | MonthlyGoal = "monthlyGoal",
46 | }
47 |
48 | export enum GoalTarget {
49 | TargetBalanceGoal = "targetBalanceGoal",
50 | TargetAmountGoal = "targetAmountGoal",
51 | }
52 |
--------------------------------------------------------------------------------
/client/src/models/institution.ts:
--------------------------------------------------------------------------------
1 | import { IAccount } from "./account";
2 |
3 | export interface IInstitution {
4 | id: string;
5 | name: string;
6 | index: number;
7 | deleted: Date | null;
8 | userID: string;
9 | accounts: IAccount[];
10 | }
11 |
12 | export interface InstitutionIndexRequest {
13 | id: string;
14 | index: number;
15 | }
16 |
17 | export interface IInstitutionCreateRequest {
18 | name: string;
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/models/userSettings.ts:
--------------------------------------------------------------------------------
1 | export interface IUserSettings {
2 | currency: string;
3 | }
4 |
5 | export interface IUserSettingsUpdateRequest {
6 | currency: string;
7 | }
8 |
9 | export enum Currency {
10 | USD = "USD",
11 | EUR = "EUR",
12 | GBP = "GBP",
13 | JPY = "JPY",
14 | AUD = "AUD",
15 | CAD = "CAD",
16 | CHF = "CHF",
17 | CNY = "CNY",
18 | SEK = "SEK",
19 | NZD = "NZD",
20 | }
21 |
--------------------------------------------------------------------------------
/client/src/shared/projectEnvVariables.ts:
--------------------------------------------------------------------------------
1 | type ProjectEnvVariablesType = Pick;
2 |
3 | // Environment Variable Template to Be Replaced at Runtime
4 | const projectEnvVariables: ProjectEnvVariablesType = {
5 | // eslint-disable-next-line no-template-curly-in-string
6 | VITE_API_URL: "${VITE_API_URL}",
7 | };
8 |
9 | // Returning the variable value from runtime or obtained as a result of the build
10 | export const getProjectEnvVariables = (): {
11 | envVariables: ProjectEnvVariablesType;
12 | } => {
13 | return {
14 | envVariables: {
15 | VITE_API_URL: !projectEnvVariables.VITE_API_URL.includes("VITE_")
16 | ? projectEnvVariables.VITE_API_URL
17 | : import.meta.env.VITE_API_URL,
18 | },
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_API_URL: string;
5 | }
6 |
7 | interface ImportMeta {
8 | readonly env: ImportMetaEnv;
9 | }
10 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2024",
4 | "useDefineForClassFields": true,
5 | "lib": ["ESNext", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true,
23 | "noUncheckedIndexedAccess": true,
24 | "forceConsistentCasingInFileNames": true,
25 | "paths": {
26 | "~/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["src"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [
8 | react({
9 | babel: {
10 | plugins: [["babel-plugin-react-compiler", { target: "19" }]],
11 | },
12 | }),
13 | ],
14 | build: {
15 | rollupOptions: {
16 | output: {
17 | format: "es",
18 | globals: {
19 | react: "React",
20 | "react-dom": "ReactDOM",
21 | },
22 | manualChunks(id) {
23 | if (/projectEnvVariables.ts/.test(id)) {
24 | return "projectEnvVariables";
25 | }
26 | },
27 | },
28 | },
29 | },
30 | resolve: {
31 | alias: {
32 | "~": path.resolve(__dirname, "./src"),
33 | },
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/compose.override.yml:
--------------------------------------------------------------------------------
1 | services:
2 | budget-board-server:
3 | environment:
4 | POSTGRES_HOST: budget-board-db
5 | POSTGRES_DATABASE: budgetboard
6 | POSTGRES_USER: postgres
7 | POSTGRES_PASSWORD: superSecretPassword
8 | AUTO_UPDATE_DB: true
9 | DISABLE_NEW_USERS: false
10 |
11 | # Email confirmation is optional. See the README for more details.
12 | # EMAIL_SENDER: example@gmail.com
13 | # EMAIL_SENDER_PASSWORD: appPassword
14 | # EMAIL_SMTP_HOST: smtp.gmail.com
15 |
16 | # If you want to disable the scheduled job that automatically
17 | # syncs SimpleFIN data every 12 hours, set this to true.
18 | # DISABLE_AUTO_SYNC: true
19 | budget-board-db:
20 | environment:
21 | POSTGRES_USER: postgres
22 | POSTGRES_PASSWORD: superSecretPassword
23 | POSTGRES_DB: budgetboard
24 |
--------------------------------------------------------------------------------
/compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | budget-board-server:
3 | container_name: budget-board-server
4 | restart: unless-stopped
5 | image: ghcr.io/teelur/budget-board/server:release
6 | build: ./server
7 | environment:
8 | Logging__LogLevel__Default: Information
9 | CLIENT_URL: budget-board-client
10 | POSTGRES_HOST: budget-board-db
11 | POSTGRES_DATABASE: budgetboard
12 | POSTGRES_USER: postgres
13 | POSTGRES_PASSWORD: superSecretPassword
14 | AUTO_UPDATE_DB: true
15 | networks:
16 | - budget-board-network
17 | depends_on:
18 | - budget-board-db
19 | budget-board-client:
20 | container_name: budget-board-client
21 | restart: unless-stopped
22 | image: ghcr.io/teelur/budget-board/client:release
23 | build: ./client
24 | environment:
25 | VITE_API_URL: budget-board-client
26 | PORT: 6253
27 | ports:
28 | - 6253:6253
29 | networks:
30 | - budget-board-network
31 | depends_on:
32 | - budget-board-server
33 | budget-board-db:
34 | container_name: postgres-db
35 | image: postgres:16
36 | restart: unless-stopped
37 | environment:
38 | POSTGRES_USER: postgres
39 | POSTGRES_PASSWORD: superSecretPassword
40 | POSTGRES_DB: budgetboard
41 | ports:
42 | - "6252:5432"
43 | volumes:
44 | - db-data:/var/lib/postgresql/data
45 | networks:
46 | - budget-board-network
47 |
48 | volumes:
49 | db-data:
50 |
51 | networks:
52 | budget-board-network:
53 | name: budget-board-network
54 | driver: bridge
55 |
--------------------------------------------------------------------------------
/img/budget-board-budgets.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teelur/budget-board/f497debd7c92c03dea4dc0138ce1590ea168fe8d/img/budget-board-budgets.png
--------------------------------------------------------------------------------
/img/budget-board-dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teelur/budget-board/f497debd7c92c03dea4dc0138ce1590ea168fe8d/img/budget-board-dashboard.png
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/BudgetBoard.Database.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0
5 | enable
6 | enable
7 | 56a11503-c5b0-42c8-aa2e-847462ecf36c
8 |
9 |
10 |
11 |
12 |
13 |
14 | all
15 | runtime; build; native; contentfiles; analyzers; buildtransitive
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240114181918_ChangeAmountToDecimal.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class ChangeAmountToDecimal : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AlterColumn(
14 | name: "Amount",
15 | table: "Transaction",
16 | type: "numeric",
17 | nullable: false,
18 | oldClrType: typeof(int),
19 | oldType: "integer");
20 | }
21 |
22 | ///
23 | protected override void Down(MigrationBuilder migrationBuilder)
24 | {
25 | migrationBuilder.AlterColumn(
26 | name: "Amount",
27 | table: "Transaction",
28 | type: "integer",
29 | nullable: false,
30 | oldClrType: typeof(decimal),
31 | oldType: "numeric");
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240120050640_UpdateTypeType.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class UpdateTypeType : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AlterColumn(
14 | name: "Type",
15 | table: "Account",
16 | type: "text",
17 | nullable: false,
18 | oldClrType: typeof(int),
19 | oldType: "integer");
20 |
21 | migrationBuilder.AlterColumn(
22 | name: "Subtype",
23 | table: "Account",
24 | type: "text",
25 | nullable: false,
26 | oldClrType: typeof(int),
27 | oldType: "integer");
28 | }
29 |
30 | ///
31 | protected override void Down(MigrationBuilder migrationBuilder)
32 | {
33 | migrationBuilder.AlterColumn(
34 | name: "Type",
35 | table: "Account",
36 | type: "integer",
37 | nullable: false,
38 | oldClrType: typeof(string),
39 | oldType: "text");
40 |
41 | migrationBuilder.AlterColumn(
42 | name: "Subtype",
43 | table: "Account",
44 | type: "integer",
45 | nullable: false,
46 | oldClrType: typeof(string),
47 | oldType: "text");
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240123044246_AddSubCategory.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddSubCategory : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "SubCategory",
15 | table: "Transaction",
16 | type: "text",
17 | nullable: true);
18 | }
19 |
20 | ///
21 | protected override void Down(MigrationBuilder migrationBuilder)
22 | {
23 | migrationBuilder.DropColumn(
24 | name: "SubCategory",
25 | table: "Transaction");
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240124031230_ChangeToOneWord.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class ChangeToOneWord : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.RenameColumn(
14 | name: "SubCategory",
15 | table: "Transaction",
16 | newName: "Subcategory");
17 | }
18 |
19 | ///
20 | protected override void Down(MigrationBuilder migrationBuilder)
21 | {
22 | migrationBuilder.RenameColumn(
23 | name: "Subcategory",
24 | table: "Transaction",
25 | newName: "SubCategory");
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240217002101_AddAccessToken.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddAccessToken : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "AccessToken",
15 | table: "User",
16 | type: "text",
17 | nullable: false,
18 | defaultValue: "");
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "AccessToken",
26 | table: "User");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240316022816_Add-Sync-ID.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddSyncID : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "SyncID",
15 | table: "Transaction",
16 | type: "text",
17 | nullable: true);
18 | }
19 |
20 | ///
21 | protected override void Down(MigrationBuilder migrationBuilder)
22 | {
23 | migrationBuilder.DropColumn(
24 | name: "SyncID",
25 | table: "Transaction");
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240316042033_Add-Sync-ID-account.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddSyncIDaccount : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "SyncID",
15 | table: "Account",
16 | type: "text",
17 | nullable: true);
18 | }
19 |
20 | ///
21 | protected override void Down(MigrationBuilder migrationBuilder)
22 | {
23 | migrationBuilder.DropColumn(
24 | name: "SyncID",
25 | table: "Account");
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240316195318_Add-Account-CurrentBalance.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddAccountCurrentBalance : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "CurrentBalance",
15 | table: "Account",
16 | type: "real",
17 | nullable: false,
18 | defaultValue: 0f);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "CurrentBalance",
26 | table: "Account");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240317163328_Add-LastSync.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddLastSync : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.AddColumn(
15 | name: "LastSync",
16 | table: "User",
17 | type: "timestamp with time zone",
18 | nullable: false,
19 | defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
20 | }
21 |
22 | ///
23 | protected override void Down(MigrationBuilder migrationBuilder)
24 | {
25 | migrationBuilder.DropColumn(
26 | name: "LastSync",
27 | table: "User");
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240331223918_AddBudgets.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddBudgets : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 |
14 | }
15 |
16 | ///
17 | protected override void Down(MigrationBuilder migrationBuilder)
18 | {
19 |
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240331225133_AddBudgetsFix.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddBudgetsFix : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.CreateTable(
15 | name: "Budget",
16 | columns: table => new
17 | {
18 | ID = table.Column(type: "uuid", nullable: false),
19 | Date = table.Column(type: "timestamp with time zone", nullable: false),
20 | Category = table.Column(type: "text", nullable: false),
21 | Limit = table.Column(type: "real", nullable: false),
22 | UserID = table.Column(type: "uuid", nullable: false)
23 | },
24 | constraints: table =>
25 | {
26 | table.PrimaryKey("PK_Budget", x => x.ID);
27 | table.ForeignKey(
28 | name: "FK_Budget_User_UserID",
29 | column: x => x.UserID,
30 | principalTable: "User",
31 | principalColumn: "ID",
32 | onDelete: ReferentialAction.Cascade);
33 | });
34 |
35 | migrationBuilder.CreateIndex(
36 | name: "IX_Budget_UserID",
37 | table: "Budget",
38 | column: "UserID");
39 | }
40 |
41 | ///
42 | protected override void Down(MigrationBuilder migrationBuilder)
43 | {
44 | migrationBuilder.DropTable(
45 | name: "Budget");
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240401020645_AddBudgetsDeleted.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddBudgetsDeleted : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.RenameColumn(
15 | name: "Date",
16 | table: "Budget",
17 | newName: "Created");
18 |
19 | migrationBuilder.AddColumn(
20 | name: "Deleted",
21 | table: "Budget",
22 | type: "timestamp with time zone",
23 | nullable: true);
24 | }
25 |
26 | ///
27 | protected override void Down(MigrationBuilder migrationBuilder)
28 | {
29 | migrationBuilder.DropColumn(
30 | name: "Deleted",
31 | table: "Budget");
32 |
33 | migrationBuilder.RenameColumn(
34 | name: "Created",
35 | table: "Budget",
36 | newName: "Date");
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240401040512_FixBudget.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class FixBudget : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.DropColumn(
15 | name: "Deleted",
16 | table: "Budget");
17 |
18 | migrationBuilder.RenameColumn(
19 | name: "Created",
20 | table: "Budget",
21 | newName: "Date");
22 | }
23 |
24 | ///
25 | protected override void Down(MigrationBuilder migrationBuilder)
26 | {
27 | migrationBuilder.RenameColumn(
28 | name: "Date",
29 | table: "Budget",
30 | newName: "Created");
31 |
32 | migrationBuilder.AddColumn(
33 | name: "Deleted",
34 | table: "Budget",
35 | type: "timestamp with time zone",
36 | nullable: true);
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240409004620_AddHideTransactionBool.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddHideTransactionBool : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "HideTransactions",
15 | table: "Account",
16 | type: "boolean",
17 | nullable: false,
18 | defaultValue: false);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "HideTransactions",
26 | table: "Account");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240502020354_AddHideAccount.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddHideAccount : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "HideAccount",
15 | table: "Account",
16 | type: "boolean",
17 | nullable: false,
18 | defaultValue: false);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "HideAccount",
26 | table: "Account");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240519182542_AddGoals.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddGoals : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.CreateTable(
15 | name: "Goal",
16 | columns: table => new
17 | {
18 | ID = table.Column(type: "uuid", nullable: false),
19 | Name = table.Column(type: "text", nullable: false),
20 | CompleteDate = table.Column(type: "timestamp with time zone", nullable: false),
21 | Amount = table.Column(type: "real", nullable: false),
22 | UserID = table.Column(type: "uuid", nullable: false)
23 | },
24 | constraints: table =>
25 | {
26 | table.PrimaryKey("PK_Goal", x => x.ID);
27 | table.ForeignKey(
28 | name: "FK_Goal_User_UserID",
29 | column: x => x.UserID,
30 | principalTable: "User",
31 | principalColumn: "ID",
32 | onDelete: ReferentialAction.Cascade);
33 | });
34 |
35 | migrationBuilder.CreateIndex(
36 | name: "IX_Goal_UserID",
37 | table: "Goal",
38 | column: "UserID");
39 | }
40 |
41 | ///
42 | protected override void Down(MigrationBuilder migrationBuilder)
43 | {
44 | migrationBuilder.DropTable(
45 | name: "Goal");
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240519204749_AddCategory.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddCategory : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.CreateTable(
15 | name: "Category",
16 | columns: table => new
17 | {
18 | ID = table.Column(type: "uuid", nullable: false),
19 | Label = table.Column(type: "text", nullable: false),
20 | Value = table.Column(type: "text", nullable: false),
21 | Parent = table.Column(type: "text", nullable: false),
22 | UserID = table.Column(type: "uuid", nullable: false)
23 | },
24 | constraints: table =>
25 | {
26 | table.PrimaryKey("PK_Category", x => x.ID);
27 | table.ForeignKey(
28 | name: "FK_Category_User_UserID",
29 | column: x => x.UserID,
30 | principalTable: "User",
31 | principalColumn: "ID",
32 | onDelete: ReferentialAction.Cascade);
33 | });
34 |
35 | migrationBuilder.CreateIndex(
36 | name: "IX_Category_UserID",
37 | table: "Category",
38 | column: "UserID");
39 | }
40 |
41 | ///
42 | protected override void Down(MigrationBuilder migrationBuilder)
43 | {
44 | migrationBuilder.DropTable(
45 | name: "Category");
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240520034903_UpdateGoal.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class UpdateGoal : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.AddColumn(
15 | name: "InitialAmount",
16 | table: "Goal",
17 | type: "real",
18 | nullable: false,
19 | defaultValue: 0f);
20 |
21 | migrationBuilder.CreateTable(
22 | name: "AccountGoal",
23 | columns: table => new
24 | {
25 | AccountsID = table.Column(type: "uuid", nullable: false),
26 | GoalsID = table.Column(type: "uuid", nullable: false)
27 | },
28 | constraints: table =>
29 | {
30 | table.PrimaryKey("PK_AccountGoal", x => new { x.AccountsID, x.GoalsID });
31 | table.ForeignKey(
32 | name: "FK_AccountGoal_Account_AccountsID",
33 | column: x => x.AccountsID,
34 | principalTable: "Account",
35 | principalColumn: "ID",
36 | onDelete: ReferentialAction.Cascade);
37 | table.ForeignKey(
38 | name: "FK_AccountGoal_Goal_GoalsID",
39 | column: x => x.GoalsID,
40 | principalTable: "Goal",
41 | principalColumn: "ID",
42 | onDelete: ReferentialAction.Cascade);
43 | });
44 |
45 | migrationBuilder.CreateIndex(
46 | name: "IX_AccountGoal_GoalsID",
47 | table: "AccountGoal",
48 | column: "GoalsID");
49 | }
50 |
51 | ///
52 | protected override void Down(MigrationBuilder migrationBuilder)
53 | {
54 | migrationBuilder.DropTable(
55 | name: "AccountGoal");
56 |
57 | migrationBuilder.DropColumn(
58 | name: "InitialAmount",
59 | table: "Goal");
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240524040826_UpdateGoalMonthlyContribution.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class UpdateGoalMonthlyContribution : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "MonthlyContribution",
15 | table: "Goal",
16 | type: "real",
17 | nullable: false,
18 | defaultValue: 0f);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "MonthlyContribution",
26 | table: "Goal");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20240821025811_AddBalanceDate.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddBalanceDate : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.AddColumn(
15 | name: "BalanceDate",
16 | table: "Account",
17 | type: "timestamp with time zone",
18 | nullable: true);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "BalanceDate",
26 | table: "Account");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20241119004148_AddBalances.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddBalances : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.CreateTable(
15 | name: "Balance",
16 | columns: table => new
17 | {
18 | ID = table.Column(type: "uuid", nullable: false),
19 | Amount = table.Column(type: "numeric", nullable: false),
20 | DateTime = table.Column(type: "timestamp with time zone", nullable: false),
21 | AccountID = table.Column(type: "uuid", nullable: false)
22 | },
23 | constraints: table =>
24 | {
25 | table.PrimaryKey("PK_Balance", x => x.ID);
26 | table.ForeignKey(
27 | name: "FK_Balance_Account_AccountID",
28 | column: x => x.AccountID,
29 | principalTable: "Account",
30 | principalColumn: "ID",
31 | onDelete: ReferentialAction.Cascade);
32 | });
33 |
34 | migrationBuilder.CreateIndex(
35 | name: "IX_Balance_AccountID",
36 | table: "Balance",
37 | column: "AccountID");
38 | }
39 |
40 | ///
41 | protected override void Down(MigrationBuilder migrationBuilder)
42 | {
43 | migrationBuilder.DropTable(
44 | name: "Balance");
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20241214190502_AddAccountIndex.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddAccountIndex : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "Index",
15 | table: "Account",
16 | type: "integer",
17 | nullable: false,
18 | defaultValue: 0);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "Index",
26 | table: "Account");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20241224001212_UpdateBudgetFloatToDecimal.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class UpdateBudgetFloatToDecimal : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AlterColumn(
14 | name: "Limit",
15 | table: "Budget",
16 | type: "numeric",
17 | nullable: false,
18 | oldClrType: typeof(float),
19 | oldType: "real");
20 | }
21 |
22 | ///
23 | protected override void Down(MigrationBuilder migrationBuilder)
24 | {
25 | migrationBuilder.AlterColumn(
26 | name: "Limit",
27 | table: "Budget",
28 | type: "real",
29 | nullable: false,
30 | oldClrType: typeof(decimal),
31 | oldType: "numeric");
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250106051133_DropDeleted.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class DropDeleted : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.DropColumn(
14 | name: "Deleted",
15 | table: "TransactionCategory");
16 | }
17 |
18 | ///
19 | protected override void Down(MigrationBuilder migrationBuilder)
20 | {
21 | migrationBuilder.AddColumn(
22 | name: "Deleted",
23 | table: "TransactionCategory",
24 | type: "boolean",
25 | nullable: false,
26 | defaultValue: false);
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250109054806_RemoveNullable.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class RemoveNullable : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AlterColumn(
14 | name: "InitialAmount",
15 | table: "Goal",
16 | type: "numeric",
17 | nullable: false,
18 | defaultValue: 0m,
19 | oldClrType: typeof(decimal),
20 | oldType: "numeric",
21 | oldNullable: true);
22 | }
23 |
24 | ///
25 | protected override void Down(MigrationBuilder migrationBuilder)
26 | {
27 | migrationBuilder.AlterColumn(
28 | name: "InitialAmount",
29 | table: "Goal",
30 | type: "numeric",
31 | nullable: true,
32 | oldClrType: typeof(decimal),
33 | oldType: "numeric");
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250222202242_RemoveUserUid.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class RemoveUserUid : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.DropColumn(
14 | name: "Uid",
15 | table: "User");
16 | }
17 |
18 | ///
19 | protected override void Down(MigrationBuilder migrationBuilder)
20 | {
21 | migrationBuilder.AddColumn(
22 | name: "Uid",
23 | table: "User",
24 | type: "text",
25 | nullable: false,
26 | defaultValue: "");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250322013118_AddDeletedToInstitution.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddDeletedToInstitution : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.AddColumn(
15 | name: "Deleted",
16 | table: "Institution",
17 | type: "timestamp with time zone",
18 | nullable: true);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "Deleted",
26 | table: "Institution");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250404235803_AddAccountSource.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddAccountSource : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "Source",
15 | table: "Account",
16 | type: "text",
17 | nullable: false,
18 | defaultValue: "");
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "Source",
26 | table: "Account");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250415230451_AddGoalCompleted.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.EntityFrameworkCore.Migrations;
3 |
4 | #nullable disable
5 |
6 | namespace BudgetBoard.Database.Migrations
7 | {
8 | ///
9 | public partial class AddGoalCompleted : Migration
10 | {
11 | ///
12 | protected override void Up(MigrationBuilder migrationBuilder)
13 | {
14 | migrationBuilder.AddColumn(
15 | name: "Completed",
16 | table: "Goal",
17 | type: "timestamp with time zone",
18 | nullable: true);
19 | }
20 |
21 | ///
22 | protected override void Down(MigrationBuilder migrationBuilder)
23 | {
24 | migrationBuilder.DropColumn(
25 | name: "Completed",
26 | table: "Goal");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250523225400_AddInterestRate.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore.Migrations;
2 |
3 | #nullable disable
4 |
5 | namespace BudgetBoard.Database.Migrations
6 | {
7 | ///
8 | public partial class AddInterestRate : Migration
9 | {
10 | ///
11 | protected override void Up(MigrationBuilder migrationBuilder)
12 | {
13 | migrationBuilder.AddColumn(
14 | name: "InterestRate",
15 | table: "Account",
16 | type: "numeric",
17 | nullable: true);
18 | }
19 |
20 | ///
21 | protected override void Down(MigrationBuilder migrationBuilder)
22 | {
23 | migrationBuilder.DropColumn(
24 | name: "InterestRate",
25 | table: "Account");
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Migrations/20250528000456_AddUserSettings.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using BudgetBoard.Database.Models;
3 | using Microsoft.EntityFrameworkCore.Migrations;
4 |
5 | #nullable disable
6 |
7 | namespace BudgetBoard.Database.Migrations
8 | {
9 | ///
10 | public partial class AddUserSettings : Migration
11 | {
12 | ///
13 | protected override void Up(MigrationBuilder migrationBuilder)
14 | {
15 | migrationBuilder.AlterDatabase()
16 | .Annotation("Npgsql:Enum:currency", "aud,cad,chf,cny,eur,gbp,inr,jpy,nzd,sek,usd");
17 |
18 | migrationBuilder.CreateTable(
19 | name: "UserSettings",
20 | columns: table => new
21 | {
22 | ID = table.Column(type: "uuid", nullable: false),
23 | Currency = table.Column(type: "currency", nullable: false),
24 | UserID = table.Column(type: "uuid", nullable: false)
25 | },
26 | constraints: table =>
27 | {
28 | table.PrimaryKey("PK_UserSettings", x => x.ID);
29 | table.ForeignKey(
30 | name: "FK_UserSettings_User_UserID",
31 | column: x => x.UserID,
32 | principalTable: "User",
33 | principalColumn: "Id",
34 | onDelete: ReferentialAction.Cascade);
35 | });
36 |
37 | migrationBuilder.CreateIndex(
38 | name: "IX_UserSettings_UserID",
39 | table: "UserSettings",
40 | column: "UserID",
41 | unique: true);
42 | }
43 |
44 | ///
45 | protected override void Down(MigrationBuilder migrationBuilder)
46 | {
47 | migrationBuilder.DropTable(
48 | name: "UserSettings");
49 |
50 | migrationBuilder.AlterDatabase()
51 | .OldAnnotation("Npgsql:Enum:currency", "aud,cad,chf,cny,eur,gbp,inr,jpy,nzd,sek,usd");
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Account.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models;
2 |
3 | public class Account
4 | {
5 | public Guid ID { get; set; }
6 | public string? SyncID { get; set; }
7 | public required string Name { get; set; }
8 | public Guid? InstitutionID { get; set; }
9 | public Institution Institution { get; set; } = null!;
10 | public string Type { get; set; } = "";
11 | public string Subtype { get; set; } = "";
12 | public bool HideTransactions { get; set; } = false;
13 | public bool HideAccount { get; set; } = false;
14 | public DateTime? Deleted { get; set; } = null;
15 | public int Index { get; set; } = 0;
16 | public decimal? InterestRate { get; set; } = null;
17 | public string Source { get; set; } = string.Empty;
18 | public ICollection Transactions { get; set; } = [];
19 | public ICollection Goals { get; set; } = [];
20 | public ICollection Balances { get; set; } = [];
21 | public required Guid UserID { get; set; }
22 | public ApplicationUser? User { get; set; } = null!;
23 | }
24 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/ApplicationUser.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Identity;
2 |
3 | namespace BudgetBoard.Database.Models;
4 |
5 | public interface IApplicationUser
6 | {
7 | Guid Id { get; set; }
8 | string AccessToken { get; set; }
9 | DateTime LastSync { get; set; }
10 | ICollection Accounts { get; set; }
11 | ICollection Budgets { get; set; }
12 | ICollection Goals { get; set; }
13 | ICollection TransactionCategories { get; set; }
14 | ICollection Institutions { get; set; }
15 | UserSettings? UserSettings { get; set; }
16 | }
17 |
18 | public class ApplicationUser : IdentityUser, IApplicationUser
19 | {
20 | public string AccessToken { get; set; } = string.Empty;
21 | public DateTime LastSync { get; set; } = DateTime.MinValue;
22 | public ICollection Accounts { get; set; } = [];
23 | public ICollection Budgets { get; set; } = [];
24 | public ICollection Goals { get; set; } = [];
25 | public ICollection TransactionCategories { get; set; } = [];
26 | public ICollection Institutions { get; set; } = [];
27 | public UserSettings? UserSettings { get; set; } = null!;
28 | }
29 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Balance.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models;
2 |
3 | public class Balance
4 | {
5 | public Guid ID { get; set; }
6 | public decimal Amount { get; set; }
7 | public DateTime DateTime { get; set; }
8 | public required Guid AccountID { get; set; }
9 | public Account? Account { get; set; } = null;
10 | }
11 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Budget.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models;
2 | public class Budget
3 | {
4 | public Guid ID { get; set; }
5 | public required DateTime Date { get; set; }
6 | public required string Category { get; set; }
7 | public decimal Limit { get; set; } = 0.0M;
8 | public required Guid UserID { get; set; }
9 | public ApplicationUser? User { get; set; } = null!;
10 | }
11 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Category.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models;
2 |
3 | public class Category
4 | {
5 | public Guid ID { get; set; }
6 | public required string Value { get; set; }
7 | public required string Parent { get; set; }
8 | public required Guid UserID { get; set; }
9 | public ApplicationUser? User { get; set; } = null!;
10 | }
11 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Goal.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models;
2 |
3 | public class Goal
4 | {
5 | public Guid ID { get; set; }
6 | public string Name { get; set; } = string.Empty;
7 | public DateTime? CompleteDate { get; set; } = null;
8 | public decimal Amount { get; set; } = 0.0M;
9 | public decimal InitialAmount { get; set; } = 0.0M;
10 | public decimal? MonthlyContribution { get; set; } = null;
11 | public DateTime? Completed { get; set; } = null;
12 | public ICollection Accounts { get; set; } = [];
13 | public required Guid UserID { get; set; }
14 | public ApplicationUser? User { get; set; } = null;
15 | }
16 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Institution.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models
2 | {
3 | public class Institution
4 | {
5 | public Guid ID { get; set; }
6 | public string Name { get; set; } = string.Empty;
7 | public int Index { get; set; } = 0;
8 | public DateTime? Deleted { get; set; } = null;
9 | public required Guid UserID { get; set; }
10 | public ApplicationUser? User { get; set; } = null;
11 | public ICollection Accounts { get; set; } = [];
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/Transaction.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel.DataAnnotations;
2 |
3 | namespace BudgetBoard.Database.Models;
4 |
5 | public class Transaction
6 | {
7 | public Guid ID { get; set; }
8 | public string? SyncID { get; set; }
9 | public decimal Amount { get; set; }
10 | public DateTime Date { get; set; }
11 | [DisplayFormat(NullDisplayText = "No Category")]
12 | public string? Category { get; set; }
13 | [DisplayFormat(NullDisplayText = "No Subcategory")]
14 | public string? Subcategory { get; set; }
15 | [DisplayFormat(NullDisplayText = "No Merchant")]
16 | public string? MerchantName { get; set; }
17 | public bool Pending { get; set; } = false;
18 | public DateTime? Deleted { get; set; } = null;
19 | public required string Source { get; set; }
20 | public required Guid AccountID { get; set; }
21 | public Account? Account { get; set; } = null!;
22 | }
--------------------------------------------------------------------------------
/server/BudgetBoard.Database/Models/UserSettings.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Database.Models;
2 |
3 | public enum Currency
4 | {
5 | USD, // US Dollar
6 | EUR, // Euro
7 | GBP, // British Pound
8 | JPY, // Japanese Yen
9 | CNY, // Chinese Yuan
10 | INR, // Indian Rupee
11 | AUD, // Australian Dollar
12 | CAD, // Canadian Dollar
13 | CHF, // Swiss Franc
14 | SEK, // Swedish Krona
15 | NZD, // New Zealand Dollar
16 | }
17 |
18 | public class UserSettings()
19 | {
20 | public Guid ID { get; set; }
21 | public Currency Currency { get; set; } = Currency.USD;
22 | public Guid UserID { get; set; }
23 | public ApplicationUser User { get; set; } = null!;
24 | }
25 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/ApplicationUserService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Data;
2 | using BudgetBoard.Database.Models;
3 | using BudgetBoard.Service.Interfaces;
4 | using BudgetBoard.Service.Models;
5 | using Microsoft.EntityFrameworkCore;
6 | using Microsoft.Extensions.Logging;
7 |
8 | namespace BudgetBoard.Service;
9 |
10 | public class ApplicationUserService(ILogger logger, UserDataContext userDataContext) : IApplicationUserService
11 | {
12 | private readonly ILogger _logger = logger;
13 | private readonly UserDataContext _userDataContext = userDataContext;
14 |
15 | public async Task ReadApplicationUserAsync(Guid userGuid)
16 | {
17 | var userData = await GetCurrentUserAsync(userGuid.ToString());
18 | return new ApplicationUserResponse(userData);
19 | }
20 |
21 | public async Task UpdateApplicationUserAsync(Guid userGuid, IApplicationUserUpdateRequest user)
22 | {
23 | var userData = await GetCurrentUserAsync(userGuid.ToString());
24 |
25 | userData.LastSync = user.LastSync;
26 |
27 | await _userDataContext.SaveChangesAsync();
28 | }
29 |
30 | private async Task GetCurrentUserAsync(string id)
31 | {
32 | List users;
33 | ApplicationUser? foundUser;
34 | try
35 | {
36 | users = await _userDataContext.ApplicationUsers.ToListAsync();
37 | foundUser = users.FirstOrDefault(u => u.Id == new Guid(id));
38 | }
39 | catch (Exception ex)
40 | {
41 | _logger.LogError("An error occurred while retrieving the user data: {ExceptionMessage}", ex.Message);
42 | throw new BudgetBoardServiceException("An error occurred while retrieving the user data.");
43 | }
44 |
45 | if (foundUser == null)
46 | {
47 | _logger.LogError("Attempt to create an account for an invalid user.");
48 | throw new BudgetBoardServiceException("Provided user not found.");
49 | }
50 |
51 | return foundUser;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/BudgetBoard.Service.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Helpers/TransactionCategoriesHelpers.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Helpers;
4 |
5 | public static class TransactionCategoriesHelpers
6 | {
7 | public static string GetParentCategory(string category, IEnumerable customCategories)
8 | {
9 | var allCategories = TransactionCategoriesConstants.DefaultTransactionCategories
10 | .Concat(customCategories)
11 | .ToList();
12 | var parentCategory = allCategories
13 | .FirstOrDefault(c => c.Value.Equals(category, StringComparison.CurrentCultureIgnoreCase))?.Parent;
14 | return parentCategory ?? string.Empty;
15 | }
16 |
17 | public static bool GetIsParentCategory(string category, IEnumerable customCategories)
18 | {
19 | var allCategories = TransactionCategoriesConstants.DefaultTransactionCategories
20 | .Concat(customCategories)
21 | .ToList();
22 | return allCategories
23 | .Any(c => c.Parent.Equals(category, StringComparison.CurrentCultureIgnoreCase));
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IAccountService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IAccountService
6 | {
7 | Task CreateAccountAsync(Guid userGuid, IAccountCreateRequest account);
8 | Task> ReadAccountsAsync(Guid userGuid, Guid accountGuid = default);
9 | Task UpdateAccountAsync(Guid userGuid, IAccountUpdateRequest editedAccount);
10 | Task DeleteAccountAsync(Guid userGuid, Guid guid, bool deleteTransactions = false);
11 | Task RestoreAccountAsync(Guid userGuid, Guid guid, bool restoreTransactions = false);
12 | Task OrderAccountsAsync(Guid userGuid, IEnumerable orderedAccounts);
13 | }
14 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IApplicationUserService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IApplicationUserService
6 | {
7 | Task ReadApplicationUserAsync(Guid userGuid);
8 | Task UpdateApplicationUserAsync(Guid userGuid, IApplicationUserUpdateRequest user);
9 | }
10 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IBalanceService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IBalanceService
6 | {
7 | Task CreateBalancesAsync(Guid userGuid, IBalanceCreateRequest balance);
8 | Task> ReadBalancesAsync(Guid userGuid, Guid accountId);
9 | Task UpdateBalanceAsync(Guid userGuid, IBalanceUpdateRequest updatedBalance);
10 | Task DeleteBalanceAsync(Guid userGuid, Guid guid);
11 | }
12 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IBudgetService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IBudgetService
6 | {
7 | Task CreateBudgetsAsync(Guid userGuid, IEnumerable budget);
8 | Task> ReadBudgetsAsync(Guid userGuid, DateTime date);
9 | Task UpdateBudgetAsync(Guid userGuid, IBudgetUpdateRequest updatedBudget);
10 | Task DeleteBudgetAsync(Guid userGuid, Guid budgetGuid);
11 | }
12 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IGoalService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IGoalService
6 | {
7 | Task CreateGoalAsync(Guid userGuid, IGoalCreateRequest request);
8 | Task> ReadGoalsAsync(Guid userGuid, bool includeInterest);
9 | Task UpdateGoalAsync(Guid userGuid, IGoalUpdateRequest request);
10 | Task DeleteGoalAsync(Guid userGuid, Guid guid);
11 | Task CompleteGoalAsync(Guid userGuid, Guid goalID, DateTime completedDate);
12 | }
13 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IInstitutionService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IInstitutionService
6 | {
7 | Task CreateInstitutionAsync(Guid userGuid, IInstitutionCreateRequest request);
8 | Task> ReadInstitutionsAsync(Guid userGuid, Guid guid = default);
9 | Task UpdateInstitutionAsync(Guid userGuid, IInstitutionUpdateRequest request);
10 | Task DeleteInstitutionAsync(Guid userGuid, Guid id, bool deleteTransactions);
11 | Task OrderInstitutionsAsync(Guid userGuid, IEnumerable orderedInstitutions);
12 | }
13 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/ISimpleFinService.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Service.Interfaces;
2 |
3 | public interface ISimpleFinService
4 | {
5 | Task> SyncAsync(Guid userGuid);
6 | Task UpdateAccessTokenFromSetupToken(Guid userGuid, string setupToken);
7 | }
8 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/ITransactionCategoryService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface ITransactionCategoryService
6 | {
7 | Task CreateTransactionCategoryAsync(Guid userGuid, ICategoryCreateRequest request);
8 | Task> ReadTransactionCategoriesAsync(Guid userGuid, Guid categoryGuid = default);
9 | Task UpdateTransactionCategoryAsync(Guid userGuid, ICategoryUpdateRequest request);
10 | Task DeleteTransactionCategoryAsync(Guid userGuid, Guid guid);
11 | }
12 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/ITransactionService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface ITransactionService
6 | {
7 | Task CreateTransactionAsync(Guid userGuid, ITransactionCreateRequest transaction);
8 | Task> ReadTransactionsAsync(Guid userGuid, int? year, int? month, bool getHidden, Guid guid = default);
9 | Task UpdateTransactionAsync(Guid userGuid, ITransactionUpdateRequest editedTransaction);
10 | Task DeleteTransactionAsync(Guid userGuid, Guid transactionID);
11 | Task RestoreTransactionAsync(Guid userGuid, Guid transactionID);
12 | Task SplitTransactionAsync(Guid userGuid, ITransactionSplitRequest transaction);
13 | Task ImportTransactionsAsync(Guid userGuid, ITransactionImportRequest transactionImportRequest);
14 | }
15 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Interfaces/IUserSettingsService.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Service.Models;
2 |
3 | namespace BudgetBoard.Service.Interfaces;
4 |
5 | public interface IUserSettingsService
6 | {
7 | Task ReadUserSettingsAsync(Guid userGuid);
8 | Task UpdateUserSettingsAsync(
9 | Guid userGuid,
10 | IUserSettingsUpdateRequest userSettingsUpdateRequest
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Models/ApplicationUser.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Models;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace BudgetBoard.Service.Models;
5 |
6 | public interface IApplicationUserUpdateRequest
7 | {
8 | DateTime LastSync { get; set; }
9 | }
10 | public class ApplicationUserUpdateRequest : IApplicationUserUpdateRequest
11 | {
12 | public DateTime LastSync { get; set; }
13 |
14 | [JsonConstructor]
15 | public ApplicationUserUpdateRequest()
16 | {
17 | LastSync = DateTime.MinValue;
18 | }
19 | }
20 |
21 | public interface IApplicationUserResponse
22 | {
23 | Guid ID { get; set; }
24 | bool AccessToken { get; set; }
25 | DateTime LastSync { get; set; }
26 | }
27 | public class ApplicationUserResponse : IApplicationUserResponse
28 | {
29 | public Guid ID { get; set; }
30 | public bool AccessToken { get; set; }
31 | public DateTime LastSync { get; set; }
32 |
33 | [JsonConstructor]
34 | public ApplicationUserResponse()
35 | {
36 | ID = new Guid();
37 | AccessToken = false;
38 | LastSync = DateTime.MinValue;
39 | }
40 |
41 | public ApplicationUserResponse(ApplicationUser user)
42 | {
43 | ID = user.Id;
44 | AccessToken = (user.AccessToken != string.Empty);
45 | LastSync = user.LastSync;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Models/Balance.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Models;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace BudgetBoard.Service.Models;
5 |
6 | public interface IBalanceCreateRequest
7 | {
8 | decimal Amount { get; set; }
9 | DateTime DateTime { get; set; }
10 | Guid AccountID { get; set; }
11 | }
12 |
13 | public class BalanceCreateRequest : IBalanceCreateRequest
14 | {
15 | public decimal Amount { get; set; }
16 | public DateTime DateTime { get; set; }
17 | public Guid AccountID { get; set; }
18 |
19 | [JsonConstructor]
20 | public BalanceCreateRequest()
21 | {
22 | Amount = 0;
23 | DateTime = DateTime.MinValue;
24 | AccountID = Guid.NewGuid();
25 | }
26 | }
27 |
28 | public interface IBalanceUpdateRequest
29 | {
30 | Guid ID { get; set; }
31 | decimal Amount { get; set; }
32 | DateTime DateTime { get; set; }
33 | Guid AccountID { get; set; }
34 | }
35 |
36 | public class BalanceUpdateRequest : IBalanceUpdateRequest
37 | {
38 | public Guid ID { get; set; }
39 | public decimal Amount { get; set; }
40 | public DateTime DateTime { get; set; }
41 | public Guid AccountID { get; set; }
42 |
43 | [JsonConstructor]
44 | public BalanceUpdateRequest()
45 | {
46 | ID = Guid.NewGuid();
47 | Amount = 0;
48 | DateTime = DateTime.MinValue;
49 | AccountID = Guid.NewGuid();
50 | }
51 | }
52 |
53 | public interface IBalanceResponse
54 | {
55 | Guid ID { get; set; }
56 | decimal Amount { get; set; }
57 | DateTime DateTime { get; set; }
58 | Guid AccountID { get; set; }
59 | }
60 |
61 | public class BalanceResponse : IBalanceResponse
62 | {
63 | public Guid ID { get; set; }
64 | public decimal Amount { get; set; }
65 | public DateTime DateTime { get; set; }
66 | public Guid AccountID { get; set; }
67 |
68 | [JsonConstructor]
69 | public BalanceResponse()
70 | {
71 | ID = Guid.NewGuid();
72 | Amount = 0;
73 | DateTime = DateTime.MinValue;
74 | AccountID = Guid.NewGuid();
75 | }
76 |
77 | public BalanceResponse(Balance balance)
78 | {
79 | ID = balance.ID;
80 | Amount = balance.Amount;
81 | DateTime = balance.DateTime;
82 | AccountID = balance.AccountID;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Models/Budget.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Models;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace BudgetBoard.Service.Models;
5 |
6 | public interface IBudgetCreateRequest
7 | {
8 | DateTime Date { get; set; }
9 | string Category { get; set; }
10 | decimal Limit { get; set; }
11 | }
12 | public class BudgetCreateRequest : IBudgetCreateRequest
13 | {
14 | public DateTime Date { get; set; }
15 | public string Category { get; set; }
16 | public decimal Limit { get; set; }
17 |
18 | [JsonConstructor]
19 | public BudgetCreateRequest()
20 | {
21 | Date = DateTime.MinValue;
22 | Category = string.Empty;
23 | Limit = 0;
24 | }
25 | }
26 |
27 | public interface IBudgetUpdateRequest
28 | {
29 | Guid ID { get; set; }
30 | decimal Limit { get; set; }
31 | }
32 | public class BudgetUpdateRequest : IBudgetUpdateRequest
33 | {
34 | public Guid ID { get; set; }
35 | public decimal Limit { get; set; }
36 |
37 | [JsonConstructor]
38 | public BudgetUpdateRequest()
39 | {
40 | ID = Guid.NewGuid();
41 | Limit = 0;
42 | }
43 | }
44 |
45 | public interface IBudgetResponse
46 | {
47 | Guid ID { get; set; }
48 | DateTime Date { get; set; }
49 | string Category { get; set; }
50 | decimal Limit { get; set; }
51 | Guid UserID { get; set; }
52 | }
53 | public class BudgetResponse : IBudgetResponse
54 | {
55 | public Guid ID { get; set; }
56 | public DateTime Date { get; set; }
57 | public string Category { get; set; }
58 | public decimal Limit { get; set; }
59 | public Guid UserID { get; set; }
60 |
61 | [JsonConstructor]
62 | public BudgetResponse()
63 | {
64 | ID = Guid.NewGuid();
65 | Date = DateTime.MinValue;
66 | Category = string.Empty;
67 | Limit = 0;
68 | UserID = Guid.NewGuid();
69 | }
70 |
71 | public BudgetResponse(Budget budget)
72 | {
73 | ID = budget.ID;
74 | Date = budget.Date;
75 | Category = budget.Category;
76 | Limit = budget.Limit;
77 | UserID = budget.UserID;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Models/BudgetBoardServiceException.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Service.Models;
2 |
3 | public class BudgetBoardServiceException : Exception
4 | {
5 | public BudgetBoardServiceException()
6 | {
7 | }
8 |
9 | public BudgetBoardServiceException(string? message) : base(message)
10 | {
11 | }
12 |
13 | public BudgetBoardServiceException(string? message, Exception? innerException)
14 | : base(message, innerException)
15 | {
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Models/Category.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Models;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace BudgetBoard.Service.Models;
5 |
6 | public interface ICategory
7 | {
8 | public string Value { get; set; }
9 | public string Parent { get; set; }
10 | }
11 |
12 | public class CategoryBase : ICategory
13 | {
14 | public string Value { get; set; }
15 | public string Parent { get; set; }
16 |
17 | [JsonConstructor]
18 | public CategoryBase()
19 | {
20 | Value = string.Empty;
21 | Parent = string.Empty;
22 | }
23 | }
24 |
25 | public interface ICategoryCreateRequest : ICategory
26 | {
27 | }
28 |
29 | public class CategoryCreateRequest : ICategoryCreateRequest
30 | {
31 | public string Value { get; set; }
32 | public string Parent { get; set; }
33 |
34 | [JsonConstructor]
35 | public CategoryCreateRequest()
36 | {
37 | Value = string.Empty;
38 | Parent = string.Empty;
39 | }
40 | }
41 |
42 | public interface ICategoryUpdateRequest
43 | {
44 | Guid ID { get; set; }
45 | string Value { get; set; }
46 | string Parent { get; set; }
47 | }
48 | public class CategoryUpdateRequest : ICategoryUpdateRequest
49 | {
50 | public Guid ID { get; set; }
51 | public string Value { get; set; }
52 | public string Parent { get; set; }
53 |
54 | [JsonConstructor]
55 | public CategoryUpdateRequest()
56 | {
57 | ID = Guid.Empty;
58 | Value = string.Empty;
59 | Parent = string.Empty;
60 | }
61 | }
62 |
63 | public interface ICategoryResponse
64 | {
65 | Guid ID { get; set; }
66 | string Value { get; set; }
67 | string Parent { get; set; }
68 | Guid UserID { get; set; }
69 | }
70 | public class CategoryResponse : ICategoryResponse
71 | {
72 | public Guid ID { get; set; }
73 | public string Value { get; set; }
74 | public string Parent { get; set; }
75 | public Guid UserID { get; set; }
76 |
77 | [JsonConstructor]
78 | public CategoryResponse()
79 | {
80 | ID = Guid.Empty;
81 | Value = string.Empty;
82 | Parent = string.Empty;
83 | UserID = Guid.Empty;
84 | }
85 |
86 | public CategoryResponse(Category category)
87 | {
88 | ID = category.ID;
89 | Value = category.Value;
90 | Parent = category.Parent;
91 | UserID = category.UserID;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Service/Models/UserSettings.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.Service.Models;
5 |
6 | public interface IUserSettingsResponse
7 | {
8 | string Currency { get; set; }
9 | }
10 |
11 | public class UserSettingsResponse : IUserSettingsResponse
12 | {
13 | public string Currency { get; set; }
14 |
15 | [JsonConstructor]
16 | public UserSettingsResponse()
17 | {
18 | Currency = Database.Models.Currency.USD.ToString();
19 | }
20 |
21 | public UserSettingsResponse(UserSettings userSettings)
22 | {
23 | Currency = userSettings.Currency.ToString();
24 | }
25 | }
26 |
27 | public interface IUserSettingsUpdateRequest
28 | {
29 | public string Currency { get; set; }
30 | }
31 |
32 | public class UserSettingsUpdateRequest : IUserSettingsUpdateRequest
33 | {
34 | public string Currency { get; set; }
35 |
36 | [JsonConstructor]
37 | public UserSettingsUpdateRequest()
38 | {
39 | Currency = Database.Models.Currency.USD.ToString();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/BudgetBoard.IntegrationTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0
5 | enable
6 | enable
7 |
8 | false
9 | true
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | runtime; build; native; contentfiles; analyzers; buildtransitive
21 | all
22 |
23 |
24 | runtime; build; native; contentfiles; analyzers; buildtransitive
25 | all
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/AccountFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | public class AccountFaker : Faker
7 | {
8 | public AccountFaker()
9 | {
10 | RuleFor(a => a.ID, f => Guid.NewGuid())
11 | .RuleFor(a => a.SyncID, f => f.Random.String(20))
12 | .RuleFor(a => a.Name, f => f.Finance.AccountName())
13 | .RuleFor(a => a.InstitutionID, f => Guid.NewGuid())
14 | .RuleFor(a => a.Type, f => f.Finance.TransactionType())
15 | .RuleFor(a => a.Subtype, f => f.Finance.TransactionType())
16 | .RuleFor(a => a.HideTransactions, f => false)
17 | .RuleFor(a => a.HideAccount, f => false);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/ApplicationUserFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | class ApplicationUserFaker : Faker
7 | {
8 | public ApplicationUserFaker()
9 | {
10 | RuleFor(u => u.Id, f => f.Random.Guid());
11 | RuleFor(u => u.AccessToken, f => f.Random.String());
12 | RuleFor(u => u.LastSync, f => f.Date.Past());
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/BalanceFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | public class BalanceFaker : Faker
7 | {
8 | public ICollection AccountIds { get; set; }
9 | public BalanceFaker()
10 | {
11 | AccountIds = [];
12 |
13 | RuleFor(b => b.ID, f => Guid.NewGuid())
14 | .RuleFor(b => b.Amount, f => f.Finance.Amount())
15 | .RuleFor(b => b.DateTime, f => f.Date.Past());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/BudgetFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | class BudgetFaker : Faker
7 | {
8 | public BudgetFaker()
9 | {
10 | RuleFor(b => b.ID, f => Guid.NewGuid())
11 | .RuleFor(b => b.Date, f => f.Date.Between(DateTime.Now.AddMonths(-2), DateTime.Now))
12 | .RuleFor(b => b.Category, f => f.Finance.AccountName())
13 | .RuleFor(b => b.Limit, f => f.Finance.Amount());
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/GoalFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | class GoalFaker : Faker
7 | {
8 |
9 | public GoalFaker()
10 | {
11 | RuleFor(g => g.ID, f => Guid.NewGuid())
12 | .RuleFor(g => g.Name, f => f.Lorem.Word())
13 | .RuleFor(g => g.CompleteDate, f => f.Date.Future())
14 | .RuleFor(g => g.Amount, f => f.Finance.Amount())
15 | .RuleFor(g => g.InitialAmount, f => f.Finance.Amount())
16 | .RuleFor(g => g.MonthlyContribution, f => f.Finance.Amount());
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/InstitutionFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | public class InstitutionFaker : Faker
7 | {
8 | public InstitutionFaker()
9 | {
10 | RuleFor(i => i.ID, f => f.Random.Guid())
11 | .RuleFor(i => i.Name, f => f.Company.CompanyName())
12 | .RuleFor(i => i.Index, f => f.Random.Int(0, 100));
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/TransactionCategoryFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | class TransactionCategoryFaker : Faker
7 | {
8 | public TransactionCategoryFaker()
9 | {
10 | RuleFor(c => c.ID, f => f.Random.Guid())
11 | .RuleFor(c => c.Value, f => f.Random.String(20))
12 | .RuleFor(c => c.Parent, f => f.Random.String(20));
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/TransactionFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | public class TransactionFaker : Faker
7 | {
8 | public ICollection AccountIds { get; set; }
9 |
10 | public TransactionFaker()
11 | {
12 | AccountIds = [];
13 |
14 | AccountIds = [];
15 | RuleFor(t => t.ID, f => Guid.NewGuid())
16 | .RuleFor(t => t.SyncID, f => f.Random.String(20))
17 | .RuleFor(t => t.Amount, f => f.Finance.Amount())
18 | .RuleFor(t => t.Date, f => f.Date.Past())
19 | .RuleFor(t => t.Category, f => f.Random.String(10))
20 | .RuleFor(t => t.Subcategory, f => f.Random.String(10))
21 | .RuleFor(t => t.MerchantName, f => f.Random.String(10))
22 | .RuleFor(t => t.Pending, f => false)
23 | .RuleFor(t => t.Source, f => f.Random.String(10))
24 | .RuleFor(t => t.AccountID, f => f.PickRandom(AccountIds));
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/Fakers/UserSettingsFaker.cs:
--------------------------------------------------------------------------------
1 | using Bogus;
2 | using BudgetBoard.Database.Models;
3 |
4 | namespace BudgetBoard.IntegrationTests.Fakers;
5 |
6 | class UserSettingsFaker : Faker
7 | {
8 | public UserSettingsFaker()
9 | {
10 | RuleFor(u => u.ID, f => f.Random.Guid());
11 | RuleFor(u => u.Currency, f => f.Random.Enum());
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/GlobalUsings.cs:
--------------------------------------------------------------------------------
1 | global using Xunit;
--------------------------------------------------------------------------------
/server/BudgetBoard.Tests/TestHelper.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Data;
2 | using BudgetBoard.Database.Models;
3 | using BudgetBoard.IntegrationTests.Fakers;
4 | using Microsoft.EntityFrameworkCore;
5 |
6 | namespace BudgetBoard.IntegrationTests;
7 |
8 | internal class TestHelper
9 | {
10 | public readonly UserDataContext UserDataContext;
11 | public readonly ApplicationUser demoUser = _applicationUserFaker.Generate();
12 |
13 | private static readonly ApplicationUserFaker _applicationUserFaker = new();
14 |
15 | public TestHelper()
16 | {
17 | var builder = new DbContextOptionsBuilder();
18 | builder.UseInMemoryDatabase(new Guid().ToString());
19 |
20 | var dbContextOptions = builder.Options;
21 | UserDataContext = new UserDataContext(dbContextOptions);
22 | // Delete existing db before creating a new one
23 | UserDataContext.Database.EnsureDeleted();
24 | UserDataContext.Database.EnsureCreated();
25 |
26 | // Seed a demo user
27 | UserDataContext.Users.Add(demoUser);
28 | UserDataContext.SaveChanges();
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/Controllers/SimpleFinController.cs:
--------------------------------------------------------------------------------
1 | using BudgetBoard.Database.Models;
2 | using BudgetBoard.Service.Interfaces;
3 | using BudgetBoard.Service.Models;
4 | using BudgetBoard.WebAPI.Utils;
5 | using Microsoft.AspNetCore.Authorization;
6 | using Microsoft.AspNetCore.Identity;
7 | using Microsoft.AspNetCore.Mvc;
8 |
9 | namespace BudgetBoard.WebAPI.Controllers;
10 |
11 | [Route("api/[controller]/[action]")]
12 | [ApiController]
13 | public class SimpleFinController(ILogger logger, UserManager userManager, ISimpleFinService simpleFinService) : ControllerBase
14 | {
15 | private readonly ILogger _logger = logger;
16 | private readonly UserManager _userManager = userManager;
17 | private readonly ISimpleFinService _simpleFinService = simpleFinService;
18 |
19 | [HttpGet]
20 | [Authorize]
21 | public async Task Sync()
22 | {
23 | try
24 | {
25 | return Ok(await _simpleFinService.SyncAsync(new Guid(_userManager.GetUserId(User) ?? string.Empty)));
26 | }
27 | catch (BudgetBoardServiceException bbex)
28 | {
29 | return Helpers.BuildErrorResponse(bbex.Message);
30 | }
31 | catch
32 | {
33 | return Helpers.BuildErrorResponse();
34 | }
35 | }
36 |
37 | [HttpPut]
38 | [Authorize]
39 | public async Task UpdateAccessToken(string setupToken)
40 | {
41 | try
42 | {
43 | await _simpleFinService.UpdateAccessTokenFromSetupToken(new Guid(_userManager.GetUserId(User) ?? string.Empty), setupToken);
44 | return Ok();
45 | }
46 | catch (BudgetBoardServiceException bbex)
47 | {
48 | return Helpers.BuildErrorResponse(bbex.Message);
49 | }
50 | catch
51 | {
52 | return Helpers.BuildErrorResponse();
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/Overrides/IdentityApiEndpointRouteBuilderOptions.cs:
--------------------------------------------------------------------------------
1 | namespace BudgetBoard.Overrides;
2 |
3 | public class IdentityApiEndpointRouteBuilderOptions
4 | {
5 | public bool ExcludeRegisterPost { get; set; }
6 | public bool ExcludeLoginPost { get; set; }
7 | public bool ExcludeRefreshPost { get; set; }
8 | public bool ExcludeConfirmEmailGet { get; set; }
9 | public bool ExcludeResendConfirmationEmailPost { get; set; }
10 | public bool ExcludeForgotPasswordPost { get; set; }
11 | public bool ExcludeResetPasswordPost { get; set; }
12 | public bool ExcludeManageGroup { get; set; }
13 | public bool Exclude2faPost { get; set; }
14 | public bool ExcludegInfoGet { get; set; }
15 | public bool ExcludeInfoPost { get; set; }
16 | }
17 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "http": {
4 | "commandName": "Project",
5 | "environmentVariables": {
6 | "ASPNETCORE_ENVIRONMENT": "Development"
7 | },
8 | "dotnetRunMessages": true,
9 | "launchBrowser": false,
10 | "applicationUrl": "http://localhost:5293"
11 | },
12 | "https": {
13 | "commandName": "Project",
14 | "environmentVariables": {
15 | "ASPNETCORE_ENVIRONMENT": "Development"
16 | },
17 | "dotnetRunMessages": true,
18 | "launchBrowser": false,
19 | "applicationUrl": "https://localhost:7122;http://localhost:5293"
20 | },
21 | "https-prod": {
22 | "commandName": "Project",
23 | "environmentVariables": {
24 | "ASPNETCORE_ENVIRONMENT": "Production"
25 | },
26 | "dotnetRunMessages": true,
27 | "launchBrowser": false,
28 | "applicationUrl": "https://localhost:7122;http://localhost:5293"
29 | }
30 | },
31 | "$schema": "https://json.schemastore.org/launchsettings.json"
32 | }
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/Utils/EmailSender.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Identity.UI.Services;
2 | using System.Net;
3 | using System.Net.Mail;
4 |
5 | namespace BudgetBoard.WebAPI.Utils;
6 |
7 | public class EmailSender : IEmailSender
8 | {
9 | public IConfiguration Configuration { get; }
10 | public EmailSender(IConfiguration configuration)
11 | {
12 | Configuration = configuration;
13 | }
14 |
15 | public async Task SendEmailAsync(string email, string subject, string htmlMessage)
16 | {
17 | var sender = Configuration.GetValue("EMAIL_SENDER");
18 | if (string.IsNullOrEmpty(sender))
19 | {
20 | throw new ArgumentNullException(nameof(sender));
21 | }
22 |
23 | var senderPassword = Configuration.GetValue("EMAIL_SENDER_PASSWORD");
24 | if (string.IsNullOrEmpty(senderPassword))
25 | {
26 | throw new ArgumentNullException(nameof(senderPassword));
27 | }
28 |
29 | var smtpHost = Configuration.GetValue("EMAIL_SMTP_HOST");
30 | if (string.IsNullOrEmpty(smtpHost))
31 | {
32 | throw new ArgumentNullException(nameof(smtpHost));
33 | }
34 |
35 | using (MailMessage mm = new MailMessage(sender, email))
36 | {
37 | mm.Subject = subject;
38 | string body = htmlMessage;
39 | mm.Body = body;
40 | mm.IsBodyHtml = true;
41 | SmtpClient smtp = new SmtpClient();
42 | smtp.Host = smtpHost;
43 | smtp.EnableSsl = true;
44 | NetworkCredential NetworkCred = new NetworkCredential(sender, senderPassword);
45 | smtp.UseDefaultCredentials = false;
46 | smtp.Credentials = NetworkCred;
47 | smtp.Port = 587;
48 | await smtp.SendMailAsync(mm);
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/Utils/Helpers.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 |
3 | namespace BudgetBoard.WebAPI.Utils;
4 |
5 | public static class Helpers
6 | {
7 | public const long UNIX_MONTH = 2629743;
8 | public const long UNIX_WEEK = 604800;
9 |
10 | public const string DEFAULT_ERROR_STRING = "There was an internal server error.";
11 |
12 | public static IActionResult BuildErrorResponse(string message = DEFAULT_ERROR_STRING)
13 | {
14 | var errorObjectResult = new ObjectResult(message)
15 | {
16 | StatusCode = StatusCodes.Status500InternalServerError
17 | };
18 |
19 | return errorObjectResult;
20 | }
21 |
22 | public static HostString GetHostString(HttpRequest request)
23 | {
24 | var host = GetHost(request);
25 | var port = GetPort(request);
26 |
27 | if (port == -1)
28 | {
29 | return new HostString(host);
30 | }
31 | else
32 | {
33 | return new HostString(host, port);
34 | }
35 | }
36 |
37 | public static string GetHost(HttpRequest request)
38 | {
39 | return request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? request.Host.ToString();
40 | }
41 |
42 | public static int GetPort(HttpRequest request)
43 | {
44 | var portString = request.Headers["X-Forwarded-Port"].FirstOrDefault() ?? "-1";
45 | return int.Parse(portString);
46 | }
47 |
48 | public static string GetProto(HttpRequest request)
49 | {
50 | return request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? request.Protocol;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft.AspNetCore": "Warning"
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "https_port": 443,
3 | "Logging": {
4 | "LogLevel": {
5 | "Default": "Debug",
6 | "Microsoft.AspNetCore": "Debug",
7 | "Microsoft.AspNetCore.Authentication": "Debug"
8 | }
9 | },
10 | "AllowedHosts": "*",
11 | "Serilog": {
12 | "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
13 | "MinimumLevel": {
14 | "Default": "Information",
15 | "Override": {
16 | "Microsoft": "Warning",
17 | "System": "Warning"
18 | }
19 | },
20 | "WriteTo": [
21 | {
22 | "Name": "Console"
23 | },
24 | {
25 | "Name": "File",
26 | "Args": {
27 | "path": "Logs/applog-.txt",
28 | "rollingInterval": "Day",
29 | "rollOnFileSizeLimit": true,
30 | "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
31 | }
32 | }
33 | ],
34 | "Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ],
35 | "Properties": {
36 | "ApplicationName": "Budget Board",
37 | "Environment": "Development"
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/server/BudgetBoard.WebAPI/libman.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0",
3 | "defaultProvider": "cdnjs",
4 | "libraries": []
5 | }
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
2 | COPY . ./app
3 |
4 | WORKDIR /app
5 | RUN dotnet restore "BudgetBoard.WebAPI/BudgetBoard.WebAPI.csproj"
6 | ARG configuration=Release
7 | RUN dotnet publish "BudgetBoard.WebAPI/BudgetBoard.WebAPI.csproj" -c $configuration -o /app/publish /p:UseAppHost=false --no-restore
8 |
9 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
10 | WORKDIR /app
11 | COPY --from=build /app/publish .
12 | ENTRYPOINT ["dotnet", "BudgetBoard.WebAPI.dll"]
13 |
--------------------------------------------------------------------------------