├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── backend ├── .eslintrc.js ├── babel.config.js ├── jest.config.js ├── nodemon.json ├── ormconfig.ts ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ ├── config │ │ ├── index.ts │ │ └── winston.ts │ ├── controllers │ │ ├── AccountsController.ts │ │ ├── BudgetsController.ts │ │ ├── CategoriesController.ts │ │ ├── PayeesController.ts │ │ ├── RootController.ts │ │ ├── TransactionsController.ts │ │ ├── UsersController.ts │ │ ├── requests.ts │ │ └── responses.ts │ ├── entities │ │ ├── Account.ts │ │ ├── Budget.ts │ │ ├── BudgetMonth.ts │ │ ├── Category.ts │ │ ├── CategoryGroup.ts │ │ ├── CategoryMonth.ts │ │ ├── Payee.ts │ │ ├── Transaction.ts │ │ └── User.ts │ ├── middleware │ │ └── authentication.ts │ ├── migrations │ │ ├── 1649259669912-initial.ts │ │ ├── 1649260258195-budget-month-available.ts │ │ └── 1650032177205-add-budget-currency.ts │ ├── models │ │ ├── Account.ts │ │ ├── Budget.ts │ │ ├── BudgetMonth.ts │ │ ├── Category.ts │ │ ├── CategoryGroup.ts │ │ ├── CategoryMonth.ts │ │ ├── Payee.ts │ │ ├── Transaction.ts │ │ └── User.ts │ ├── repositories │ │ ├── BudgetMonths.ts │ │ └── CategoryMonths.ts │ ├── server.ts │ ├── subscribers │ │ ├── AccountSubscriber.ts │ │ ├── BudgetMonthSubscriber.ts │ │ ├── BudgetSubscriber.ts │ │ ├── CategoryMonthSubscriber.ts │ │ ├── CategorySubscriber.ts │ │ └── TransactionSubscriber.ts │ └── utils.ts ├── test.db ├── test │ └── BudgE.test.js ├── tsconfig.json ├── tslint.json └── tsoa.json ├── docker-compose.yml ├── docker ├── .gitignore ├── custom-cont-init.d │ └── 99-node ├── custom-services.d │ ├── backend │ └── frontend └── nginx │ ├── nginx.conf │ └── site-confs │ └── default ├── frontend ├── .eslintrc.js ├── .gitignore ├── README.md ├── craco.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── api.js │ ├── components │ ├── AccountDetails.js │ ├── AccountTable │ │ ├── AccountAmountCell.js │ │ ├── AccountTable.js │ │ ├── AccountTableBody.js │ │ ├── AccountTableCell.js │ │ ├── AccountTableHeader.js │ │ ├── AccountTableRow.js │ │ ├── BalanceCalculation.js │ │ ├── TableColumns.js │ │ ├── TransactionDatePicker.js │ │ └── constants.js │ ├── AddAccountDialog.js │ ├── AlertDialog.js │ ├── BudgetDetails.js │ ├── BudgetMonthNavigator.js │ ├── BudgetMonthPicker.js │ ├── BudgetTable │ │ ├── BudgetMonthCalculation.js │ │ ├── BudgetTable.js │ │ ├── BudgetTableAssignedCell.js │ │ └── BudgetTableHeader.js │ ├── CategoryForm.js │ ├── CategoryGroupForm.js │ ├── CategoryMonthActivity.js │ ├── Drawer.js │ ├── ImportCSV.js │ ├── Login.js │ ├── ReconcileForm.js │ └── Settings │ │ ├── Account.js │ │ ├── Budget.js │ │ └── Settings.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── pages │ ├── Account.js │ └── Budget.js │ ├── redux │ ├── slices │ │ ├── Accounts.js │ │ ├── App.js │ │ ├── BudgetMonths.js │ │ ├── Budgets.js │ │ ├── Categories.js │ │ ├── CategoryGroups.js │ │ ├── Payees.js │ │ ├── Users.js │ │ └── index.js │ └── store.js │ ├── reportWebVitals.js │ ├── setupTests.js │ ├── utils │ ├── Currency.js │ ├── Date.js │ ├── Export.js │ ├── Store.js │ ├── Table.js │ └── useInterval.js │ └── wdyr.js ├── images ├── account.png └── budget.png ├── test.js └── ynab ├── import.js ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .idea/ 3 | .vscode/ 4 | node_modules/ 5 | build/ 6 | tmp/ 7 | temp/ 8 | database.db 9 | public/ 10 | .history/ 11 | ynab_register.csv 12 | ynab_budget.csv 13 | 14 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | arrowParens: 'avoid' 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BudgE 2 | BudgE (pronounced "budgie", like the bird) is an open source "budgeting with envelopes" personal finance app, taking inspiration from other tools such as [Aspire Budgeting](https://www.aspirebudget.com/), [budgetzero](https://budgetzero.io/), and [Buckets](https://www.budgetwithbuckets.com/). 3 | 4 | :warning: BudgE is still under active development and considered an 'alpha' version until more tests can be written to ensure functionality is maintained. :warning: 5 | 6 | ![Budget](images/budget.png) 7 | ![Account](images/account.png) 8 | 9 | # Current Features 10 | - Multi user support 11 | - Envelope budgeting with monthly rollover 12 | - Transaction management for accounts 13 | - Standard bank account management 14 | - Credit card management with payment handling 15 | - Tracking accounts 16 | - Export account transactions 17 | - CSV transaction import 18 | 19 | # Planned Features 20 | - [ ] Multiple budgets per user (already started) 21 | - [ ] Goals 22 | - [ ] Reports 23 | - [ ] Payee management 24 | - [ ] Category hiding and deletion 25 | - [ ] Mobile view for transaction input on the go! 26 | 27 | # Getting Started 28 | See [linuxserver/docker-BudgE](https://github.com/linuxserver/docker-BudgE). 29 | 30 | # Support 31 | [https://discord.gg/hKJWjDqCBz](https://discord.gg/hKJWjDqCBz) or through GitHub issues 32 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 10 | sourceType: 'module', // Allows for the use of imports 11 | }, 12 | rules: { 13 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 14 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 15 | semi: ['error', 'never'], 16 | arrowParens: 'as-needed', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /backend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | plugins: [ 7 | [ "@babel/plugin-proposal-decorators", {"legacy": true }], 8 | [ '@babel/plugin-proposal-class-properties', { 'loose': false }], 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | "timers": "fake", 4 | "transform": { 5 | "^.+\\.[t|j]sx?$": "babel-jest" 6 | }, 7 | testMatch: [ 8 | '**/test/**/*.[jt]s?(x)', 9 | '!**/test/coverage/**', 10 | '!**/test/utils/**', 11 | '!**/test/images/**', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "exec": "ts-node src/server.ts", 3 | "watch": ["src"], 4 | "ext": "ts" 5 | } 6 | -------------------------------------------------------------------------------- /backend/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions } from 'typeorm' 2 | 3 | const { join } = require('path') 4 | 5 | const config: ConnectionOptions = { 6 | type: 'sqlite', 7 | database: process.env.BUDGE_DATABASE || './budge.sqlite', 8 | synchronize: false, 9 | logging: false, 10 | entities: [join(__dirname, 'src/entities/**', '*.{ts,js}')], 11 | migrations: [join(__dirname, 'src/migrations/**', '*.{ts,js}')], 12 | subscribers: [join(__dirname, 'src/subscribers/**', '*.{ts,js}')], 13 | cli: { 14 | entitiesDir: 'src/entities', 15 | migrationsDir: 'src/migrations', 16 | subscribersDir: 'src/subscribers', 17 | }, 18 | } 19 | 20 | export = config 21 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BudgE", 3 | "version": "0.0.8", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "dev": "concurrently \"nodemon\" \"nodemon -x tsoa spec-and-routes\"", 8 | "build": "tsoa spec-and-routes && tsc", 9 | "start": "node build/src/server.js", 10 | "test": "jest", 11 | "migrate": "npx ts-node ./node_modules/typeorm/cli.js migration:run" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@babel/plugin-proposal-class-properties": "^7.16.0", 18 | "@babel/plugin-proposal-decorators": "^7.16.4", 19 | "@babel/plugin-transform-runtime": "^7.16.4", 20 | "@babel/preset-env": "^7.16.4", 21 | "@babel/preset-typescript": "^7.16.0", 22 | "@babel/runtime": "^7.16.3", 23 | "@types/body-parser": "^1.19.1", 24 | "@types/cookie-parser": "^1.4.2", 25 | "@types/cors": "^2.8.12", 26 | "@types/express": "^4.17.13", 27 | "@types/helmet": "^4.0.0", 28 | "@types/jest": "^27.0.3", 29 | "@types/mongoose": "^5.11.96", 30 | "@types/morgan": "^1.9.3", 31 | "@types/node-geocoder": "^3.24.3", 32 | "@types/swagger-ui-express": "^4.1.3", 33 | "@types/winston": "^2.4.4", 34 | "@typescript-eslint/eslint-plugin": "^5.3.0", 35 | "@typescript-eslint/parser": "^5.3.0", 36 | "concurrently": "^6.3.0", 37 | "eslint": "^8.2.0", 38 | "eslint-config-prettier": "^8.3.0", 39 | "eslint-plugin-prettier": "^4.0.0", 40 | "jest": "^27.3.1", 41 | "nodemon": "^2.0.14", 42 | "prettier": "^2.4.1", 43 | "ts-jest": "^27.0.7", 44 | "ts-node-dev": "^1.1.8", 45 | "tslint": "^6.1.3", 46 | "typescript": "^4.4.4" 47 | }, 48 | "dependencies": { 49 | "@types/bcrypt": "^5.0.0", 50 | "@types/jsonwebtoken": "^8.5.5", 51 | "@types/luxon": "^2.0.9", 52 | "bcrypt": "^5.0.1", 53 | "body-parser": "^1.19.0", 54 | "class-validator": "^0.13.1", 55 | "cookie-parser": "^1.4.5", 56 | "cors": "^2.8.5", 57 | "express": "^4.17.1", 58 | "helmet": "^4.6.0", 59 | "jsonwebtoken": "^8.5.1", 60 | "luxon": "^2.3.0", 61 | "morgan": "^1.10.0", 62 | "mysql": "^2.18.1", 63 | "pg": "^8.7.1", 64 | "sqlite3": "^5.0.2", 65 | "swagger-ui-express": "^4.1.6", 66 | "ts-node": "^10.7.0", 67 | "tsoa": "^3.14.0", 68 | "typeorm": "^0.2.38", 69 | "winston": "^3.3.3" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Application, Request, Response, NextFunction } from 'express' 2 | import { json } from 'body-parser' 3 | import cookieParser from 'cookie-parser' 4 | import cors from 'cors' 5 | import helmet from 'helmet' 6 | import config from './config' 7 | import morgan from 'morgan' 8 | import swaggerUi from 'swagger-ui-express' 9 | import { RegisterRoutes } from '../routes' 10 | import { logger, stream } from './config/winston' 11 | import { ValidateError } from 'tsoa' 12 | 13 | const app: Application = express() 14 | 15 | app.use( 16 | cors({ 17 | credentials: true, 18 | origin: function (origin: string, callback: CallableFunction) { 19 | if (config.env === 'development') { 20 | return callback(null, true) 21 | } 22 | 23 | if (origin === config.uiOrigin) { 24 | return callback(null, true) 25 | } 26 | 27 | return callback(null, true) 28 | 29 | callback(new Error('Not allowed by CORS')) 30 | }, 31 | }), 32 | ) 33 | app.use(helmet()) 34 | app.use(cookieParser()) 35 | app.use(json()) 36 | // app.use(morgan('combined', { stream: new stream() })) 37 | 38 | // app.use(bodyParser.json({ 39 | // limit: '50mb', 40 | // verify(req: any, res, buf, encoding) { 41 | // req.rawBody = buf; 42 | // } 43 | // })); 44 | 45 | RegisterRoutes(app) 46 | 47 | app.use(function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): Response | void { 48 | if (err instanceof ValidateError) { 49 | console.warn(`Caught Validation Error for ${req.path}:`, err.fields) 50 | return res.status(422).json({ 51 | message: 'Validation Failed', 52 | details: err?.fields, 53 | }) 54 | } 55 | 56 | if (err instanceof Error) { 57 | console.log(err) 58 | return res.status(500).json({ 59 | message: 'Something went wrong!', 60 | }) 61 | } 62 | 63 | next() 64 | }) 65 | 66 | app.use('/docs', swaggerUi.serve, async (_req: Request, res: Response) => { 67 | return res.send(swaggerUi.generateHTML(await import('../swagger.json'))) 68 | }) 69 | 70 | export { app } 71 | -------------------------------------------------------------------------------- /backend/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | env: process.env.NODE_ENV || 'development', 3 | port: process.env.PORT || 5000, 4 | jwtSecret: process.env.JWT_SECRET || '6oQKw5jkUzSQQycttMmJ', 5 | dbUrl: process.env.DB_URL || 'mongodb://localhost:27017/cardboard', 6 | uiOrigin: process.env.UI_ORIGIN || 'http://localhost:3000', 7 | logfile: process.env.LOG_FILE || false, 8 | logLevel: process.env.LOG_LEVEL || 'info', 9 | database: { 10 | type: 'sqlite', 11 | database: '../test.db', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/config/winston.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston' 2 | import config from './index' 3 | 4 | // creates a new Winston Logger 5 | export const logger = winston.createLogger({ 6 | transports: [ 7 | // ...(config.logfile && [ 8 | // new winston.transports.File({ 9 | // level: 'info', 10 | // filename: config.logfile as string, 11 | // handleExceptions: true, 12 | // maxsize: 5242880, //5MB 13 | // maxFiles: 5, 14 | // }), 15 | // ]), 16 | new winston.transports.Console({ 17 | level: config.logLevel as string, 18 | handleExceptions: true, 19 | format: winston.format.combine(winston.format.colorize(), winston.format.simple()), 20 | }), 21 | ], 22 | exitOnError: false, 23 | }) 24 | 25 | export class stream { 26 | write(message: string): void { 27 | logger.info(message) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/controllers/PayeesController.ts: -------------------------------------------------------------------------------- 1 | import { Get, Route, Path, Security, Post, Body, Controller, Tags, Request, Example } from 'tsoa' 2 | import { Budget } from '../entities/Budget' 3 | import { ExpressRequest } from './requests' 4 | import { ErrorResponse } from './responses' 5 | import { PayeeRequest, PayeeResponse, PayeesResponse } from '../models/Payee' 6 | import { Payee } from '../entities/Payee' 7 | import { getRepository } from 'typeorm' 8 | 9 | @Tags('Payees') 10 | @Route('budgets/{budgetId}/payees') 11 | export class PayeesController extends Controller { 12 | /** 13 | * Create a new payee 14 | */ 15 | @Security('jwtRequired') 16 | @Post() 17 | @Example({ 18 | message: 'success', 19 | data: { 20 | id: 'abc123', 21 | transferAccountId: null, 22 | name: 'Random Store Name', 23 | internal: false, 24 | created: new Date('2011-10-05T14:48:00.000Z'), 25 | updated: new Date('2011-10-05T14:48:00.000Z'), 26 | }, 27 | }) 28 | public async createPayee( 29 | @Path() budgetId: string, 30 | @Body() requestBody: PayeeRequest, 31 | @Request() request: ExpressRequest, 32 | ): Promise { 33 | try { 34 | const budget = await getRepository(Budget).findOne(budgetId) 35 | if (!budget || budget.userId !== request.user.id) { 36 | this.setStatus(404) 37 | return { 38 | message: 'Not found', 39 | } 40 | } 41 | 42 | const payee = getRepository(Payee).create({ 43 | ...requestBody, 44 | budgetId, 45 | }) 46 | await getRepository(Payee).insert(payee) 47 | 48 | return { 49 | message: 'success', 50 | data: await payee.toResponseModel(), 51 | } 52 | } catch (err) { 53 | return { message: err.message } 54 | } 55 | } 56 | 57 | /** 58 | * Find all budget payees 59 | */ 60 | @Security('jwtRequired') 61 | @Get() 62 | @Example({ 63 | message: 'success', 64 | data: [ 65 | { 66 | id: 'abc123', 67 | transferAccountId: null, 68 | name: 'Random Store Name', 69 | internal: false, 70 | created: new Date('2011-10-05T14:48:00.000Z'), 71 | updated: new Date('2011-10-05T14:48:00.000Z'), 72 | }, 73 | ], 74 | }) 75 | public async getPayees( 76 | @Path() budgetId: string, 77 | @Request() request: ExpressRequest, 78 | ): Promise { 79 | try { 80 | const budget = await getRepository(Budget).findOne(budgetId) 81 | if (!budget || budget.userId !== request.user.id) { 82 | this.setStatus(404) 83 | return { 84 | message: 'Not found', 85 | } 86 | } 87 | 88 | const payees = await getRepository(Payee).find({ where: { budgetId } }) 89 | 90 | return { 91 | message: 'success', 92 | data: await Promise.all(payees.map(payee => payee.toResponseModel())), 93 | } 94 | } catch (err) { 95 | return { message: err.message } 96 | } 97 | } 98 | 99 | /** 100 | * Find a single budget payee 101 | */ 102 | @Security('jwtRequired') 103 | @Get('{payeeId}') 104 | @Example({ 105 | message: 'success', 106 | data: { 107 | id: 'abc123', 108 | transferAccountId: null, 109 | name: 'Random Store Name', 110 | internal: false, 111 | created: new Date('2011-10-05T14:48:00.000Z'), 112 | updated: new Date('2011-10-05T14:48:00.000Z'), 113 | }, 114 | }) 115 | public async getPayee( 116 | @Path() budgetId: string, 117 | @Path() payeeId: string, 118 | @Request() request: ExpressRequest, 119 | ): Promise { 120 | try { 121 | const budget = await getRepository(Budget).findOne(budgetId) 122 | if (!budget || budget.userId !== request.user.id) { 123 | this.setStatus(404) 124 | return { 125 | message: 'Not found', 126 | } 127 | } 128 | 129 | const payee = await getRepository(Payee).findOne(payeeId) 130 | 131 | return { 132 | message: 'success', 133 | data: await payee.toResponseModel(), 134 | } 135 | } catch (err) { 136 | return { message: err.message } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /backend/src/controllers/RootController.ts: -------------------------------------------------------------------------------- 1 | import { LoginResponse, LogoutResponse, UserResponse } from '../models/User' 2 | import { Get, Security, Route, Post, Body, Controller, Tags, Example, Request } from 'tsoa' 3 | import { User } from '../entities/User' 4 | import { LoginRequest, ExpressRequest } from './requests' 5 | import { ErrorResponse } from './responses' 6 | import { getRepository } from 'typeorm' 7 | 8 | @Route() 9 | export class RootController extends Controller { 10 | /** 11 | * Log in and retrieve token for authenticating requests 12 | */ 13 | @Tags('Authentication') 14 | @Post('login') 15 | @Example({ 16 | message: 'success', 17 | data: { 18 | id: 'abc123', 19 | email: 'alex@example.com', 20 | created: new Date('2011-10-05T14:48:00.000Z'), 21 | updated: new Date('2011-10-05T14:48:00.000Z'), 22 | }, 23 | token: '1234abcd', 24 | }) 25 | public async login( 26 | @Body() requestBody: LoginRequest, 27 | @Request() request: ExpressRequest, 28 | ): Promise { 29 | const { email, password } = requestBody 30 | const user: User = await getRepository(User).findOne({ email }) 31 | 32 | if (!user) { 33 | this.setStatus(403) 34 | return { message: '' } 35 | } 36 | 37 | if (!password || !user.checkPassword(password)) { 38 | this.setStatus(403) 39 | return { message: '' } 40 | } 41 | 42 | const token = user.generateJWT() 43 | 44 | this.setHeader('Set-Cookie', `jwt=${token}; Max-Age=3600; Path=/; HttpOnly`) 45 | 46 | return { 47 | data: await user.toResponseModel(), 48 | token: token, 49 | } 50 | } 51 | 52 | /** 53 | * Retrieve currently logged in user 54 | */ 55 | @Security('jwtRequired') 56 | @Post('ping') 57 | @Example({ 58 | message: 'success', 59 | data: { 60 | id: 'abc123', 61 | email: 'alex@example.com', 62 | created: new Date('2011-10-05T14:48:00.000Z'), 63 | updated: new Date('2011-10-05T14:48:00.000Z'), 64 | }, 65 | }) 66 | public async ping(@Request() request: ExpressRequest): Promise { 67 | try { 68 | const token = request.user.generateJWT() 69 | 70 | this.setHeader('Set-Cookie', `jwt=${token}; Max-Age=3600; Path=/; HttpOnly`) 71 | 72 | return { 73 | data: await request.user.toResponseModel(), 74 | message: 'success', 75 | } 76 | } catch (err) { 77 | return { 78 | message: 'failed', 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * Logout and clear existing JWT 85 | */ 86 | @Tags('Authentication') 87 | @Get('logout') 88 | @Example({ 89 | message: 'success', 90 | }) 91 | public async logout(@Request() request: ExpressRequest): Promise { 92 | this.setHeader('Set-Cookie', `jwt=0; Max-Age=3600; Path=/; HttpOnly; expires=Thu, 01 Jan 1970 00:00:01 GMT`) 93 | 94 | return { message: '' } 95 | } 96 | 97 | /** 98 | * Retrieve currently logged in user 99 | */ 100 | @Security('jwtRequired') 101 | @Get('me') 102 | @Example({ 103 | message: 'success', 104 | data: { 105 | id: 'abc123', 106 | email: 'alex@example.com', 107 | created: new Date('2011-10-05T14:48:00.000Z'), 108 | updated: new Date('2011-10-05T14:48:00.000Z'), 109 | }, 110 | }) 111 | public async getMe(@Request() request: ExpressRequest): Promise { 112 | try { 113 | const user: User = await getRepository(User).findOne({ email: request.user.email }) 114 | 115 | return { 116 | data: await user.toResponseModel(), 117 | message: 'success', 118 | } 119 | } catch (err) { 120 | return { 121 | message: 'failed', 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /backend/src/controllers/UsersController.ts: -------------------------------------------------------------------------------- 1 | import { UserResponse } from '../models/User' 2 | import { Get, Route, Path, Security, Post, Put, Body, Controller, Tags, Request, Example } from 'tsoa' 3 | import { User } from '../entities/User' 4 | import { ExpressRequest, UserCreateRequest, UserUpdateRequest } from './requests' 5 | import { ErrorResponse } from './responses' 6 | import { getManager, getRepository } from 'typeorm' 7 | 8 | @Tags('Users') 9 | @Route('users') 10 | export class UsersController extends Controller { 11 | /** 12 | * Create a new user 13 | */ 14 | @Post() 15 | @Example({ 16 | message: 'success', 17 | data: { 18 | id: 'abc123', 19 | email: 'alex@example.com', 20 | created: new Date('2011-10-05T14:48:00.000Z'), 21 | updated: new Date('2011-10-05T14:48:00.000Z'), 22 | }, 23 | }) 24 | public async createUser(@Body() requestBody: UserCreateRequest): Promise { 25 | if (process.env.REGISTRATION_DISABLED?.match(/true|1/i)) { 26 | this.setStatus(400) 27 | return { message: 'Registration is disabled' } 28 | } 29 | 30 | const { email } = requestBody 31 | 32 | const emailCheck: User = await getRepository(User).findOne({ email }) 33 | if (emailCheck) { 34 | this.setStatus(400) 35 | return { message: 'Email already exists' } 36 | } 37 | 38 | try { 39 | const newUser = await getManager().transaction(async transactionalEntityManager => { 40 | const newUser: User = transactionalEntityManager.getRepository(User).create({ ...requestBody }) 41 | await transactionalEntityManager.getRepository(User).insert(newUser) 42 | return newUser 43 | }) 44 | 45 | return { 46 | message: 'success', 47 | data: await newUser.toResponseModel(), 48 | } 49 | } catch (err) { 50 | return { message: err.message } 51 | } 52 | } 53 | 54 | /** 55 | * Retrieve an existing user 56 | * 57 | * @param email Email of the user you are retrieving 58 | * @example email "alex@example.com" 59 | */ 60 | @Security('jwtRequired') 61 | @Get('{email}') 62 | @Example({ 63 | message: 'success', 64 | data: { 65 | id: 'abc123', 66 | email: 'alex@example.com', 67 | created: new Date('2011-10-05T14:48:00.000Z'), 68 | updated: new Date('2011-10-05T14:48:00.000Z'), 69 | }, 70 | }) 71 | public async getUserByEmail(@Path() email: string): Promise { 72 | try { 73 | const user: User = await getRepository(User).findOne({ email }) 74 | 75 | return { 76 | data: await user.toResponseModel(), 77 | message: 'success', 78 | } 79 | } catch (err) { 80 | return { 81 | message: 'failed', 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * Update a user 88 | * 89 | * @param email Email of the user you are updating 90 | * @example email "alex@example.com" 91 | */ 92 | @Security('jwtRequired') 93 | @Put() 94 | @Example({ 95 | message: 'success', 96 | data: { 97 | id: 'abc123', 98 | email: 'alex@example.com', 99 | created: new Date('2011-10-05T14:48:00.000Z'), 100 | updated: new Date('2011-10-05T14:48:00.000Z'), 101 | }, 102 | }) 103 | public async updateUser( 104 | @Body() requestBody: UserUpdateRequest, 105 | @Request() request: ExpressRequest, 106 | ): Promise { 107 | if (requestBody.password && requestBody.currentPassword) { 108 | if (!request.user.checkPassword(requestBody.currentPassword)) { 109 | this.setStatus(400) 110 | return { 111 | message: 'Your current password is incorrect', 112 | } 113 | } 114 | 115 | request.user.password = requestBody.password 116 | } else { 117 | delete requestBody.password 118 | } 119 | 120 | delete requestBody.currentPassword 121 | 122 | if (requestBody.email) { 123 | request.user.email = requestBody.email 124 | } 125 | 126 | try { 127 | await getRepository(User).save(request.user) 128 | 129 | return { 130 | data: await request.user.toResponseModel(), 131 | message: 'success', 132 | } 133 | } catch (err) { 134 | return { 135 | message: err.message, 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /backend/src/controllers/requests.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { User } from '../entities/User' 3 | 4 | export interface ExpressRequest extends express.Request { 5 | user?: User 6 | } 7 | 8 | /** 9 | * @example { 10 | * "email": "alex\u0040example.com", 11 | * "password": "supersecurepassword" 12 | * } 13 | */ 14 | export interface LoginRequest { 15 | email: string 16 | password: string 17 | } 18 | 19 | /** 20 | * @example { 21 | * "email": "alex\u0040example.com", 22 | * "password": "supersecurepassword" 23 | * } 24 | */ 25 | export interface UserCreateRequest { 26 | email: string 27 | password: string 28 | } 29 | 30 | /** 31 | * @example { 32 | * "currentPassword": "oldPassword", 33 | * "password": "myUpdatedPassword" 34 | * } 35 | */ 36 | export interface UserUpdateRequest { 37 | /** 38 | * Updated email address 39 | */ 40 | email?: string 41 | 42 | /** 43 | * Current password if updating to a new password 44 | */ 45 | currentPassword?: string 46 | 47 | /** 48 | * New password 49 | */ 50 | password?: string 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/controllers/responses.ts: -------------------------------------------------------------------------------- 1 | export interface BaseResponse { 2 | message?: string 3 | } 4 | 5 | export interface DataResponse extends BaseResponse { 6 | data?: T 7 | } 8 | 9 | /** 10 | * @example { 11 | * message: "Not allowed", 12 | * } 13 | */ 14 | export interface ErrorResponse { 15 | /** 16 | * Message of the error 17 | */ 18 | message: string 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/entities/Account.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '../models/Account' 2 | import { 3 | Entity, 4 | OneToOne, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | ManyToOne, 9 | OneToMany, 10 | JoinColumn, 11 | } from 'typeorm' 12 | import { Budget } from './Budget' 13 | import { Transaction } from './Transaction' 14 | import { Payee } from './Payee' 15 | 16 | export enum AccountTypes { 17 | Bank, 18 | CreditCard, 19 | Tracking, 20 | } 21 | 22 | @Entity('accounts') 23 | export class Account { 24 | @PrimaryGeneratedColumn('uuid') 25 | id: string 26 | 27 | @Column({ type: 'varchar', nullable: false }) 28 | budgetId: string 29 | 30 | @Column({ type: 'varchar', nullable: true }) 31 | transferPayeeId: string 32 | 33 | @Column({ type: 'varchar' }) 34 | name: string 35 | 36 | @Column({ type: 'int' }) 37 | type: AccountTypes 38 | 39 | @Column({ 40 | type: 'int', 41 | default: 0, 42 | }) 43 | balance: number = 0 44 | 45 | @Column({ 46 | type: 'int', 47 | default: 0, 48 | }) 49 | cleared: number = 0 50 | 51 | @Column({ 52 | type: 'int', 53 | default: 0, 54 | }) 55 | uncleared: number = 0 56 | 57 | @Column({ type: 'int', default: 0 }) 58 | order: number = 0 59 | 60 | @CreateDateColumn() 61 | created: Date 62 | 63 | @CreateDateColumn() 64 | updated: Date 65 | 66 | /** 67 | * Belongs to a budget 68 | */ 69 | @ManyToOne(() => Budget, budget => budget.accounts, { onDelete: 'CASCADE' }) 70 | budget: Promise 71 | 72 | /** 73 | * Has many transactions 74 | */ 75 | @OneToMany(() => Transaction, transaction => transaction.account) 76 | transactions: Promise 77 | 78 | /** 79 | * Can have one payee 80 | */ 81 | @OneToOne(() => Payee, payee => payee.transferAccount) 82 | @JoinColumn() 83 | transferPayee: Promise 84 | 85 | public getUpdatePayload() { 86 | return { 87 | id: this.id, 88 | budgetId: this.budgetId, 89 | transferPayeeId: this.transferPayeeId, 90 | name: this.name, 91 | type: this.type, 92 | balance: this.balance, 93 | cleared: this.cleared, 94 | uncleared: this.uncleared, 95 | } 96 | } 97 | 98 | public async toResponseModel(): Promise { 99 | return { 100 | id: this.id, 101 | budgetId: this.budgetId, 102 | transferPayeeId: this.transferPayeeId, 103 | name: this.name, 104 | type: this.type, 105 | balance: this.balance, 106 | cleared: this.cleared, 107 | uncleared: this.uncleared, 108 | order: this.order, 109 | created: this.created, 110 | updated: this.updated, 111 | } 112 | } 113 | 114 | public static sort(accounts: Account[]): Account[] { 115 | accounts = accounts.sort((a, b) => { 116 | if (a.order === b.order) { 117 | return a.name > b.name ? -1 : 1 118 | } 119 | return a.order < b.order ? -1 : 1 120 | }) 121 | 122 | return accounts.map((group, index) => { 123 | group.order = index 124 | return group 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /backend/src/entities/Budget.ts: -------------------------------------------------------------------------------- 1 | import { BudgetModel } from '../models/Budget' 2 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, OneToMany, DeepPartial } from 'typeorm' 3 | import { User } from './User' 4 | import { Account } from './Account' 5 | import { CategoryGroup } from './CategoryGroup' 6 | import { Category } from './Category' 7 | import { BudgetMonth } from './BudgetMonth' 8 | import { Transaction } from './Transaction' 9 | import { Payee } from './Payee' 10 | 11 | @Entity('budgets') 12 | export class Budget { 13 | @PrimaryGeneratedColumn('uuid') 14 | id: string 15 | 16 | @Column({ type: 'varchar', nullable: false }) 17 | userId: string 18 | 19 | @Column({ type: 'varchar' }) 20 | name: string 21 | 22 | @Column({ type: 'varchar', default: 'USD' }) 23 | currency: string 24 | 25 | @CreateDateColumn() 26 | created: Date 27 | 28 | @CreateDateColumn() 29 | updated: Date 30 | 31 | /** 32 | * Belongs to a user 33 | */ 34 | @ManyToOne(() => User, user => user.budgets, { onDelete: 'CASCADE' }) 35 | user: User 36 | 37 | /** 38 | * Has many accounts 39 | */ 40 | @OneToMany(() => Account, account => account.budget) 41 | accounts: Promise 42 | 43 | /** 44 | * Has many categories 45 | */ 46 | @OneToMany(() => Category, category => category.budget) 47 | categories: Promise 48 | 49 | /** 50 | * Has many category groups 51 | */ 52 | @OneToMany(() => CategoryGroup, categoryGroup => categoryGroup.budget) 53 | categoryGroups: Promise 54 | 55 | /** 56 | * Has many budget months 57 | */ 58 | @OneToMany(() => BudgetMonth, budgetMonth => budgetMonth.budget) 59 | months: Promise 60 | 61 | /** 62 | * Has many budget transactions 63 | */ 64 | @OneToMany(() => Transaction, transaction => transaction.budget) 65 | transactions: Promise 66 | 67 | /** 68 | * Has many budget transactions 69 | */ 70 | @OneToMany(() => Payee, payee => payee.budget) 71 | payees: Promise 72 | 73 | public update(partial: DeepPartial): Budget { 74 | Object.assign(this, partial) 75 | return this 76 | } 77 | 78 | public getUpdatePayload() { 79 | return { 80 | id: this.id, 81 | userId: this.userId, 82 | name: this.name, 83 | currency: this.currency, 84 | } 85 | } 86 | 87 | public async toResponseModel(): Promise { 88 | return { 89 | id: this.id, 90 | name: this.name, 91 | currency: this.currency, 92 | accounts: await Promise.all((await this.accounts).map(account => account.toResponseModel())), 93 | created: this.created, 94 | updated: this.updated, 95 | } 96 | } 97 | 98 | public async getMonths(): Promise { 99 | return (await this.months).map(month => month.month).sort() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /backend/src/entities/BudgetMonth.ts: -------------------------------------------------------------------------------- 1 | import { BudgetMonthModel } from '../models/BudgetMonth' 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | CreateDateColumn, 7 | ManyToOne, 8 | Index, 9 | OneToMany, 10 | AfterLoad, 11 | } from 'typeorm' 12 | import { Budget } from './Budget' 13 | import { CategoryMonth } from './CategoryMonth' 14 | 15 | export type BudgetMonthOriginalValues = { 16 | income: number 17 | budgeted: number 18 | activity: number 19 | available: number 20 | underfunded: number 21 | } 22 | 23 | export class BudgetMonthCache { 24 | static cache: { [key: string]: BudgetMonthOriginalValues } = {} 25 | 26 | public static get(id: string): BudgetMonthOriginalValues | null { 27 | if (BudgetMonthCache.cache[id]) { 28 | return BudgetMonthCache.cache[id] 29 | } 30 | 31 | return { 32 | income: 0, 33 | budgeted: 0, 34 | activity: 0, 35 | available: 0, 36 | underfunded: 0, 37 | } 38 | } 39 | 40 | public static set(budgetMonth: BudgetMonth) { 41 | BudgetMonthCache.cache[budgetMonth.id] = { 42 | income: budgetMonth.income, 43 | budgeted: budgetMonth.budgeted, 44 | activity: budgetMonth.activity, 45 | available: budgetMonth.available, 46 | underfunded: budgetMonth.underfunded, 47 | } 48 | } 49 | } 50 | 51 | @Entity('budget_months') 52 | export class BudgetMonth { 53 | @PrimaryGeneratedColumn('uuid') 54 | id: string 55 | 56 | @Column({ type: 'varchar', nullable: false }) 57 | @Index() 58 | budgetId: string 59 | 60 | @Column({ type: 'varchar', nullable: false }) 61 | @Index() 62 | month: string 63 | 64 | @Column({ 65 | type: 'int', 66 | default: 0, 67 | }) 68 | income: number = 0 69 | 70 | @Column({ 71 | type: 'int', 72 | default: 0, 73 | }) 74 | budgeted: number = 0 75 | 76 | @Column({ 77 | type: 'int', 78 | default: 0, 79 | }) 80 | activity: number = 0 81 | 82 | @Column({ 83 | type: 'int', 84 | default: 0, 85 | }) 86 | available: number = 0 87 | 88 | @Column({ 89 | type: 'int', 90 | default: 0, 91 | }) 92 | underfunded: number = 0 93 | 94 | @CreateDateColumn() 95 | created: Date 96 | 97 | @CreateDateColumn() 98 | updated: Date 99 | 100 | /** 101 | * Belongs to a budget 102 | */ 103 | @ManyToOne(() => Budget, budget => budget.months, { onDelete: 'CASCADE' }) 104 | budget: Promise 105 | 106 | /** 107 | * Has many category months 108 | */ 109 | @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.budgetMonth) 110 | categories: Promise 111 | 112 | @AfterLoad() 113 | private storeOriginalValues(): void { 114 | BudgetMonthCache.set(this) 115 | } 116 | 117 | public getUpdatePayload() { 118 | return { 119 | id: this.id, 120 | budgetId: this.budgetId, 121 | month: this.month, 122 | income: this.income, 123 | budgeted: this.budgeted, 124 | activity: this.activity, 125 | available: this.available, 126 | underfunded: this.underfunded, 127 | } 128 | } 129 | 130 | public async toResponseModel(): Promise { 131 | return { 132 | id: this.id, 133 | budgetId: this.budgetId, 134 | month: this.month, 135 | income: this.income, 136 | budgeted: this.budgeted, 137 | activity: this.activity, 138 | available: this.available, 139 | underfunded: this.underfunded, 140 | created: this.created, 141 | updated: this.updated, 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /backend/src/entities/Category.ts: -------------------------------------------------------------------------------- 1 | import { CategoryModel } from '../models/Category' 2 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, OneToMany, Index } from 'typeorm' 3 | import { CategoryGroup } from './CategoryGroup' 4 | import { CategoryMonth } from './CategoryMonth' 5 | import { Transaction } from './Transaction' 6 | import { Budget } from './Budget' 7 | 8 | @Entity('categories') 9 | export class Category { 10 | @PrimaryGeneratedColumn('uuid') 11 | id: string 12 | 13 | @Column({ type: 'varchar', nullable: false }) 14 | @Index() 15 | budgetId: string 16 | 17 | @Column({ type: 'varchar', nullable: false }) 18 | categoryGroupId: string 19 | 20 | @Index({ unique: true }) 21 | @Column({ type: 'varchar', nullable: true }) 22 | trackingAccountId: string 23 | 24 | @Column({ type: 'varchar' }) 25 | name: string 26 | 27 | @Column({ type: 'boolean', default: false }) 28 | inflow: boolean 29 | 30 | @Column({ type: 'boolean', default: false }) 31 | locked: boolean 32 | 33 | @Column({ type: 'int', default: 0 }) 34 | order: number = 0 35 | 36 | @CreateDateColumn() 37 | created: Date 38 | 39 | @CreateDateColumn() 40 | updated: Date 41 | 42 | /** 43 | * Belongs to a budget 44 | */ 45 | @ManyToOne(() => Budget, budget => budget.categories) 46 | budget: Budget 47 | 48 | /** 49 | * Belongs to a category group 50 | */ 51 | @ManyToOne(() => CategoryGroup, categoryGroup => categoryGroup.categories, { onDelete: 'CASCADE' }) 52 | categoryGroup: CategoryGroup 53 | 54 | /** 55 | * Has many months 56 | */ 57 | @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.category) 58 | categoryMonths: CategoryMonth[] 59 | 60 | /** 61 | * Has many transactions 62 | */ 63 | @OneToMany(() => Transaction, transaction => transaction.category) 64 | transactions: Transaction[] 65 | 66 | public getUpdatePayload() { 67 | return { 68 | id: this.id, 69 | budgetId: this.budgetId, 70 | categoryGroupId: this.categoryGroupId, 71 | trackingAccountId: this.trackingAccountId, 72 | name: this.name, 73 | inflow: this.inflow, 74 | locked: this.locked, 75 | order: this.order, 76 | } 77 | } 78 | 79 | public async toResponseModel(): Promise { 80 | return { 81 | id: this.id, 82 | categoryGroupId: this.categoryGroupId, 83 | trackingAccountId: this.trackingAccountId, 84 | name: this.name, 85 | inflow: this.inflow, 86 | locked: this.locked, 87 | order: this.order, 88 | created: this.created, 89 | updated: this.updated, 90 | } 91 | } 92 | 93 | public static sort(categories: Category[]): Category[] { 94 | categories.sort((a, b) => { 95 | if (a.order === b.order) { 96 | return a.name < b.name ? -1 : 1 97 | } 98 | return a.order < b.order ? -1 : 1 99 | }) 100 | 101 | return categories.map((cat, index) => { 102 | cat.order = index 103 | return cat 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /backend/src/entities/CategoryGroup.ts: -------------------------------------------------------------------------------- 1 | import { CategoryGroupModel } from '../models/CategoryGroup' 2 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, OneToMany, Index } from 'typeorm' 3 | import { Budget } from './Budget' 4 | import { Category } from './Category' 5 | 6 | export const CreditCardGroupName = 'Credit Card Payments' 7 | 8 | @Entity('category_groups') 9 | export class CategoryGroup { 10 | @PrimaryGeneratedColumn('uuid') 11 | id: string 12 | 13 | @Column({ type: 'varchar', nullable: false }) 14 | @Index() 15 | budgetId: string 16 | 17 | @Column({ type: 'varchar' }) 18 | name: string 19 | 20 | @Column({ type: 'boolean', default: false }) 21 | internal: boolean 22 | 23 | @Column({ type: 'boolean', default: false }) 24 | locked: boolean 25 | 26 | @Column({ type: 'int', default: 0 }) 27 | order: number = 0 28 | 29 | @CreateDateColumn() 30 | created: Date 31 | 32 | @CreateDateColumn() 33 | updated: Date 34 | 35 | /** 36 | * Belongs to a budget 37 | */ 38 | @ManyToOne(() => Budget, budget => budget.categoryGroups, { onDelete: 'CASCADE' }) 39 | budget: Budget 40 | 41 | /** 42 | * Has many categories 43 | */ 44 | @OneToMany(() => Category, category => category.categoryGroup, { eager: true }) 45 | categories: Promise 46 | 47 | public getUpdatePayload() { 48 | return { 49 | id: this.id, 50 | budgetId: this.budgetId, 51 | name: this.name, 52 | internal: this.internal, 53 | locked: this.locked, 54 | order: this.order, 55 | } 56 | } 57 | 58 | public async toResponseModel(): Promise { 59 | return { 60 | id: this.id, 61 | budgetId: this.budgetId, 62 | name: this.name, 63 | internal: this.internal, 64 | locked: this.locked, 65 | order: this.order, 66 | categories: await Promise.all((await this.categories).map(category => category.toResponseModel())), 67 | created: this.created, 68 | updated: this.created, 69 | } 70 | } 71 | 72 | public static sort(categoryGroups: CategoryGroup[]): CategoryGroup[] { 73 | categoryGroups = categoryGroups.sort((a, b) => { 74 | if (a.order === b.order) { 75 | return a.name > b.name ? -1 : 1 76 | } 77 | return a.order < b.order ? -1 : 1 78 | }) 79 | 80 | return categoryGroups.map((group, index) => { 81 | group.order = index 82 | return group 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /backend/src/entities/CategoryMonth.ts: -------------------------------------------------------------------------------- 1 | import { CategoryMonthModel } from '../models/CategoryMonth' 2 | import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, Index, AfterLoad } from 'typeorm' 3 | import { BudgetMonth } from './BudgetMonth' 4 | import { Category } from './Category' 5 | 6 | export type CategoryMonthOriginalValues = { 7 | budgeted: number 8 | activity: number 9 | balance: number 10 | } 11 | 12 | export class CategoryMonthCache { 13 | static cache: { [key: string]: CategoryMonthOriginalValues } = {} 14 | 15 | public static get(id: string): CategoryMonthOriginalValues | null { 16 | if (CategoryMonthCache.cache[id]) { 17 | return CategoryMonthCache.cache[id] 18 | } 19 | 20 | return { 21 | budgeted: 0, 22 | activity: 0, 23 | balance: 0, 24 | } 25 | } 26 | 27 | public static set(categoryMonth: CategoryMonth) { 28 | CategoryMonthCache.cache[categoryMonth.id] = { 29 | budgeted: categoryMonth.budgeted, 30 | activity: categoryMonth.activity, 31 | balance: categoryMonth.balance, 32 | } 33 | } 34 | } 35 | 36 | @Entity('category_months') 37 | export class CategoryMonth { 38 | @PrimaryGeneratedColumn('uuid') 39 | id: string 40 | 41 | @Column({ type: 'varchar', nullable: false }) 42 | @Index() 43 | categoryId: string 44 | 45 | @Column({ type: 'varchar', nullable: false }) 46 | @Index() 47 | budgetMonthId: string 48 | 49 | @Column({ type: 'varchar', nullable: false }) 50 | @Index() 51 | month: string 52 | 53 | @Column({ 54 | type: 'int', 55 | default: 0, 56 | }) 57 | budgeted: number = 0 58 | 59 | @Column({ 60 | type: 'int', 61 | default: 0, 62 | }) 63 | activity: number = 0 64 | 65 | @Column({ 66 | type: 'int', 67 | default: 0, 68 | }) 69 | balance: number = 0 70 | 71 | @CreateDateColumn() 72 | created: Date 73 | 74 | @CreateDateColumn() 75 | updated: Date 76 | 77 | /** 78 | * Belongs to a category 79 | */ 80 | @ManyToOne(() => Category, category => category.categoryMonths, { onDelete: 'CASCADE' }) 81 | category: Promise 82 | 83 | /** 84 | * Belongs to a budget month 85 | */ 86 | @ManyToOne(() => BudgetMonth, budgetMonth => budgetMonth.categories) 87 | budgetMonth: Promise 88 | 89 | @AfterLoad() 90 | private storeOriginalValues(): void { 91 | CategoryMonthCache.set(this) 92 | } 93 | 94 | public getUpdatePayload() { 95 | return { 96 | id: this.id, 97 | categoryId: this.categoryId, 98 | budgetMonthId: this.budgetMonthId, 99 | month: this.month, 100 | budgeted: this.budgeted, 101 | activity: this.activity, 102 | balance: this.balance, 103 | } 104 | } 105 | 106 | public update({ activity, budgeted }: { [key: string]: number }) { 107 | if (activity !== undefined) { 108 | this.activity = this.activity + activity 109 | this.balance = this.balance + activity 110 | } 111 | if (budgeted !== undefined) { 112 | const budgetedDifference = budgeted - this.budgeted 113 | this.budgeted = this.budgeted + budgetedDifference 114 | this.balance = this.balance + budgetedDifference 115 | } 116 | } 117 | 118 | public async toResponseModel(): Promise { 119 | return { 120 | id: this.id, 121 | categoryId: this.categoryId, 122 | month: this.month, 123 | budgeted: this.budgeted, 124 | activity: this.activity, 125 | balance: this.balance, 126 | created: this.created, 127 | updated: this.updated, 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /backend/src/entities/Payee.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | OneToOne, 4 | JoinColumn, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | OneToMany, 9 | ManyToOne, 10 | } from 'typeorm' 11 | import { Account } from './Account' 12 | import { PayeeModel } from '../models/Payee' 13 | import { Transaction } from './Transaction' 14 | import { Budget } from './Budget' 15 | 16 | @Entity('payees') 17 | export class Payee { 18 | @PrimaryGeneratedColumn('uuid') 19 | id: string 20 | 21 | @Column({ type: 'varchar', nullable: false }) 22 | budgetId: string 23 | 24 | @Column({ type: 'varchar', nullable: true }) 25 | transferAccountId: string 26 | 27 | @Column({ type: 'varchar' }) 28 | name: string 29 | 30 | @Column({ type: 'boolean' }) 31 | internal: boolean = false 32 | 33 | @CreateDateColumn() 34 | created: Date 35 | 36 | @CreateDateColumn() 37 | updated: Date 38 | 39 | @ManyToOne(() => Budget, budget => budget.payees, { onDelete: 'CASCADE' }) 40 | budget: Promise 41 | 42 | @OneToOne(() => Account, account => account.transferPayee, { onDelete: 'CASCADE' }) 43 | @JoinColumn() 44 | transferAccount: Promise 45 | 46 | /** 47 | * Has many transactions 48 | */ 49 | @OneToMany(() => Transaction, transaction => transaction.account) 50 | transactions: Promise 51 | 52 | public async toResponseModel(): Promise { 53 | return { 54 | id: this.id, 55 | transferAccountId: this.transferAccountId, 56 | name: this.name, 57 | internal: this.internal, 58 | created: this.created, 59 | updated: this.updated, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/entities/Transaction.ts: -------------------------------------------------------------------------------- 1 | import { TransactionModel } from '../models/Transaction' 2 | import { 3 | Entity, 4 | AfterLoad, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | ManyToOne, 9 | DeepPartial, 10 | Index, 11 | } from 'typeorm' 12 | import { Account } from './Account' 13 | import { Category } from './Category' 14 | import { formatMonthFromDateString } from '../utils' 15 | import { Budget } from './Budget' 16 | import { Payee } from './Payee' 17 | 18 | export enum TransactionStatus { 19 | Pending, 20 | Cleared, 21 | Reconciled, 22 | } 23 | 24 | export type TransactionOriginalValues = { 25 | payeeId: string 26 | categoryId: string 27 | amount: number 28 | date: Date 29 | status: TransactionStatus 30 | } 31 | 32 | export class TransactionCache { 33 | static cache: { [key: string]: TransactionOriginalValues } = {} 34 | 35 | static transfers: string[] = [] 36 | 37 | public static get(id: string): TransactionOriginalValues | null { 38 | if (TransactionCache.cache[id]) { 39 | return TransactionCache.cache[id] 40 | } 41 | 42 | return null 43 | } 44 | 45 | public static set(transaction: Transaction) { 46 | TransactionCache.cache[transaction.id] = { 47 | payeeId: transaction.payeeId, 48 | categoryId: transaction.categoryId, 49 | amount: transaction.amount, 50 | date: new Date(transaction.date.getTime()), 51 | status: transaction.status, 52 | } 53 | } 54 | 55 | public static enableTransfers(id: string) { 56 | const index = TransactionCache.transfers.indexOf(id) 57 | if (index === -1) { 58 | TransactionCache.transfers.push(id) 59 | } 60 | } 61 | 62 | public static disableTransfers(id: string) { 63 | const index = TransactionCache.transfers.indexOf(id) 64 | if (index > -1) { 65 | TransactionCache.transfers.splice(index, 1) 66 | } 67 | } 68 | 69 | public static transfersEnabled(id: string): boolean { 70 | const index = TransactionCache.transfers.indexOf(id) 71 | if (index > -1) { 72 | return true 73 | } 74 | 75 | return false 76 | } 77 | } 78 | 79 | @Entity('transactions') 80 | export class Transaction { 81 | @PrimaryGeneratedColumn('uuid') 82 | id: string 83 | 84 | @Column({ type: 'varchar', nullable: false }) 85 | budgetId: string 86 | 87 | @Column({ type: 'varchar', nullable: false }) 88 | accountId: string 89 | 90 | @Column({ type: 'varchar', nullable: false }) 91 | payeeId: string 92 | 93 | @Column({ type: 'varchar', nullable: true }) 94 | transferAccountId: string 95 | 96 | @Index() 97 | @Column({ type: 'varchar', nullable: true, default: null }) 98 | transferTransactionId: string 99 | 100 | @Column({ type: 'varchar', nullable: true }) 101 | categoryId: string 102 | 103 | @Column({ 104 | type: 'int', 105 | default: 0, 106 | }) 107 | amount: number = 0 108 | 109 | @Column({ type: 'datetime' }) 110 | date: Date 111 | 112 | @Column({ type: 'varchar', default: '' }) 113 | memo: string 114 | 115 | @Column({ type: 'int', default: TransactionStatus.Pending }) 116 | status: TransactionStatus 117 | 118 | @CreateDateColumn() 119 | created: Date 120 | 121 | @CreateDateColumn() 122 | updated: Date 123 | 124 | /** 125 | * Belongs to a budget 126 | */ 127 | @ManyToOne(() => Budget, budget => budget.transactions) 128 | budget: Promise 129 | 130 | /** 131 | * Belongs to an account 132 | */ 133 | @ManyToOne(() => Account, account => account.transactions, { onDelete: 'CASCADE' }) 134 | account: Promise 135 | 136 | /** 137 | * Belongs to a payee (account type) 138 | */ 139 | @ManyToOne(() => Payee, payee => payee.transactions) 140 | payee: Promise 141 | 142 | /** 143 | * Belongs to a category 144 | */ 145 | @ManyToOne(() => Category, category => category.transactions) 146 | category: Promise 147 | 148 | @AfterLoad() 149 | private storeOriginalValues() { 150 | TransactionCache.set(this) 151 | } 152 | 153 | public update(partial: DeepPartial): Transaction { 154 | Object.assign(this, partial) 155 | return this 156 | } 157 | 158 | public getUpdatePayload() { 159 | return { 160 | id: this.id, 161 | budgetId: this.budgetId, 162 | accountId: this.accountId, 163 | payeeId: this.payeeId, 164 | transferAccountId: this.transferAccountId, 165 | transferTransactionId: this.transferTransactionId, 166 | categoryId: this.categoryId, 167 | amount: this.amount, 168 | date: this.date, 169 | memo: this.memo, 170 | status: this.status, 171 | } 172 | } 173 | 174 | public async toResponseModel(): Promise { 175 | return { 176 | id: this.id, 177 | accountId: this.accountId, 178 | payeeId: this.payeeId, 179 | amount: this.amount, 180 | date: this.date, 181 | memo: this.memo, 182 | categoryId: this.categoryId, 183 | status: this.status, 184 | created: this.created, 185 | updated: this.updated, 186 | } 187 | } 188 | 189 | public static getMonth(date: Date): string { 190 | return formatMonthFromDateString(date) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /backend/src/entities/User.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | AfterLoad, 6 | BeforeUpdate, 7 | BeforeInsert, 8 | Index, 9 | CreateDateColumn, 10 | OneToMany, 11 | } from 'typeorm' 12 | import bcrypt from 'bcrypt' 13 | import jwt from 'jsonwebtoken' 14 | import config from '../config' 15 | import { UserModel } from '../models/User' 16 | import { Budget } from './Budget' 17 | 18 | @Entity('users') 19 | export class User { 20 | @PrimaryGeneratedColumn('uuid') 21 | id: string 22 | 23 | @Column({ type: 'varchar' }) 24 | @Index({ unique: true }) 25 | email: string 26 | 27 | @Column({ type: 'varchar' }) 28 | password: string 29 | 30 | private currentPassword: string 31 | 32 | @OneToMany(() => Budget, budget => budget.user) 33 | budgets: Budget[] 34 | 35 | @CreateDateColumn() 36 | created: Date 37 | 38 | @CreateDateColumn() 39 | updated: Date 40 | 41 | @AfterLoad() 42 | private storeCurrentPassword(): void { 43 | this.currentPassword = this.password 44 | } 45 | 46 | @BeforeInsert() 47 | @BeforeUpdate() 48 | private encryptPassword(): void { 49 | if (this.currentPassword !== this.password) { 50 | this.password = User.hashPassword(this.password) 51 | } 52 | } 53 | 54 | public checkPassword(this: User, password: string): boolean { 55 | return bcrypt.compareSync(password, this.password) 56 | } 57 | 58 | public generateJWT(this: User): string { 59 | return jwt.sign({ userId: this.id, email: this.email, timestamp: Date.now() }, config.jwtSecret, { 60 | expiresIn: '1h', 61 | }) 62 | } 63 | 64 | public async toResponseModel(): Promise { 65 | return { 66 | id: this.id, 67 | email: this.email, 68 | created: this.created, 69 | updated: this.updated, 70 | } 71 | } 72 | 73 | public static hashPassword(password: string): string { 74 | return bcrypt.hashSync(password, 10) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /backend/src/middleware/authentication.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { Request } from 'express' 3 | import { User } from '../entities/User' 4 | import config from '../config' 5 | import { logger } from '../config/winston' 6 | import { getRepository } from 'typeorm' 7 | 8 | export async function expressAuthentication( 9 | request: Request, 10 | securityName: string, 11 | scopes?: string[] | undefined, 12 | ): Promise { 13 | const token = 14 | request.body.token || 15 | request.query.token || 16 | request.headers['x-access-token'] || 17 | request.headers['authorization'] || 18 | request.cookies['jwt'] 19 | 20 | let user: User = null 21 | if (token) { 22 | let jwtPayload = null 23 | try { 24 | jwtPayload = jwt.verify(token, config.jwtSecret) as any 25 | const { userId, email } = jwtPayload 26 | 27 | user = await getRepository(User).findOne(userId) 28 | } catch (err) {} 29 | } 30 | 31 | if (!user) { 32 | logger.debug('User is not authenticated') 33 | if (securityName === 'jwtRequired') { 34 | throw new Error('You must be logged in to do that') 35 | } 36 | 37 | return null 38 | } 39 | 40 | return user 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/migrations/1649260258195-budget-month-available.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class budgetMonthAvailable1649260258195 implements MigrationInterface { 4 | name = 'budgetMonthAvailable1649260258195' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "temporary_budgets" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "name" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "updated" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "FK_27e688ddf1ff3893b43065899f9" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, 9 | ) 10 | await queryRunner.query( 11 | `INSERT INTO "temporary_budgets"("id", "userId", "name", "created", "updated") SELECT "id", "userId", "name", "created", "updated" FROM "budgets"`, 12 | ) 13 | await queryRunner.query(`DROP TABLE "budgets"`) 14 | await queryRunner.query(`ALTER TABLE "temporary_budgets" RENAME TO "budgets"`) 15 | await queryRunner.query(`DROP INDEX "IDX_0c21df54422306fdf78621fc18"`) 16 | await queryRunner.query(`DROP INDEX "IDX_398c07457719d1899ba4f11914"`) 17 | await queryRunner.query( 18 | `CREATE TABLE "temporary_budget_months" ("id" varchar PRIMARY KEY NOT NULL, "budgetId" varchar NOT NULL, "month" varchar NOT NULL, "income" integer NOT NULL DEFAULT (0), "budgeted" integer NOT NULL DEFAULT (0), "activity" integer NOT NULL DEFAULT (0), "underfunded" integer NOT NULL DEFAULT (0), "created" datetime NOT NULL DEFAULT (datetime('now')), "updated" datetime NOT NULL DEFAULT (datetime('now')), "available" integer NOT NULL DEFAULT (0), CONSTRAINT "FK_398c07457719d1899ba4f11914d" FOREIGN KEY ("budgetId") REFERENCES "budgets" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, 19 | ) 20 | await queryRunner.query( 21 | `INSERT INTO "temporary_budget_months"("id", "budgetId", "month", "income", "budgeted", "activity", "underfunded", "created", "updated") SELECT "id", "budgetId", "month", "income", "budgeted", "activity", "underfunded", "created", "updated" FROM "budget_months"`, 22 | ) 23 | await queryRunner.query(`DROP TABLE "budget_months"`) 24 | await queryRunner.query(`ALTER TABLE "temporary_budget_months" RENAME TO "budget_months"`) 25 | await queryRunner.query(`CREATE INDEX "IDX_0c21df54422306fdf78621fc18" ON "budget_months" ("month") `) 26 | await queryRunner.query(`CREATE INDEX "IDX_398c07457719d1899ba4f11914" ON "budget_months" ("budgetId") `) 27 | } 28 | 29 | public async down(queryRunner: QueryRunner): Promise { 30 | await queryRunner.query(`DROP INDEX "IDX_398c07457719d1899ba4f11914"`) 31 | await queryRunner.query(`DROP INDEX "IDX_0c21df54422306fdf78621fc18"`) 32 | await queryRunner.query(`ALTER TABLE "budget_months" RENAME TO "temporary_budget_months"`) 33 | await queryRunner.query( 34 | `CREATE TABLE "budget_months" ("id" varchar PRIMARY KEY NOT NULL, "budgetId" varchar NOT NULL, "month" varchar NOT NULL, "income" integer NOT NULL DEFAULT (0), "budgeted" integer NOT NULL DEFAULT (0), "activity" integer NOT NULL DEFAULT (0), "underfunded" integer NOT NULL DEFAULT (0), "created" datetime NOT NULL DEFAULT (datetime('now')), "updated" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "FK_398c07457719d1899ba4f11914d" FOREIGN KEY ("budgetId") REFERENCES "budgets" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, 35 | ) 36 | await queryRunner.query( 37 | `INSERT INTO "budget_months"("id", "budgetId", "month", "income", "budgeted", "activity", "underfunded", "created", "updated") SELECT "id", "budgetId", "month", "income", "budgeted", "activity", "underfunded", "created", "updated" FROM "temporary_budget_months"`, 38 | ) 39 | await queryRunner.query(`DROP TABLE "temporary_budget_months"`) 40 | await queryRunner.query(`CREATE INDEX "IDX_398c07457719d1899ba4f11914" ON "budget_months" ("budgetId") `) 41 | await queryRunner.query(`CREATE INDEX "IDX_0c21df54422306fdf78621fc18" ON "budget_months" ("month") `) 42 | await queryRunner.query(`ALTER TABLE "budgets" RENAME TO "temporary_budgets"`) 43 | await queryRunner.query( 44 | `CREATE TABLE "budgets" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "name" varchar NOT NULL, "toBeBudgeted" integer NOT NULL DEFAULT (0), "created" datetime NOT NULL DEFAULT (datetime('now')), "updated" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "FK_27e688ddf1ff3893b43065899f9" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, 45 | ) 46 | await queryRunner.query( 47 | `INSERT INTO "budgets"("id", "userId", "name", "created", "updated") SELECT "id", "userId", "name", "created", "updated" FROM "temporary_budgets"`, 48 | ) 49 | await queryRunner.query(`DROP TABLE "temporary_budgets"`) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/migrations/1650032177205-add-budget-currency.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class addBudgetCurrency1650032177205 implements MigrationInterface { 4 | name = 'addBudgetCurrency1650032177205' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`CREATE TABLE "temporary_budgets" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "name" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "updated" datetime NOT NULL DEFAULT (datetime('now')), "currency" varchar NOT NULL DEFAULT ('USD'), CONSTRAINT "FK_27e688ddf1ff3893b43065899f9" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); 8 | await queryRunner.query(`INSERT INTO "temporary_budgets"("id", "userId", "name", "created", "updated") SELECT "id", "userId", "name", "created", "updated" FROM "budgets"`); 9 | await queryRunner.query(`DROP TABLE "budgets"`); 10 | await queryRunner.query(`ALTER TABLE "temporary_budgets" RENAME TO "budgets"`); 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(`ALTER TABLE "budgets" RENAME TO "temporary_budgets"`); 15 | await queryRunner.query(`CREATE TABLE "budgets" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "name" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "updated" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "FK_27e688ddf1ff3893b43065899f9" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); 16 | await queryRunner.query(`INSERT INTO "budgets"("id", "userId", "name", "created", "updated") SELECT "id", "userId", "name", "created", "updated" FROM "temporary_budgets"`); 17 | await queryRunner.query(`DROP TABLE "temporary_budgets"`); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/models/Account.ts: -------------------------------------------------------------------------------- 1 | import { AccountTypes } from '../entities/Account' 2 | import { DataResponse } from '../controllers/responses' 3 | 4 | /** 5 | * @example { 6 | * id: "abc123", 7 | * budgetId: "abc456", 8 | * name: "My Budget", 9 | * type: 0, 10 | * created: "2011-10-05T14:48:00.000Z", 11 | * updated: "2011-10-05T14:48:00.000Z", 12 | * } 13 | */ 14 | export interface AccountModel { 15 | /** 16 | * Unique id 17 | */ 18 | id: string 19 | 20 | /** 21 | * Parent budget ID 22 | */ 23 | budgetId: string 24 | 25 | /** 26 | * ID of payee for account transfers 27 | */ 28 | transferPayeeId: string 29 | 30 | /** 31 | * Budget name 32 | */ 33 | name: string 34 | 35 | /** 36 | * Account type 37 | */ 38 | type: AccountTypes 39 | 40 | /** 41 | * Account balance (cleared + uncleared) 42 | */ 43 | balance: number 44 | 45 | /** 46 | * Cleared account balance 47 | */ 48 | cleared: number 49 | 50 | /** 51 | * Pending account balance 52 | */ 53 | uncleared: number 54 | 55 | /** 56 | * Order position of accounts 57 | */ 58 | order: number 59 | 60 | /** 61 | * Datetime user was created 62 | */ 63 | created: Date 64 | 65 | /** 66 | * Datetime user was updated 67 | */ 68 | updated: Date 69 | } 70 | 71 | /** 72 | * @example { 73 | * "name": "My Budget", 74 | * "type": 0, 75 | * } 76 | */ 77 | export interface AccountRequest { 78 | name: string 79 | type: AccountTypes 80 | } 81 | 82 | /** 83 | * @example { 84 | * "name": "My Budget", 85 | * "type": 0, 86 | * "balance": 100, 87 | * } 88 | */ 89 | export interface CreateAccountRequest { 90 | name: string 91 | type: AccountTypes 92 | balance: number 93 | date: string 94 | } 95 | 96 | /** 97 | * @example { 98 | * "name": "My Budget", 99 | * "balance": 100, 100 | * } 101 | */ 102 | export interface EditAccountRequest { 103 | name?: string 104 | order?: number 105 | balance?: number 106 | } 107 | 108 | export type AccountResponse = DataResponse 109 | 110 | export type AccountsResponse = DataResponse 111 | -------------------------------------------------------------------------------- /backend/src/models/Budget.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | import { AccountModel } from './Account' 3 | 4 | /** 5 | * @example { 6 | * id: "abc123", 7 | * name: "My Budget", 8 | * created: "2011-10-05T14:48:00.000Z", 9 | * updated: "2011-10-05T14:48:00.000Z", 10 | * } 11 | */ 12 | export interface BudgetModel { 13 | /** 14 | * Unique id 15 | */ 16 | id: string 17 | 18 | /** 19 | * Budget name 20 | */ 21 | name: string 22 | 23 | /** 24 | * Currency setting of the budget 25 | */ 26 | currency: string 27 | 28 | /** 29 | * Budget's accounts 30 | */ 31 | accounts: AccountModel[] 32 | 33 | /** 34 | * Datetime user was created 35 | */ 36 | created: Date 37 | 38 | /** 39 | * Datetime user was updated 40 | */ 41 | updated: Date 42 | } 43 | 44 | /** 45 | * @example { 46 | * "name": "My Budget", 47 | * "currency": "USD", 48 | * } 49 | */ 50 | export interface BudgetRequest { 51 | name: string 52 | currency?: string 53 | } 54 | 55 | export type BudgetResponse = DataResponse 56 | 57 | export type BudgetsResponse = DataResponse 58 | -------------------------------------------------------------------------------- /backend/src/models/BudgetMonth.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | import { CategoryMonthModel } from './CategoryMonth' 3 | 4 | /** 5 | * @example { 6 | * id: "abc123", 7 | * budgetId: "def456", 8 | * month: "2021-10-01", 9 | * income: 0, 10 | * budgeted: 0, 11 | * activity: 0, 12 | * toBeBudgetd: 0, 13 | * created: "2011-10-05T14:48:00.000Z", 14 | * updated: "2011-10-05T14:48:00.000Z", 15 | * } 16 | */ 17 | export interface BudgetMonthModel { 18 | /** 19 | * Unique ID 20 | */ 21 | id: string 22 | 23 | /** 24 | * Budget ID 25 | */ 26 | budgetId: string 27 | 28 | /** 29 | * Date string 30 | */ 31 | month: string 32 | 33 | /** 34 | * Month income 35 | */ 36 | income: number 37 | 38 | /** 39 | * Amount budgeted 40 | */ 41 | budgeted: number 42 | 43 | /** 44 | * Activity amount 45 | */ 46 | activity: number 47 | 48 | /** 49 | * Amount available to budget 50 | */ 51 | available: number 52 | 53 | /** 54 | * Deficit amount for the month 55 | */ 56 | underfunded: number 57 | 58 | /** 59 | * Date created 60 | */ 61 | created: Date 62 | 63 | /** 64 | * Date updated 65 | */ 66 | updated: Date 67 | } 68 | 69 | export interface BudgetMonthWithCategoriesModel extends BudgetMonthModel { 70 | /** 71 | * All categories for this month 72 | */ 73 | categories: CategoryMonthModel[] 74 | } 75 | 76 | export type BudgetMonthResponse = DataResponse 77 | 78 | export type BudgetMonthsResponse = DataResponse 79 | 80 | export type BudgetMonthWithCategoriesResponse = DataResponse 81 | 82 | export type BudgetMonthsWithCategoriesResponse = DataResponse 83 | -------------------------------------------------------------------------------- /backend/src/models/Category.ts: -------------------------------------------------------------------------------- 1 | import { AccountTypes } from '../entities/Account' 2 | import { DataResponse } from '../controllers/responses' 3 | import { TransactionStatus } from '../entities/Transaction' 4 | 5 | /** 6 | * @example { 7 | * id: "abc123", 8 | * categoryGroupId: "def456", 9 | * name: "My Budget", 10 | * created: "2011-10-05T14:48:00.000Z", 11 | * updated: "2011-10-05T14:48:00.000Z", 12 | * } 13 | */ 14 | export interface CategoryModel { 15 | /** 16 | * Unique id 17 | */ 18 | id: string 19 | 20 | /** 21 | * Group ID 22 | */ 23 | categoryGroupId: string 24 | 25 | /** 26 | * ID of tracking account (for CCs) 27 | */ 28 | trackingAccountId: string 29 | 30 | /** 31 | * Name 32 | */ 33 | name: string 34 | 35 | /** 36 | * Inflow category flag 37 | */ 38 | inflow: boolean 39 | 40 | /** 41 | * Locked flag 42 | */ 43 | locked: boolean 44 | 45 | /** 46 | * Category ordering 47 | */ 48 | order: number 49 | 50 | /** 51 | * Datetime transaction was created 52 | */ 53 | created: Date 54 | 55 | /** 56 | * Datetime transaction was updated 57 | */ 58 | updated: Date 59 | } 60 | 61 | /** 62 | * @example { 63 | * "categoryGroupId": "abc123", 64 | * "name": "Emergency Fund", 65 | * } 66 | */ 67 | export interface CategoryRequest { 68 | categoryGroupId: string 69 | name: string 70 | order: number 71 | } 72 | 73 | export type CategoryResponse = DataResponse 74 | -------------------------------------------------------------------------------- /backend/src/models/CategoryGroup.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | import { CategoryModel } from './Category' 3 | 4 | /** 5 | * @example { 6 | * id: "abc123", 7 | * budgetId: "def456", 8 | * name: "Expenses", 9 | * categories: [], 10 | * created: "2011-10-05T14:48:00.000Z", 11 | * updated: "2011-10-05T14:48:00.000Z", 12 | * } 13 | */ 14 | export interface CategoryGroupModel { 15 | /** 16 | * Unique id 17 | */ 18 | id: string 19 | 20 | /** 21 | * Budget ID 22 | */ 23 | budgetId: string 24 | 25 | /** 26 | * Name 27 | */ 28 | name: string 29 | 30 | /** 31 | * Flag for internal use only 32 | */ 33 | internal: boolean 34 | 35 | /** 36 | * Flag for locked accounts - prevents renaming and other actions 37 | */ 38 | locked: boolean 39 | 40 | /** 41 | * Category group ordering 42 | */ 43 | order: number 44 | 45 | /** 46 | * Child categories 47 | */ 48 | categories: CategoryModel[] 49 | 50 | /** 51 | * Datetime transaction was created 52 | */ 53 | created: Date 54 | 55 | /** 56 | * Datetime transaction was updated 57 | */ 58 | updated: Date 59 | } 60 | 61 | /** 62 | * @example { 63 | * "categoryGroupId": "abc123", 64 | * "name": "Emergency Fund", 65 | * "order": 0, 66 | * } 67 | */ 68 | export interface CategoryGroupRequest { 69 | name: string 70 | order: number 71 | } 72 | 73 | export type CategoryGroupResponse = DataResponse 74 | 75 | export type CategoryGroupsResponse = DataResponse 76 | -------------------------------------------------------------------------------- /backend/src/models/CategoryMonth.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | 3 | /** 4 | * @example { 5 | * id: "abc123", 6 | * categoryGroupId: "def456", 7 | * name: "My Budget", 8 | * created: "2011-10-05T14:48:00.000Z", 9 | * updated: "2011-10-05T14:48:00.000Z", 10 | * } 11 | */ 12 | export interface CategoryMonthModel { 13 | /** 14 | * Unique ID 15 | */ 16 | id: string 17 | 18 | /** 19 | * Category ID 20 | */ 21 | categoryId: string 22 | 23 | /** 24 | * Date string 25 | */ 26 | month: string 27 | 28 | /** 29 | * Amount budgeted 30 | */ 31 | budgeted: number 32 | 33 | /** 34 | * Activity amount 35 | */ 36 | activity: number 37 | 38 | /** 39 | * Month balance 40 | */ 41 | balance: number 42 | 43 | /** 44 | * Date created 45 | */ 46 | created: Date 47 | 48 | /** 49 | * Date updated 50 | */ 51 | updated: Date 52 | } 53 | 54 | /** 55 | * @example { 56 | * "budgeted": 0, 57 | * } 58 | */ 59 | export interface CategoryMonthRequest { 60 | budgeted: number 61 | } 62 | 63 | export type CategoryMonthResponse = DataResponse 64 | 65 | export type CategoryMonthsResponse = DataResponse 66 | -------------------------------------------------------------------------------- /backend/src/models/Payee.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | 3 | /** 4 | * @example { 5 | * id: "abc123", 6 | * budgetId: "abc456", 7 | * name: "Random Store Name", 8 | * created: "2011-10-05T14:48:00.000Z", 9 | * updated: "2011-10-05T14:48:00.000Z", 10 | * } 11 | */ 12 | export interface PayeeModel { 13 | /** 14 | * Unique id 15 | */ 16 | id: string 17 | 18 | /** 19 | * Budget name 20 | */ 21 | name: string 22 | 23 | /** 24 | * Account associated with the payee for transfers 25 | */ 26 | transferAccountId: string | null 27 | 28 | /** 29 | * Flag that payee is for internal use only 30 | */ 31 | internal: boolean 32 | 33 | /** 34 | * Datetime user was created 35 | */ 36 | created: Date 37 | 38 | /** 39 | * Datetime user was updated 40 | */ 41 | updated: Date 42 | } 43 | 44 | /** 45 | * @example { 46 | * "name": "Random Store Name", 47 | * } 48 | */ 49 | export interface PayeeRequest { 50 | name: string 51 | } 52 | 53 | export type PayeeResponse = DataResponse 54 | 55 | export type PayeesResponse = DataResponse 56 | -------------------------------------------------------------------------------- /backend/src/models/Transaction.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | import { TransactionStatus } from '../entities/Transaction' 3 | 4 | /** 5 | * @example { 6 | * id: "abc123", 7 | * accountId: "def456", 8 | * name: "My Budget", 9 | * type: 0, 10 | * created: "2011-10-05T14:48:00.000Z", 11 | * updated: "2011-10-05T14:48:00.000Z", 12 | * } 13 | */ 14 | export interface TransactionModel { 15 | /** 16 | * Unique id 17 | */ 18 | id: string 19 | 20 | /** 21 | * Parent account ID 22 | */ 23 | accountId: string 24 | 25 | /** 26 | * Payee account ID 27 | */ 28 | payeeId: string 29 | 30 | /** 31 | * Category ID 32 | */ 33 | categoryId: string | null 34 | 35 | /** 36 | * Transaction amount 37 | */ 38 | amount: number 39 | 40 | /** 41 | * Date of the transaction 42 | */ 43 | date: Date 44 | 45 | /** 46 | * Transaction notes / memo 47 | */ 48 | memo: string 49 | 50 | /** 51 | * Transaction status 52 | */ 53 | status: TransactionStatus 54 | 55 | /** 56 | * Datetime transaction was created 57 | */ 58 | created: Date 59 | 60 | /** 61 | * Datetime transaction was updated 62 | */ 63 | updated: Date 64 | } 65 | 66 | /** 67 | * @example { 68 | * "name": "My Budget", 69 | * } 70 | */ 71 | export interface TransactionRequest { 72 | id?: string 73 | accountId: string 74 | payeeId: string 75 | amount: number 76 | date: string 77 | memo?: string 78 | categoryId?: string | null 79 | status: TransactionStatus 80 | } 81 | 82 | /** 83 | * @example { 84 | * "transactions": [ 85 | * { 86 | * "name": "My Budget", 87 | * } 88 | * ], 89 | * } 90 | */ 91 | export interface TransactionsRequest { 92 | transactions: TransactionRequest[] 93 | } 94 | 95 | /** 96 | * @example { 97 | * ids: ["abc123"] 98 | * } 99 | */ 100 | export interface TransactionsDeleteRequest { 101 | ids: string[] 102 | } 103 | 104 | export type TransactionResponse = DataResponse 105 | 106 | export type TransactionsResponse = DataResponse 107 | -------------------------------------------------------------------------------- /backend/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { DataResponse } from '../controllers/responses' 2 | 3 | /** 4 | * @example { 5 | * id: "c3n8327rp8arhj8", 6 | * email: "alex@example.com", 7 | * name: "John Doe", 8 | * role: 1, 9 | * } 10 | */ 11 | export interface UserModel { 12 | /** 13 | * Unique id 14 | */ 15 | id: string 16 | 17 | /** 18 | * User's email 19 | */ 20 | email: string 21 | 22 | /** 23 | * Datetime user was created 24 | */ 25 | created: Date 26 | 27 | /** 28 | * Datetime user was updated 29 | */ 30 | updated: Date 31 | } 32 | 33 | export type UserResponse = DataResponse 34 | 35 | export interface LoginResponse extends UserResponse { 36 | /** 37 | * JSON Web Token for authenticating requests 38 | */ 39 | token: string 40 | } 41 | 42 | export interface LogoutResponse extends UserResponse {} 43 | -------------------------------------------------------------------------------- /backend/src/repositories/BudgetMonths.ts: -------------------------------------------------------------------------------- 1 | import { Budget } from '../entities/Budget' 2 | import { EntityRepository, Repository } from 'typeorm' 3 | import { BudgetMonth } from '../entities/BudgetMonth' 4 | import { formatMonthFromDateString } from '../utils' 5 | 6 | @EntityRepository(BudgetMonth) 7 | export class BudgetMonths extends Repository { 8 | async findOrCreate(budgetId: string, month: string): Promise { 9 | let budgetMonth: BudgetMonth = await this.findOne({ budgetId, month }) 10 | if (!budgetMonth) { 11 | const budget = await this.manager.getRepository(Budget).findOne(budgetId) 12 | const months = await budget.getMonths() 13 | 14 | let newBudgetMonth 15 | let direction = 1 16 | let monthFrom = new Date() 17 | monthFrom.setDate(1) 18 | 19 | if (month < months[0]) { 20 | monthFrom = new Date(`${months[0]}T12:00:00`) 21 | direction = -1 22 | } else if (month > months[months.length - 1]) { 23 | monthFrom = new Date(`${months[months.length - 1]}T12:00:00`) 24 | } 25 | 26 | // iterate over all months until we hit the first budget month 27 | do { 28 | monthFrom.setMonth(monthFrom.getMonth() + direction) 29 | newBudgetMonth = this.create({ 30 | budgetId, 31 | month: formatMonthFromDateString(monthFrom), 32 | }) 33 | await this.insert(newBudgetMonth) 34 | } while (newBudgetMonth.month !== month) 35 | 36 | return newBudgetMonth 37 | } 38 | 39 | return budgetMonth 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/repositories/CategoryMonths.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, EntityTarget, ObjectType, Repository, UpdateResult } from 'typeorm' 2 | import { CategoryMonth } from '../entities/CategoryMonth' 3 | import { BudgetMonths } from './BudgetMonths' 4 | 5 | @EntityRepository(CategoryMonth) 6 | export class CategoryMonths extends Repository { 7 | async createNew(budgetId: string, categoryId: string, month: string): Promise { 8 | const budgetMonth = await this.manager.getCustomRepository(BudgetMonths).findOrCreate(budgetId, month) 9 | const categoryMonth = this.create({ 10 | budgetMonthId: budgetMonth.id, 11 | categoryId, 12 | month: month, 13 | // @TODO: I DON'T KNOW WHY I HAVE TO SPECIFY 0s HERE AND NOT ABOVE WHEN CREATING BUDGET MONTH!!! AHHH!!! 14 | activity: 0, 15 | balance: 0, 16 | budgeted: 0, 17 | }) 18 | categoryMonth.budgetMonth = Promise.resolve(budgetMonth) 19 | 20 | return categoryMonth 21 | } 22 | 23 | async findOrCreate(budgetId: string, categoryId: string, month: string): Promise { 24 | let categoryMonth: CategoryMonth = await this.findOne({ categoryId, month: month }, { relations: ['budgetMonth'] }) 25 | 26 | if (!categoryMonth) { 27 | categoryMonth = await this.createNew(budgetId, categoryId, month) 28 | await this.insert(categoryMonth) 29 | } 30 | 31 | return categoryMonth 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import { app } from './app' 2 | import { AddressInfo } from 'net' 3 | import config from './config' 4 | import { createConnection } from 'typeorm' 5 | import dbConfig from '../ormconfig' 6 | ;(async () => { 7 | if (config.env !== 'production') { 8 | console.log('!!!WARNING!!! Running in development mode!') 9 | // await sleep(5000) 10 | } 11 | 12 | process.on('unhandledRejection', error => { 13 | // Won't execute 14 | console.log('unhandledRejection', error); 15 | }); 16 | 17 | await createConnection(dbConfig) 18 | 19 | const server = app.listen(config.port, '0.0.0.0', () => { 20 | const { port, address } = server.address() as AddressInfo 21 | console.log(`Server listening on: http://${address}:${port}`) 22 | }) 23 | })() 24 | -------------------------------------------------------------------------------- /backend/src/subscribers/AccountSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm' 2 | import { CategoryGroup, CreditCardGroupName } from '../entities/CategoryGroup' 3 | import { Category } from '../entities/Category' 4 | import { Payee } from '../entities/Payee' 5 | import { Account, AccountTypes } from '../entities/Account' 6 | 7 | @EventSubscriber() 8 | export class AccountSubscriber implements EntitySubscriberInterface { 9 | listenTo() { 10 | return Account 11 | } 12 | 13 | async afterInsert(event: InsertEvent) { 14 | await Promise.all([this.createCreditCardCategory(event), this.createAccountPayee(event)]) 15 | } 16 | 17 | private async createAccountPayee(event: InsertEvent) { 18 | const account = event.entity 19 | const manager = event.manager 20 | 21 | const payee = manager.create(Payee, { 22 | budgetId: account.budgetId, 23 | name: `Transfer : ${account.name}`, 24 | transferAccountId: account.id, 25 | }) 26 | 27 | // @TODO: I wish there was a better way around this 28 | await manager.insert(Payee, payee) 29 | account.transferPayeeId = payee.id 30 | await manager.update(Account, account.id, account.getUpdatePayload()) 31 | } 32 | 33 | private async createCreditCardCategory(event: InsertEvent) { 34 | const account = event.entity 35 | const manager = event.manager 36 | 37 | if (account.type === AccountTypes.CreditCard) { 38 | // Create CC payments category if it doesn't exist 39 | const ccGroup = 40 | (await manager.findOne(CategoryGroup, { 41 | budgetId: account.budgetId, 42 | name: CreditCardGroupName, 43 | })) || 44 | manager.create(CategoryGroup, { 45 | budgetId: account.budgetId, 46 | name: CreditCardGroupName, 47 | locked: true, 48 | }) 49 | 50 | await manager.save(CategoryGroup, ccGroup) 51 | 52 | // Create payment tracking category 53 | const paymentCategory = manager.create(Category, { 54 | budgetId: account.budgetId, 55 | categoryGroupId: ccGroup.id, 56 | trackingAccountId: account.id, 57 | name: account.name, 58 | locked: true, 59 | }) 60 | await manager.insert(Category, paymentCategory) 61 | } 62 | } 63 | 64 | async beforeUpdate(event: UpdateEvent) { 65 | const account = event.entity 66 | 67 | account.balance = account.cleared + account.uncleared 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /backend/src/subscribers/BudgetMonthSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm' 2 | import { formatMonthFromDateString, getDateFromString } from '../utils' 3 | import { BudgetMonth, BudgetMonthCache } from '../entities/BudgetMonth' 4 | import { Category } from '../entities/Category' 5 | import { CategoryMonth } from '../entities/CategoryMonth' 6 | 7 | @EventSubscriber() 8 | export class BudgetMonthSubscriber implements EntitySubscriberInterface { 9 | listenTo() { 10 | return BudgetMonth 11 | } 12 | 13 | async beforeInsert(event: InsertEvent) { 14 | const budgetMonth = event.entity 15 | const manager = event.manager 16 | const prevMonth = getDateFromString(budgetMonth.month).minus({ month: 1 }) 17 | 18 | const prevBudgetMonth = await manager.findOne(BudgetMonth, { 19 | budgetId: budgetMonth.budgetId, 20 | month: formatMonthFromDateString(prevMonth.toJSDate()), 21 | }) 22 | 23 | if (!prevBudgetMonth) { 24 | return 25 | } 26 | 27 | budgetMonth.available = prevBudgetMonth.available 28 | } 29 | 30 | async afterUpdate(event: UpdateEvent) { 31 | const budgetMonth = event.entity 32 | const manager = event.manager 33 | 34 | const nextMonth = getDateFromString(budgetMonth.month).plus({ month: 1 }) 35 | const nextBudgetMonth = await manager.findOne(BudgetMonth, { 36 | budgetId: budgetMonth.budgetId, 37 | month: formatMonthFromDateString(nextMonth.toJSDate()), 38 | }) 39 | 40 | if (!nextBudgetMonth) { 41 | return 42 | } 43 | 44 | const originalBudgetMonth = BudgetMonthCache.get(budgetMonth.id) 45 | 46 | if ( 47 | budgetMonth.income === originalBudgetMonth.income && 48 | budgetMonth.budgeted === originalBudgetMonth.budgeted && 49 | budgetMonth.underfunded === originalBudgetMonth.underfunded && 50 | budgetMonth.available === originalBudgetMonth.available 51 | ) { 52 | return 53 | } 54 | 55 | // The carryover for next month needs to include the available cash 56 | // but also account for underfunded categories. 57 | const availableDiff = 58 | originalBudgetMonth.available - 59 | budgetMonth.available - 60 | (originalBudgetMonth.underfunded - budgetMonth.underfunded) 61 | 62 | nextBudgetMonth.available -= availableDiff 63 | 64 | await manager.getRepository(BudgetMonth).update(nextBudgetMonth.id, nextBudgetMonth.getUpdatePayload()) 65 | } 66 | 67 | async afterInsert(event: InsertEvent) { 68 | const budgetMonth = event.entity 69 | const manager = event.manager 70 | const prevMonth = getDateFromString(budgetMonth.month).minus({ month: 1 }) 71 | 72 | const prevBudgetMonth = await manager.findOne(BudgetMonth, { 73 | budgetId: budgetMonth.budgetId, 74 | month: formatMonthFromDateString(prevMonth.toJSDate()), 75 | }) 76 | 77 | if (!prevBudgetMonth) { 78 | return 79 | } 80 | 81 | // Find all categories with previous balances to update the new month with 82 | const previousCategoryMonths = await manager.getRepository(CategoryMonth).find({ 83 | budgetMonthId: prevBudgetMonth.id, 84 | }) 85 | 86 | // Create a category month for each category in this new budget month 87 | for (const previousCategoryMonth of previousCategoryMonths) { 88 | let prevBalance = 0 89 | if (previousCategoryMonth.balance > 0) { 90 | prevBalance = previousCategoryMonth.balance 91 | } else { 92 | const category = await manager.findOne(Category, { 93 | id: previousCategoryMonth.categoryId, 94 | }) 95 | 96 | if (previousCategoryMonth.balance < 0 && category.trackingAccountId) { 97 | prevBalance = previousCategoryMonth.balance 98 | } 99 | } 100 | 101 | await manager.insert(CategoryMonth, { 102 | budgetMonthId: budgetMonth.id, 103 | categoryId: previousCategoryMonth.categoryId, 104 | month: budgetMonth.month, 105 | balance: prevBalance, 106 | activity: 0, 107 | budgeted: 0, 108 | }) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /backend/src/subscribers/BudgetSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Budget } from '../entities/Budget' 2 | import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm' 3 | import { getMonthString, getMonthStringFromNow } from '../utils' 4 | import { BudgetMonth } from '../entities/BudgetMonth' 5 | import { CategoryGroup } from '../entities/CategoryGroup' 6 | import { Category } from '../entities/Category' 7 | import { Payee } from '../entities/Payee' 8 | 9 | @EventSubscriber() 10 | export class BudgetSubscriber implements EntitySubscriberInterface { 11 | listenTo() { 12 | return Budget 13 | } 14 | 15 | async afterInsert(event: InsertEvent) { 16 | const manager = event.manager 17 | const budget = event.entity 18 | 19 | const today = getMonthString() 20 | const prevMonth = getMonthStringFromNow(-1) 21 | const nextMonth = getMonthStringFromNow(1) 22 | 23 | // Create initial budget months 24 | for (const month of [prevMonth, today, nextMonth]) { 25 | console.log(`creating budget month ${month}`) 26 | const newBudgetMonth = manager.create(BudgetMonth, { budgetId: budget.id, month }) 27 | await manager.insert(BudgetMonth, newBudgetMonth) 28 | } 29 | 30 | // Create internal categories 31 | const internalCategoryGroup = manager.create(CategoryGroup, { 32 | budgetId: budget.id, 33 | name: 'Internal Category', 34 | internal: true, 35 | locked: true, 36 | }) 37 | await manager.insert(CategoryGroup, internalCategoryGroup) 38 | 39 | await Promise.all( 40 | ['To be Budgeted'].map(name => { 41 | const internalCategory = manager.create(Category, { 42 | budgetId: budget.id, 43 | name: name, 44 | categoryGroupId: internalCategoryGroup.id, 45 | inflow: true, 46 | locked: true, 47 | }) 48 | return manager.insert(Category, internalCategory) 49 | }), 50 | ) 51 | 52 | // Create special 'Starting Balance' payee 53 | const startingBalancePayee = manager.create(Payee, { 54 | budgetId: budget.id, 55 | name: 'Starting Balance', 56 | internal: true, 57 | }) 58 | await manager.insert(Payee, startingBalancePayee) 59 | 60 | const reconciliationPayee = manager.create(Payee, { 61 | budgetId: budget.id, 62 | name: 'Reconciliation Balance Adjustment', 63 | internal: true, 64 | }) 65 | await manager.insert(Payee, reconciliationPayee) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /backend/src/subscribers/CategoryMonthSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Budget } from '../entities/Budget' 2 | import { EntityManager, EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm' 3 | import { formatMonthFromDateString, getDateFromString } from '../utils' 4 | import { BudgetMonth } from '../entities/BudgetMonth' 5 | import { Category } from '../entities/Category' 6 | import { CategoryMonth, CategoryMonthCache } from '../entities/CategoryMonth' 7 | import { CategoryMonths } from '../repositories/CategoryMonths' 8 | 9 | @EventSubscriber() 10 | export class CategoryMonthSubscriber implements EntitySubscriberInterface { 11 | listenTo() { 12 | return CategoryMonth 13 | } 14 | 15 | /** 16 | * Get the previous month's 'balance' as this will be the 'carry over' amount for this new month 17 | */ 18 | async beforeInsert(event: InsertEvent) { 19 | const categoryMonth = event.entity 20 | const manager = event.manager 21 | 22 | const prevMonth = getDateFromString(categoryMonth.month).minus({ month: 1 }) 23 | const prevCategoryMonth = await manager.findOne(CategoryMonth, { 24 | categoryId: categoryMonth.categoryId, 25 | month: formatMonthFromDateString(prevMonth.toJSDate()), 26 | }) 27 | 28 | const category = await event.manager.findOne(Category, { 29 | id: event.entity.categoryId, 30 | }) 31 | 32 | if (prevCategoryMonth && (category.trackingAccountId || prevCategoryMonth.balance > 0)) { 33 | categoryMonth.balance = prevCategoryMonth.balance + categoryMonth.budgeted + categoryMonth.activity 34 | } 35 | } 36 | 37 | async afterInsert(event: InsertEvent) { 38 | if (event.entity.balance === 0) { 39 | return 40 | } 41 | 42 | await this.bookkeeping(event.entity as CategoryMonth, event.manager) 43 | } 44 | 45 | async afterUpdate(event: UpdateEvent) { 46 | await this.bookkeeping(event.entity as CategoryMonth, event.manager) 47 | } 48 | 49 | /** 50 | * == RECURSIVE == 51 | * 52 | * Cascade the new assigned and activity amounts up into the parent budget month for new totals. 53 | * Also, cascade the new balance of this month into the next month to update the carry-over amount. 54 | */ 55 | private async bookkeeping(categoryMonth: CategoryMonth, manager: EntityManager) { 56 | const category = await manager.findOne(Category, categoryMonth.categoryId) 57 | const originalCategoryMonth = CategoryMonthCache.get(categoryMonth.id) 58 | 59 | // Update budget month activity and and budgeted 60 | const budgetMonth = await manager.findOne(BudgetMonth, categoryMonth.budgetMonthId) 61 | 62 | budgetMonth.budgeted = budgetMonth.budgeted + (categoryMonth.budgeted - originalCategoryMonth.budgeted) 63 | 64 | if (category.inflow === false && category.trackingAccountId === null) { 65 | // Don't update budget month activity for CC transactions. These are 'inverse' transactions of other 66 | // category transactions, so this would 'negate' them. 67 | budgetMonth.activity = budgetMonth.activity + (categoryMonth.activity - originalCategoryMonth.activity) 68 | } 69 | 70 | const budgetedDifference = originalCategoryMonth.budgeted - categoryMonth.budgeted 71 | const activityDifference = categoryMonth.activity - originalCategoryMonth.activity 72 | if (budgetedDifference !== 0 || activityDifference !== 0) { 73 | const budget = await manager.findOne(Budget, budgetMonth.budgetId) 74 | budgetMonth.available += budgetedDifference 75 | 76 | if (category.inflow) { 77 | budgetMonth.available += activityDifference 78 | } 79 | 80 | await manager.update(Budget, budget.id, budget.getUpdatePayload()) 81 | } 82 | 83 | if (category.inflow) { 84 | budgetMonth.income = budgetMonth.income + (categoryMonth.activity - originalCategoryMonth.activity) 85 | } 86 | 87 | // Underfunded only counts for non-CC accounts as a negative CC value could mean cash bach for that month 88 | if (!category.trackingAccountId) { 89 | if (originalCategoryMonth.balance < 0) { 90 | budgetMonth.underfunded = budgetMonth.underfunded + originalCategoryMonth.balance 91 | } 92 | if (categoryMonth.balance < 0) { 93 | budgetMonth.underfunded = budgetMonth.underfunded - categoryMonth.balance 94 | } 95 | } 96 | 97 | await manager.update(BudgetMonth, budgetMonth.id, budgetMonth.getUpdatePayload()) 98 | 99 | const nextMonth = getDateFromString(categoryMonth.month).plus({ month: 1 }) 100 | 101 | const nextBudgetMonth = await manager.findOne(BudgetMonth, { 102 | budgetId: category.budgetId, 103 | month: formatMonthFromDateString(nextMonth.toJSDate()), 104 | }) 105 | 106 | if (!nextBudgetMonth) { 107 | return 108 | } 109 | 110 | const nextCategoryMonth = await manager 111 | .getCustomRepository(CategoryMonths) 112 | .findOrCreate(nextBudgetMonth.budgetId, categoryMonth.categoryId, nextBudgetMonth.month) 113 | 114 | if (categoryMonth.balance > 0 || category.trackingAccountId) { 115 | nextCategoryMonth.balance = categoryMonth.balance + nextCategoryMonth.budgeted + nextCategoryMonth.activity 116 | } else { 117 | // If the next month's balance already matched it's activity, no need to keep cascading 118 | const calculatedNextMonth = nextCategoryMonth.budgeted + nextCategoryMonth.activity 119 | if (nextCategoryMonth.balance === calculatedNextMonth) { 120 | return 121 | } 122 | 123 | nextCategoryMonth.balance = calculatedNextMonth 124 | } 125 | 126 | // await CategoryMonth.update(nextCategoryMonth.id, { balance: nextCategoryMonth.balance }) 127 | await manager.update(CategoryMonth, nextCategoryMonth.id, nextCategoryMonth.getUpdatePayload()) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /backend/src/subscribers/CategorySubscriber.ts: -------------------------------------------------------------------------------- 1 | import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm' 2 | import { BudgetMonth } from '../entities/BudgetMonth' 3 | import { Category } from '../entities/Category' 4 | import { CategoryMonth, CategoryMonthCache } from '../entities/CategoryMonth' 5 | 6 | @EventSubscriber() 7 | export class CategorySubscriber implements EntitySubscriberInterface { 8 | listenTo() { 9 | return Category 10 | } 11 | 12 | async afterInsert(event: InsertEvent) { 13 | const category = event.entity 14 | const manager = event.manager 15 | 16 | // Create a category month for all existing months 17 | const budgetMonths = await manager.find(BudgetMonth, { budgetId: category.budgetId }) 18 | 19 | const categoryMonths = budgetMonths.map(budgetMonth => 20 | manager.create(CategoryMonth, { 21 | categoryId: category.id, 22 | budgetMonthId: budgetMonth.id, 23 | month: budgetMonth.month, 24 | }), 25 | ) 26 | 27 | await manager.insert(CategoryMonth, categoryMonths) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | 3 | export async function sleep(duration: number): Promise { 4 | return new Promise(resolve => { 5 | setTimeout(resolve, duration) 6 | }) 7 | } 8 | 9 | export function getDateFromString(date: string): DateTime { 10 | return DateTime.fromISO(date) 11 | } 12 | 13 | export function formatMonthFromDateString(date: Date | string): string { 14 | if (typeof date === 'string') { 15 | date = new Date(date) 16 | } 17 | 18 | let tempDate = DateTime.fromISO(date.toISOString()) 19 | tempDate = tempDate.set({ day: 1 }) 20 | return tempDate.toISO().split('T')[0] 21 | } 22 | 23 | export function getMonthDate(): DateTime { 24 | const today = DateTime.now() 25 | return today.set({ day: 1 }) 26 | } 27 | 28 | export function getMonthString(): string { 29 | return getMonthDate().toISO().split('T')[0] 30 | } 31 | 32 | export function getMonthStringFromNow(monthsAway: number): string { 33 | let today: DateTime = DateTime.now() 34 | today = today.set({ day: 1 }).plus({ month: monthsAway }) 35 | 36 | return today.toISO().split('T')[0] 37 | } 38 | -------------------------------------------------------------------------------- /backend/test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/backend/test.db -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": true, 4 | "experimentalDecorators": true, 5 | "resolveJsonModule": true, 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "target": "es6", 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "outDir": "build", 13 | "baseUrl": ".", 14 | "paths": { 15 | "*": [ 16 | "node_modules/*" 17 | ] 18 | }, 19 | "skipLibCheck": true 20 | }, 21 | "include": [ 22 | "src/**/*.ts", 23 | "jest.config.js", 24 | "test", 25 | "babel.config.js", 26 | "ormconfig.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /backend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "trailing-comma": [ false ], 9 | "no-console": false, 10 | "variable-name": false, 11 | "no-empty-interface": false 12 | }, 13 | "rulesDirectory": [] 14 | } 15 | -------------------------------------------------------------------------------- /backend/tsoa.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryFile": "src/server.ts", 3 | "noImplicitAdditionalProperties": "throw-on-extras", 4 | "controllerPathGlobs": ["src/controllers/*.ts"], 5 | "spec": { 6 | "outputDirectory": ".", 7 | "specVersion": 3 8 | }, 9 | "routes": { 10 | "routesDir": ".", 11 | "authenticationModule": "src/middleware/authentication.ts" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dev-proxy: 4 | container_name: dev-proxy 5 | image: ghcr.io/linuxserver/nginx 6 | # network_mode: host 7 | ports: 8 | - 8090:80 9 | - 3000:3000 10 | - 5000:5000 11 | volumes: 12 | - ./docker:/config 13 | - ./:/app 14 | environment: 15 | PGID: 1000 16 | PUID: 1000 17 | TZ: America/New_York 18 | -------------------------------------------------------------------------------- /docker/.gitignore: -------------------------------------------------------------------------------- 1 | geoip2db/ 2 | keys/ 3 | log/ 4 | php/ 5 | www/ 6 | 7 | -------------------------------------------------------------------------------- /docker/custom-cont-init.d/99-node: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | 3 | curl -fsSL https://deb.nodesource.com/setup_16.x | bash - 4 | apt-get install -y nodejs 5 | 6 | cd /app/backend 7 | npx prisma generate 8 | npx prisma migrate dev --name init 9 | -------------------------------------------------------------------------------- /docker/custom-services.d/backend: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | 3 | cd /app/backend 4 | 5 | exec \ 6 | s6-setuidgid abc /usr/bin/npm run dev 7 | -------------------------------------------------------------------------------- /docker/custom-services.d/frontend: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | 3 | cd /app/frontend 4 | 5 | exec \ 6 | s6-setuidgid abc /usr/bin/npm run start 7 | -------------------------------------------------------------------------------- /docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | ## Version 2021/10/24 - Changelog: https://github.com/linuxserver/docker-baseimage-alpine-nginx/commits/master/root/defaults/nginx.conf 2 | 3 | user abc; 4 | worker_processes 4; 5 | pid /run/nginx.pid; 6 | include /etc/nginx/modules/*.conf; 7 | 8 | events { 9 | worker_connections 768; 10 | # multi_accept on; 11 | } 12 | 13 | http { 14 | 15 | ## 16 | # Basic Settings 17 | ## 18 | 19 | sendfile on; 20 | tcp_nopush on; 21 | tcp_nodelay on; 22 | keepalive_timeout 65; 23 | types_hash_max_size 2048; 24 | server_tokens off; 25 | 26 | # server_names_hash_bucket_size 64; 27 | # server_name_in_redirect off; 28 | 29 | client_max_body_size 0; 30 | 31 | include /etc/nginx/mime.types; 32 | default_type application/octet-stream; 33 | 34 | ## 35 | # Logging Settings 36 | ## 37 | 38 | access_log /config/log/nginx/access.log; 39 | error_log /config/log/nginx/error.log; 40 | 41 | ## 42 | # Gzip Settings 43 | ## 44 | 45 | gzip on; 46 | gzip_disable "msie6"; 47 | 48 | # gzip_vary on; 49 | # gzip_proxied any; 50 | # gzip_comp_level 6; 51 | # gzip_buffers 16 8k; 52 | # gzip_http_version 1.1; 53 | # gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 54 | 55 | ## 56 | # nginx-naxsi config 57 | ## 58 | # Uncomment it if you installed nginx-naxsi 59 | ## 60 | 61 | #include /etc/nginx/naxsi_core.rules; 62 | 63 | ## 64 | # nginx-passenger config 65 | ## 66 | # Uncomment it if you installed nginx-passenger 67 | ## 68 | 69 | #passenger_root /usr; 70 | #passenger_ruby /usr/bin/ruby; 71 | 72 | ## 73 | # Virtual Host Configs 74 | ## 75 | include /etc/nginx/http.d/*.conf; 76 | include /config/nginx/site-confs/*; 77 | #Removed lua. Do not remove this comment 78 | 79 | # Helper variable for proxying websockets. 80 | map $http_upgrade $connection_upgrade { 81 | default upgrade; 82 | '' close; 83 | } 84 | } 85 | 86 | daemon off; 87 | -------------------------------------------------------------------------------- /docker/nginx/site-confs/default: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | 4 | listen 443 ssl; 5 | 6 | server_name _; 7 | 8 | ssl_certificate /config/keys/cert.crt; 9 | ssl_certificate_key /config/keys/cert.key; 10 | 11 | client_max_body_size 0; 12 | 13 | location / { 14 | proxy_set_header Connection $connection_upgrade; 15 | proxy_set_header Upgrade $http_upgrade; 16 | proxy_pass http://localhost:3000; 17 | } 18 | 19 | location /api/ { 20 | rewrite /foo/(.*) /$1 break; 21 | proxy_set_header Connection $connection_upgrade; 22 | proxy_set_header Upgrade $http_upgrade; 23 | proxy_pass http://localhost:5000/; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // module.exports = { 2 | // env: { 3 | // browser: true, 4 | // es2021: true, 5 | // }, 6 | // extends: ['eslint:recommended', 'plugin:react/recommended'], 7 | // parserOptions: { 8 | // ecmaFeatures: { 9 | // jsx: true, 10 | // }, 11 | // ecmaVersion: 13, 12 | // sourceType: 'module', 13 | // }, 14 | // plugins: ['react'], 15 | // rules: {}, 16 | // } 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /frontend/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babel: { 3 | loaderOptions: (babelLoaderOptions) => { 4 | const origBabelPresetCRAIndex = babelLoaderOptions.presets.findIndex((preset) => { 5 | return preset[0].includes('babel-preset-react-app'); 6 | }); 7 | 8 | const origBabelPresetCRA = babelLoaderOptions.presets[origBabelPresetCRAIndex]; 9 | 10 | babelLoaderOptions.presets[origBabelPresetCRAIndex] = function overridenPresetCRA(api, opts, env) { 11 | const babelPresetCRAResult = require( 12 | origBabelPresetCRA[0] 13 | )(api, origBabelPresetCRA[1], env); 14 | 15 | babelPresetCRAResult.presets.forEach(preset => { 16 | // detect @babel/preset-react with {development: true, runtime: 'automatic'} 17 | const isReactPreset = ( 18 | preset && preset[1] && 19 | preset[1].runtime === 'automatic' && 20 | preset[1].development === true 21 | ); 22 | if (isReactPreset) { 23 | preset[1].importSource = '@welldone-software/why-did-you-render'; 24 | } 25 | }) 26 | 27 | return babelPresetCRAResult; 28 | }; 29 | 30 | return babelLoaderOptions; 31 | }, 32 | }, 33 | // if you want to track react-redux selectors 34 | webpack: { 35 | alias: { 36 | 'react-redux': process.env.NODE_ENV === 'development' ? 'react-redux/lib' : 'react-redux' 37 | } 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BudgE", 3 | "version": "0.0.8", 4 | "private": true, 5 | "dependencies": { 6 | "@dinero.js/currencies": "^2.0.0-alpha.8", 7 | "@mui/icons-material": "^5.2.0", 8 | "@mui/lab": "^5.0.0-alpha.59", 9 | "@mui/material": "^5.2.1", 10 | "@mui/styles": "^5.2.2", 11 | "@reduxjs/toolkit": "^1.6.2", 12 | "@testing-library/jest-dom": "^5.15.0", 13 | "@testing-library/react": "^11.2.7", 14 | "@testing-library/user-event": "^12.8.3", 15 | "axios": "^0.24.0", 16 | "clsx": "^1.1.1", 17 | "csv-parse": "^5.0.4", 18 | "dinero.js": "^2.0.0-alpha.8", 19 | "filefy": "^0.1.11", 20 | "lodash": "^4.17.21", 21 | "material-ui-popup-state": "^2.0.0", 22 | "math-expression-evaluator": "^1.3.8", 23 | "normalizr": "^3.6.1", 24 | "react": "^17.0.2", 25 | "react-dom": "^17.0.2", 26 | "react-router-dom": "^6.0.2", 27 | "react-scripts": "5.0.0", 28 | "react-table": "^7.7.0", 29 | "react-virtualized-auto-sizer": "^1.0.6", 30 | "react-window": "^1.8.6", 31 | "rsuite-table": "^5.3.2", 32 | "web-vitals": "^1.1.2" 33 | }, 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "react-scripts build", 37 | "test": "react-scripts test", 38 | "eject": "react-scripts eject" 39 | }, 40 | "eslintConfig": { 41 | "extends": [ 42 | "react-app", 43 | "react-app/jest" 44 | ] 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | }, 58 | "devDependencies": { 59 | "@welldone-software/why-did-you-render": "^6.2.3", 60 | "eslint": "^8.9.0", 61 | "eslint-plugin-react": "^7.28.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 28 | 29 | 30 | 31 | 32 | BudgE 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import './App.css' 3 | import Box from '@mui/material/Box' 4 | import CssBaseline from '@mui/material/CssBaseline' 5 | import Drawer from './components/Drawer' 6 | import Budget from './pages/Budget' 7 | import Account from './pages/Account' 8 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' 9 | import Login from './components/Login' 10 | import { useDispatch, useSelector } from 'react-redux' 11 | import AddAccountDialog from './components/AddAccountDialog' 12 | import { ThemeProvider, createTheme } from '@mui/material/styles' 13 | import useInterval from './utils/useInterval' 14 | import API from './api' 15 | 16 | export default function App(props) { 17 | const theme = useSelector(state => state.app.theme) 18 | const loggedIn = useSelector(state => state.users.user.email) 19 | 20 | useInterval(async () => { 21 | if (loggedIn) { 22 | try { 23 | await API.ping() 24 | } catch (err) { 25 | window.location.reload(false) 26 | } 27 | } 28 | }, 1800000) 29 | 30 | const darkTheme = createTheme({ 31 | palette: { 32 | mode: 'dark', 33 | background: { 34 | paper: '#272b30', 35 | drawer: '#272b30', 36 | tableHeader: '#272b30', 37 | tableBody: '#2B3035', 38 | details: '#272b30', 39 | }, 40 | secondary: { 41 | main: '#ffffff', 42 | }, 43 | success: { 44 | main: '#62c462', 45 | }, 46 | error: { 47 | main: '#ee5f5b', 48 | }, 49 | warning: { 50 | main: '#f89406', 51 | }, 52 | }, 53 | components: { 54 | MuiDrawer: { 55 | styleOverrides: { 56 | paper: { 57 | backgroundColor: '#272b30', 58 | color: 'white', 59 | }, 60 | }, 61 | }, 62 | // MuiDivider: { 63 | // styleOverrides: { 64 | // root: { 65 | // borderColor: 'white', 66 | // }, 67 | // }, 68 | // }, 69 | MuiTableCell: { 70 | footer: { 71 | left: 0, 72 | bottom: 0, // <-- KEY 73 | zIndex: 2, 74 | position: 'sticky', 75 | }, 76 | }, 77 | }, 78 | typography: { 79 | fontFamily: 'Nunito', 80 | // fontFamily: 'Varela Round', 81 | // fontFamily: 'IBM Plex Sans Condensed', 82 | // fontFamily: 'Roboto', 83 | }, 84 | }) 85 | 86 | const lightTheme = createTheme({ 87 | palette: { 88 | mode: 'light', 89 | background: { 90 | drawer: '#333333', 91 | header: '#333333', 92 | tableBody: '#ffffff', 93 | tableHeader: '#333333', 94 | details: '#333333', 95 | detailsContent: '#333333', 96 | }, 97 | action: { 98 | // disabledBackground: 'set color of background here', 99 | // disabled: '#616161', 100 | }, 101 | primary: { 102 | main: '#3a3f51', 103 | }, 104 | secondary: { 105 | main: '#ffffff', 106 | }, 107 | success: { 108 | main: '#16a085', 109 | }, 110 | error: { 111 | main: '#e74c3c', 112 | }, 113 | warning: { 114 | main: '#ec912e', 115 | }, 116 | }, 117 | components: { 118 | MuiDrawer: { 119 | styleOverrides: { 120 | paper: { 121 | backgroundColor: '#333333', 122 | color: 'white', 123 | }, 124 | }, 125 | }, 126 | }, 127 | typography: { 128 | fontFamily: 'Nunito', 129 | // fontFamily: 'Varela Round', 130 | // fontFamily: 'IBM Plex Sans Condensed', 131 | // fontFamily: 'Roboto', 132 | }, 133 | }) 134 | 135 | /** 136 | * State block 137 | */ 138 | const [newAccountDialogOpen, setNewAccountDialogOpen] = useState(false) 139 | 140 | /** 141 | * Redux store block 142 | */ 143 | const initComplete = useSelector(state => state.users.initComplete) 144 | 145 | const closeNewAccountDialog = () => { 146 | setNewAccountDialogOpen(false) 147 | } 148 | 149 | return ( 150 |
151 | 152 | {!initComplete && } 153 | {initComplete && ( 154 | 155 | 156 | 157 | {/*
*/} 158 | setNewAccountDialogOpen(true)} /> 159 | 160 | 161 | 162 | 163 | } /> 164 | } /> 165 | 166 | 167 | 168 | 169 | )} 170 | 171 |
172 | ) 173 | } 174 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/AccountAmountCell.js: -------------------------------------------------------------------------------- 1 | import TextField from '@mui/material/TextField' 2 | import { useTheme } from '@mui/styles' 3 | 4 | export default function BudgetTableAssignedCell({ value, onChange, ...props }) { 5 | const theme = useTheme() 6 | 7 | const onFocus = async e => { 8 | e.target.select() 9 | } 10 | 11 | const change = e => { 12 | onChange(e.target.value) 13 | } 14 | 15 | return ( 16 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/AccountTableBody.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import TableBody from '@mui/material/TableBody' 3 | import Box from '@mui/material/Box' 4 | import AccountTableRow from './AccountTableRow' 5 | import { Currency } from '../../utils/Currency' 6 | import AutoSizer from 'react-virtualized-auto-sizer' 7 | import { FixedSizeList as List } from 'react-window' 8 | import { ROW_HEIGHT } from './constants' 9 | import { setEditingRow } from '../../redux/slices/Accounts' 10 | 11 | export default function AccountTableBody({ 12 | rows, 13 | prepareRow, 14 | onRowSave, 15 | classes, 16 | selectedRowIds, 17 | cancelAddTransaction, 18 | onTransactionAdd, 19 | toggleRowSelected, 20 | toggleAllRowsSelected, 21 | categoriesMap, 22 | ...props 23 | }) { 24 | useEffect(() => {}) 25 | 26 | const onSave = (newData, oldData) => { 27 | if (newData.id === 0) { 28 | return onTransactionAdd({ 29 | ...newData, 30 | amount: Currency.inputToDinero(newData.amount), 31 | }) 32 | } 33 | 34 | onRowSave( 35 | { 36 | ...newData, 37 | amount: Currency.inputToDinero(newData.amount), 38 | }, 39 | { 40 | ...oldData, 41 | amount: Currency.valueToDinero(oldData.amount), 42 | }, 43 | ) 44 | } 45 | 46 | const onCancel = id => { 47 | cancelAddTransaction() 48 | } 49 | 50 | const Row = ({ index, style }) => { 51 | const row = rows[index] 52 | 53 | prepareRow(row) 54 | return ( 55 | onSave(rowData, row.original)} 60 | onCancel={onCancel} 61 | row={row} 62 | classes={classes} 63 | cancelAddTransaction={cancelAddTransaction} 64 | toggleRowSelected={toggleRowSelected} 65 | toggleAllRowsSelected={toggleAllRowsSelected} 66 | categoriesMap={categoriesMap} 67 | /> 68 | ) 69 | } 70 | 71 | return ( 72 | 73 | 74 | {({ height, width }) => ( 75 | 84 | {Row} 85 | 86 | )} 87 | 88 | 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/AccountTableCell.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/frontend/src/components/AccountTable/AccountTableCell.js -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/AccountTableRow.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import TableRow from '@mui/material/TableRow' 4 | import TableCell from '@mui/material/TableCell' 5 | import Box from '@mui/material/Box' 6 | import { Currency } from '../../utils/Currency' 7 | import { toUnit } from 'dinero.js' 8 | import clsx from 'clsx' 9 | import { ROW_HEIGHT } from './constants' 10 | import { setEditingRow } from '../../redux/slices/Accounts' 11 | import { useTheme } from '@mui/styles' 12 | 13 | export default function AccountTableRow({ 14 | id, 15 | row, 16 | onCancel, 17 | onSave, 18 | classes, 19 | cancelAddTransaction, 20 | toggleRowSelected, 21 | toggleAllRowsSelected, 22 | categoriesMap, 23 | ...props 24 | }) { 25 | const theme = useTheme() 26 | const dispatch = useDispatch() 27 | 28 | const editing = useSelector(state => state.accounts.editingRow === row.original.id) 29 | 30 | const buildCategoryOptions = rowData => { 31 | let options = { ...categoriesMap } 32 | if (rowData.categoryId !== '0') { 33 | delete options['0'] 34 | } 35 | 36 | return options 37 | } 38 | 39 | const [categoryOptions, setCategoryOptions] = useState(buildCategoryOptions(row.original)) 40 | const [rowData, setRowData] = useState({ 41 | ...row.original, 42 | amount: toUnit(Currency.valueToDinero(row.original.amount), { digits: 2 }), 43 | }) 44 | 45 | const updateRowData = (field, val) => { 46 | setRowData({ 47 | ...rowData, 48 | [field]: val, 49 | }) 50 | } 51 | 52 | const onRowDataChange = newRowData => { 53 | const options = buildCategoryOptions(newRowData) 54 | 55 | setCategoryOptions(options) 56 | 57 | if (rowData.categoryId === '0') { 58 | newRowData.categoryId = row.original.categoryId || Object.keys(categoriesMap)[1] 59 | } 60 | 61 | setRowData(newRowData) 62 | } 63 | 64 | const onCellKeyPress = e => { 65 | switch (e.key) { 66 | case 'Enter': 67 | return save() 68 | 69 | case 'Escape': 70 | return cancel() 71 | } 72 | } 73 | 74 | const save = () => { 75 | onSave(rowData) 76 | dispatch(setEditingRow(0)) 77 | } 78 | 79 | const cancel = e => { 80 | if (e) { 81 | e.stopPropagation() 82 | } 83 | 84 | setRowData({ 85 | ...row.original, 86 | amount: toUnit(Currency.valueToDinero(row.original.amount), { digits: 2 }), 87 | }) 88 | dispatch(setEditingRow(0)) 89 | onCancel(row.original.id) 90 | } 91 | 92 | const onRowClick = () => { 93 | if (editing) { 94 | return 95 | } 96 | 97 | if (row.isSelected === true) { 98 | return dispatch(setEditingRow(row.original.id)) 99 | } 100 | 101 | cancelAddTransaction() 102 | dispatch(setEditingRow(0)) 103 | toggleAllRowsSelected(false) 104 | toggleRowSelected(row.id) 105 | } 106 | 107 | return ( 108 | <> 109 | 119 | {row.cells.map(cell => { 120 | return ( 121 | 136 | {cell.render(editing === true ? 'Editing' : 'Cell', { 137 | value: rowData[cell.column.id], 138 | rowData, 139 | onChange: val => updateRowData(cell.column.id, val), 140 | onRowDataChange: onRowDataChange, 141 | categoryOptions: categoryOptions, 142 | onKeyDown: onCellKeyPress, 143 | save: save, 144 | cancel: cancel, 145 | })} 146 | 147 | ) 148 | })} 149 | 150 | 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/BalanceCalculation.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | import { accountsSelectors, editAccount } from '../../redux/slices/Accounts' 4 | import { FromAPI, getBalanceColor, Currency } from '../../utils/Currency' 5 | import { usePopupState } from 'material-ui-popup-state/hooks' 6 | import Stack from '@mui/material/Stack' 7 | import Box from '@mui/material/Box' 8 | import Typography from '@mui/material/Typography' 9 | import { useTheme } from '@mui/styles' 10 | import { createSelector } from '@reduxjs/toolkit' 11 | 12 | export default function BalanceCalculation({ account }) { 13 | const theme = useTheme() 14 | 15 | return ( 16 | 17 |
18 | 24 | 31 | {Currency.intlFormat(account.cleared)} 32 | 33 | 39 | Cleared 40 | 41 | 42 |
43 | 44 | 49 | + 50 | 51 | 52 |
53 | 59 | 66 | {Currency.intlFormat(account.uncleared)} 67 | 68 | 74 | Uncleared 75 | 76 | 77 |
78 | 79 | 84 | = 85 | 86 | 87 |
88 | 94 | 101 | {Currency.intlFormat(account.balance)} 102 | 103 | 109 | Balance 110 | 111 | 112 |
113 |
114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/TableColumns.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import TableRow from '@material-ui/core/TableRow' 3 | import TableCell from '@material-ui/core/TableCell' 4 | import { ROW_SIZE } from '../constants' 5 | 6 | /** 7 | * Renders the headers row based on the columns provided. 8 | */ 9 | const TableColumns = ({ classes, columns }) => ( 10 | 11 | {columns.map((column, colIndex) => { 12 | return ( 13 | 25 | {column.label} 26 | 27 | ) 28 | })} 29 | 30 | ) 31 | 32 | export default TableColumns 33 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/TransactionDatePicker.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Box from '@mui/material/Box' 3 | import TextField from '@mui/material/TextField' 4 | import DatePicker from '@mui/lab/DatePicker' 5 | import LocalizationProvider from '@mui/lab/LocalizationProvider' 6 | import AdapterDateFns from '@mui/lab/AdapterDateFns' 7 | import { useTheme } from '@mui/styles' 8 | import EventIcon from '@mui/icons-material/Event' 9 | 10 | function DatePickerIcon(props) { 11 | const theme = useTheme() 12 | return 13 | } 14 | 15 | export default function TransactionDatePicker(props) { 16 | const theme = useTheme() 17 | const [open, setOpen] = useState(false) 18 | 19 | const onFocus = () => { 20 | setOpen(true) 21 | } 22 | 23 | const onBlur = () => { 24 | setOpen(false) 25 | } 26 | 27 | return ( 28 | 33 | 34 | { 50 | return ( 51 | 76 | ) 77 | }} 78 | /> 79 | 80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/components/AccountTable/constants.js: -------------------------------------------------------------------------------- 1 | export const ROW_HEIGHT = 30 2 | -------------------------------------------------------------------------------- /frontend/src/components/AddAccountDialog.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Button from '@mui/material/Button' 3 | import TextField from '@mui/material/TextField' 4 | import Dialog from '@mui/material/Dialog' 5 | import DialogActions from '@mui/material/DialogActions' 6 | import DialogContent from '@mui/material/DialogContent' 7 | import DialogTitle from '@mui/material/DialogTitle' 8 | import { createAccount, fetchAccounts } from '../redux/slices/Accounts' 9 | import { useDispatch, useSelector } from 'react-redux' 10 | import MenuItem from '@mui/material/MenuItem' 11 | import { fetchCategories } from '../redux/slices/CategoryGroups' 12 | import { refreshBudget } from '../redux/slices/Budgets' 13 | import { Currency } from '../utils/Currency' 14 | import { fetchPayees } from '../redux/slices/Payees' 15 | 16 | const accountTypes = ['Bank', 'Credit Card', 'Off Budget Account'] 17 | 18 | export default function AddAccountDialog(props) { 19 | const month = useSelector(state => state.budgets.currentMonth) 20 | 21 | /** 22 | * State block 23 | */ 24 | const [name, setName] = useState('') 25 | const [accountType, setAccountType] = useState('') 26 | const [balance, setBalance] = useState(0) 27 | const onNameChange = e => setName(e.target.value) 28 | const onAccountTypeChange = e => setAccountType(e.target.value) 29 | const onBalanceChange = e => setBalance(e.target.value) 30 | 31 | /** 32 | * Redux block 33 | */ 34 | const dispatch = useDispatch() 35 | 36 | const handleCreateAccount = async () => { 37 | await dispatch( 38 | createAccount({ 39 | name, 40 | accountType, 41 | balance: Currency.inputToDinero(balance), 42 | date: new Date(), 43 | }), 44 | ) 45 | 46 | dispatch(refreshBudget()) 47 | dispatch(fetchAccounts()) 48 | dispatch(fetchPayees()) 49 | 50 | // If adding a credit card, update all categories since we have added a payment category for it 51 | if (accountType === 1) { 52 | dispatch(fetchCategories()) 53 | } 54 | reset() 55 | props.close() 56 | } 57 | 58 | const reset = () => { 59 | setName('') 60 | setAccountType('') 61 | } 62 | 63 | return ( 64 |
65 | false}> 66 | Add an Account 67 | 68 | 78 | 87 | {accountTypes.map((type, index) => ( 88 | 89 | {type} 90 | 91 | ))} 92 | 93 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /frontend/src/components/AlertDialog.js: -------------------------------------------------------------------------------- 1 | import Button from '@mui/material/Button' 2 | import Dialog from '@mui/material/Dialog' 3 | import DialogActions from '@mui/material/DialogActions' 4 | import DialogContent from '@mui/material/DialogContent' 5 | import DialogContentText from '@mui/material/DialogContentText' 6 | import DialogTitle from '@mui/material/DialogTitle' 7 | 8 | export default function AlertDialog(props) { 9 | return ( 10 | 16 | {props.title && {props.title}} 17 | 18 | 19 | {props.body} 20 | 21 | 22 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/BudgetMonthNavigator.js: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import { useTheme } from '@mui/styles' 4 | import Stack from '@mui/material/Stack' 5 | import IconButton from '@mui/material/IconButton' 6 | import { formatMonthFromDateString, getDateFromString } from '../utils/Date' 7 | import BudgetMonthPicker from './BudgetMonthPicker' 8 | import Button from '@mui/material/Button' 9 | import Typography from '@mui/material/Typography' 10 | import { usePopupState, bindTrigger } from 'material-ui-popup-state/hooks' 11 | import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIosNew' 12 | import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' 13 | import { selectActiveBudget, setCurrentMonth } from '../redux/slices/Budgets' 14 | import EventIcon from '@mui/icons-material/Event' 15 | import List from '@mui/material/List' 16 | import ListItem from '@mui/material/ListItem' 17 | 18 | export default function BudgetMonthNavigator({ mini }) { 19 | const theme = useTheme() 20 | const dispatch = useDispatch() 21 | 22 | const month = useSelector(state => state.budgets.currentMonth) 23 | const availableMonths = useSelector(state => state.budgets.availableMonths) 24 | 25 | const nextMonth = getDateFromString(month) 26 | nextMonth.setMonth(nextMonth.getMonth() + 1) 27 | const nextMonthDisabled = !availableMonths.includes(formatMonthFromDateString(nextMonth)) 28 | 29 | const prevMonth = getDateFromString(month) 30 | prevMonth.setMonth(prevMonth.getMonth() - 1) 31 | const prevMonthDisabled = !availableMonths.includes(formatMonthFromDateString(prevMonth)) 32 | 33 | const monthPickerPopupState = usePopupState({ 34 | variant: 'popover', 35 | popupId: 'monthPicker', 36 | }) 37 | 38 | const navigateMonth = direction => { 39 | const monthDate = new Date(Date.UTC(...month.split('-'))) 40 | monthDate.setDate(1) 41 | monthDate.setMonth(monthDate.getMonth() + direction) 42 | dispatch(setCurrentMonth({ month: formatMonthFromDateString(monthDate) })) 43 | } 44 | 45 | const isToday = month === formatMonthFromDateString(new Date()) 46 | 47 | if (mini === true) { 48 | return ( 49 | 50 | 56 | 57 | 66 | 67 | 68 | ) 69 | } 70 | 71 | return ( 72 | 73 | 79 | navigateMonth(-1)} 82 | sx={{ 83 | fontSize: theme.typography.h6.fontSize, 84 | color: 'white', 85 | }} 86 | > 87 | 88 | 89 | 90 | 91 | 104 | 105 | 116 | 117 | 118 | navigateMonth(1)} 121 | sx={{ 122 | fontSize: theme.typography.h6.fontSize, 123 | // [`.Mui-disabled`]: { color: theme.palette.grey[500] }, 124 | color: 'white', 125 | }} 126 | > 127 | 128 | 129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /frontend/src/components/BudgetMonthPicker.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Popover from '@mui/material/Popover' 3 | import AdapterDateFns from '@mui/lab/AdapterDateFns' 4 | import LocalizationProvider from '@mui/lab/LocalizationProvider' 5 | import StaticDatePicker from '@mui/lab/StaticDatePicker' 6 | import TextField from '@mui/material/TextField' 7 | import { formatMonthFromDateString, getDateFromString } from '../utils/Date' 8 | import { bindPopover } from 'material-ui-popup-state/hooks' 9 | import { useDispatch } from 'react-redux' 10 | import { setCurrentMonth } from '../redux/slices/Budgets' 11 | 12 | export default function BudgetMonthPicker(props) { 13 | const dispatch = useDispatch() 14 | 15 | const [value, setValue] = useState(getDateFromString(props.currentMonth)) 16 | 17 | const onChange = newValue => { 18 | if (value.getMonth() === newValue.getMonth()) { 19 | if (value.getYear() === newValue.getYear()) { 20 | return props.popupState.close() 21 | } 22 | } 23 | 24 | setValue(newValue) 25 | setMonth(newValue) 26 | } 27 | 28 | const setMonth = async month => { 29 | props.popupState.close() 30 | if (!month) { 31 | return 32 | } 33 | 34 | dispatch(setCurrentMonth({ month: formatMonthFromDateString(month) })) 35 | } 36 | 37 | return ( 38 | props.popupState.close() }} 46 | > 47 | 48 | true} 57 | onMonthChange={onChange} 58 | renderInput={params => } 59 | /> 60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/BudgetTable/BudgetMonthCalculation.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import Stack from '@mui/material/Stack' 4 | import Box from '@mui/material/Box' 5 | import Typography from '@mui/material/Typography' 6 | import { useTheme } from '@mui/styles' 7 | import { getBalanceColor, Currency } from '../../utils/Currency' 8 | import { selectActiveBudget } from '../../redux/slices/Budgets' 9 | import { add } from 'dinero.js' 10 | 11 | export default function BudgetMonthCalculation({ account }) { 12 | const theme = useTheme() 13 | 14 | const month = useSelector(state => state.budgets.currentMonth) 15 | const budgetMonth = useSelector(state => { 16 | return state.budgetMonths.entities[month] || null 17 | }) 18 | 19 | const income = budgetMonth ? Currency.valueToDinero(budgetMonth.income) : Currency.inputToDinero(0) 20 | const activity = budgetMonth ? Currency.valueToDinero(budgetMonth.activity) : Currency.inputToDinero(0) 21 | 22 | return ( 23 | 24 |
25 | 31 | 38 | {Currency.intlFormat(income)} 39 | 40 | 46 | Income 47 | 48 | 49 |
50 | 51 | 56 | + 57 | 58 | 59 |
60 | 66 | 73 | {Currency.intlFormat(activity)} 74 | 75 | 81 | Spent 82 | 83 | 84 |
85 | 86 | 91 | = 92 | 93 | 94 |
95 | 101 | 108 | {Currency.intlFormat(add(income, activity))} 109 | 110 | 116 | Net 117 | 118 | 119 |
120 |
121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /frontend/src/components/BudgetTable/BudgetTableAssignedCell.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import TextField from '@mui/material/TextField' 3 | import { Currency } from '../../utils/Currency' 4 | import { useTheme } from '@mui/styles' 5 | import { isZero, toUnit } from 'dinero.js' 6 | import { styled } from '@mui/material/styles' 7 | import { useSelector } from 'react-redux' 8 | import InputAdornment from '@mui/material/InputAdornment' 9 | import CalculateIcon from '@mui/icons-material/Calculate' 10 | import CalculateOutlinedIcon from '@mui/icons-material/CalculateOutlined' 11 | import IconButton from '@mui/material/IconButton' 12 | import mexp from 'math-expression-evaluator' 13 | 14 | const BudgetCell = styled(TextField)(({ theme }) => ({ 15 | '& .MuiInput-root:before': { 16 | borderBottom: '0px', 17 | }, 18 | '.MuiInputAdornment-root .MuiSvgIcon-root': { 19 | display: 'none', 20 | }, 21 | '&:hover .MuiInputAdornment-root .MuiSvgIcon-root': { 22 | display: 'flex', 23 | }, 24 | '& .Mui-focused .MuiInputAdornment-root .MuiSvgIcon-root': { 25 | display: 'flex', 26 | }, 27 | })) 28 | 29 | export default function BudgetTableAssignedCell({ budgeted, onSubmit }) { 30 | const theme = useTheme() 31 | const month = useSelector(state => state.budgets.currentMonth) 32 | const [rowValue, setRowValue] = useState(Currency.intlFormat(budgeted)) 33 | 34 | useEffect(() => { 35 | setRowValue(Currency.intlFormat(budgeted)) 36 | }, [budgeted]) 37 | 38 | const onFocus = async e => { 39 | await setRowValue(toUnit(budgeted, { digits: 2 })) 40 | e.target.select() 41 | } 42 | 43 | const onBlur = async () => { 44 | await setRowValue(Currency.intlFormat(budgeted)) 45 | } 46 | 47 | const onChange = e => { 48 | const operators = ['+', '-', '*', '/'] 49 | const value = e.target.value 50 | 51 | if (operators.includes(value)) { 52 | if (operators.includes(rowValue[rowValue.length - 1])) { 53 | setRowValue(`${rowValue.slice(0, -1)}${value}`) 54 | } else { 55 | setRowValue(`${rowValue}${value}`) 56 | } 57 | } else { 58 | setRowValue(e.target.value) 59 | } 60 | } 61 | 62 | const onKeyPress = e => { 63 | if (e.key === 'Enter') { 64 | try { 65 | const newValue = Currency.inputToDinero(mexp.eval(rowValue)) 66 | setRowValue(Currency.intlFormat(newValue)) 67 | onSubmit(newValue, month) 68 | e.target.blur() 69 | } catch (e) { 70 | console.log(e) 71 | } 72 | } 73 | } 74 | 75 | return ( 76 | 98 | // {/* */} 99 | // 100 | // {/* */} 101 | // 102 | // ), 103 | // }} 104 | /> 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /frontend/src/components/BudgetTable/BudgetTableHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { selectActiveBudget, setCurrentMonth } from '../../redux/slices/Budgets' 4 | import IconButton from '@mui/material/IconButton' 5 | import { formatMonthFromDateString, getDateFromString } from '../../utils/Date' 6 | import { isZero } from 'dinero.js' 7 | import { getBalanceColor, Currency, valueToDinero } from '../../utils/Currency' 8 | import BudgetMonthPicker from '../BudgetMonthPicker' 9 | import Button from '@mui/material/Button' 10 | import { useTheme } from '@mui/styles' 11 | import Typography from '@mui/material/Typography' 12 | import { usePopupState, bindTrigger } from 'material-ui-popup-state/hooks' 13 | import Stack from '@mui/material/Stack' 14 | import Box from '@mui/material/Box' 15 | import Paper from '@mui/material/Paper' 16 | import Card from '@mui/material/Card' 17 | import CardHeader from '@mui/material/CardHeader' 18 | import CardMedia from '@mui/material/CardMedia' 19 | import CardContent from '@mui/material/CardContent' 20 | import CardActions from '@mui/material/CardActions' 21 | import ExpandMore from '@mui/icons-material/ExpandMore' 22 | import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIosNew' 23 | import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' 24 | import BudgetMonthNavigator from '../BudgetMonthNavigator' 25 | import BudgetMonthCalculation from './BudgetMonthCalculation' 26 | import Divider from '@mui/material/Divider' 27 | 28 | export default function BudgetTableHeader(props) { 29 | const theme = useTheme() 30 | const dispatch = useDispatch() 31 | 32 | const month = useSelector(state => state.budgets.currentMonth) 33 | const availableMonths = useSelector(state => state.budgets.availableMonths) 34 | const budget = useSelector(selectActiveBudget) 35 | 36 | const toBeBudgeted = budget ? Currency.valueToDinero(budget.toBeBudgeted) : Currency.inputToDinero(0) 37 | 38 | const isToday = month === formatMonthFromDateString(new Date()) 39 | 40 | return ( 41 | 47 | 48 | 49 | 50 | {/* 51 | Available} 53 | sx={{ 54 | backgroundColor: 'green', 55 | p: 1, 56 | }} 57 | /> 58 | {intlFormat(toBeBudgeted)} 59 | */} 60 | 61 | 62 | 63 | 64 | 69 | Available 70 | 71 | 72 | 73 | 74 | 75 | 76 | 83 | {Currency.intlFormat(toBeBudgeted)} 84 | 85 | 86 | 87 | 88 | 89 | 95 | 96 | 97 | 98 | 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /frontend/src/components/CategoryForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import { createCategory, updateCategory } from '../redux/slices/Categories' 4 | import TextField from '@mui/material/TextField' 5 | import Button from '@mui/material/Button' 6 | import Select from '@mui/material/Select' 7 | import MenuItem from '@mui/material/MenuItem' 8 | import Popover from '@mui/material/Popover' 9 | import Box from '@mui/material/Box' 10 | import { bindPopover } from 'material-ui-popup-state/hooks' 11 | import Stack from '@mui/material/Stack' 12 | import { categoryGroupsSelectors } from '../redux/slices/CategoryGroups' 13 | 14 | export default function NewCategoryDialog(props) { 15 | /** 16 | * Redux block 17 | */ 18 | const dispatch = useDispatch() 19 | const categoryGroups = useSelector(categoryGroupsSelectors.selectAll) 20 | 21 | console.log(props) 22 | 23 | /** 24 | * State block 25 | */ 26 | const [name, setName] = useState(props.name) 27 | const [categoryGroup, setCategoryGroup] = useState(props.categoryGroupId) 28 | 29 | const submit = async () => { 30 | switch (props.mode) { 31 | case 'create': 32 | await dispatch( 33 | createCategory({ 34 | name: name, 35 | categoryGroupId: categoryGroup, 36 | }), 37 | ) 38 | break 39 | case 'edit': 40 | await dispatch( 41 | updateCategory({ 42 | id: props.categoryId, 43 | name: name, 44 | order: props.order, 45 | categoryGroupId: categoryGroup, 46 | }), 47 | ) 48 | break 49 | } 50 | 51 | props.popupState.close() 52 | } 53 | 54 | return ( 55 | props.popupState.close() }} 63 | > 64 | 65 | setName(event.target.value)} 72 | label="Name" 73 | type="text" 74 | variant="standard" 75 | /> 76 | 100 | 101 | 104 | 105 | 106 | 107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /frontend/src/components/CategoryGroupForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | import { createCategoryGroup, updateCategoryGroup } from '../redux/slices/CategoryGroups' 4 | import TextField from '@mui/material/TextField' 5 | import Button from '@mui/material/Button' 6 | import { bindPopover } from 'material-ui-popup-state/hooks' 7 | import Popover from '@mui/material/Popover' 8 | import Box from '@mui/material/Box' 9 | import Stack from '@mui/material/Stack' 10 | 11 | export default function NewCategoryDialog(props) { 12 | /** 13 | * Redux block 14 | */ 15 | const dispatch = useDispatch() 16 | 17 | /** 18 | * State block 19 | */ 20 | const [name, setName] = useState(props.name) 21 | 22 | const submit = async () => { 23 | switch (props.mode) { 24 | case 'create': 25 | await dispatch( 26 | createCategoryGroup({ 27 | name: name, 28 | }), 29 | ) 30 | break 31 | case 'edit': 32 | await dispatch( 33 | updateCategoryGroup({ 34 | id: props.categoryId, 35 | name: name, 36 | order: props.order, 37 | }), 38 | ) 39 | break 40 | } 41 | 42 | props.popupState.close() 43 | } 44 | 45 | return ( 46 | props.popupState.close() }} 54 | > 55 | 56 | setName(event.target.value)} 63 | label="Group" 64 | type="text" 65 | variant="standard" 66 | /> 67 | 68 | 71 | 72 | 73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/CategoryMonthActivity.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { accountsSelectors } from '../redux/slices/Accounts' 3 | import { useSelector } from 'react-redux' 4 | import Box from '@mui/material/Box' 5 | import Table from '@mui/material/Table' 6 | import TableHead from '@mui/material/TableHead' 7 | import TableBody from '@mui/material/TableBody' 8 | import TableRow from '@mui/material/TableRow' 9 | import TableCell from '@mui/material/TableCell' 10 | import TableContainer from '@mui/material/TableContainer' 11 | import Tooltip from '@mui/material/Tooltip' 12 | import Typography from '@mui/material/Typography' 13 | import { Currency, FromAPI } from '../utils/Currency' 14 | import { formatMonthFromDateString } from '../utils/Date' 15 | import { useTheme } from '@mui/styles' 16 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' 17 | 18 | export default function CategoryMonthActivity(props) { 19 | const theme = useTheme() 20 | 21 | const month = useSelector(state => state.budgets.currentMonth.split('-')) 22 | const accounts = useSelector(accountsSelectors.selectAll) 23 | const selectedCategory = useSelector(state => { 24 | if (!state.categories.selected) { 25 | return null 26 | } 27 | 28 | return state.categories.entities[state.categories.selected] 29 | }) 30 | const payees = useSelector(state => state.payees.entities) 31 | 32 | if (!selectedCategory) { 33 | return <>No category selected 34 | } 35 | 36 | const transactions = accounts.reduce((total, account) => { 37 | const filtered = Object.values(account.transactions.entities).filter(trx => { 38 | const trxDate = trx.date.split('-') 39 | if (trx.categoryId !== selectedCategory.id) { 40 | return false 41 | } 42 | 43 | if (trxDate[0] === month[0] && trxDate[1] === month[1]) { 44 | return true 45 | } 46 | 47 | return false 48 | }) 49 | 50 | return total.concat(filtered) 51 | }, []) 52 | 53 | if (transactions.length === 0) { 54 | return No transactions for this month 55 | } 56 | 57 | return ( 58 | 59 | 60 | 61 | 62 | {transactions.map(transaction => { 63 | transaction = FromAPI.transformTransaction(transaction) 64 | return ( 65 | 66 | 70 | 71 | Date: {formatMonthFromDateString(transaction.date)} 72 | 73 | 74 | Account: {accounts.find(acct => acct.id === transaction.accountId).name} 75 | 76 | 77 | Memo: {transaction.memo}{' '} 78 | 79 | 80 | } 81 | > 82 | 83 | 84 | 85 | 86 | {payees[transaction.payeeId].name} 87 | {Currency.intlFormat(transaction.amount)} 88 | 89 | ) 90 | })} 91 | 92 |
93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /frontend/src/components/Login.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import Button from '@mui/material/Button' 3 | import TextField from '@mui/material/TextField' 4 | import Dialog from '@mui/material/Dialog' 5 | import DialogActions from '@mui/material/DialogActions' 6 | import DialogContent from '@mui/material/DialogContent' 7 | import DialogContentText from '@mui/material/DialogContentText' 8 | import DialogTitle from '@mui/material/DialogTitle' 9 | import { setUser, login, logout, setInitComplete } from '../redux/slices/Users' 10 | import { 11 | createBudget, 12 | fetchAvailableMonths, 13 | fetchBudgets, 14 | setActiveBudget, 15 | setCurrentMonth, 16 | } from '../redux/slices/Budgets' 17 | import { setAccounts, fetchAccountTransactions } from '../redux/slices/Accounts' 18 | import { fetchPayees } from '../redux/slices/Payees' 19 | import { fetchCategories, createCategoryGroup } from '../redux/slices/CategoryGroups' 20 | import { createCategory } from '../redux/slices/Categories' 21 | import api from '../api' 22 | import { useDispatch } from 'react-redux' 23 | import AlertDialog from './AlertDialog' 24 | import { formatMonthFromDateString } from '../utils/Date' 25 | 26 | export default function Login() { 27 | /** 28 | * State block 29 | */ 30 | const [open, setOpen] = useState(false) 31 | const [email, setEmail] = useState('') 32 | const [password, setPassword] = useState('') 33 | const [alertDialogOpen, setAlertDialogOpen] = useState(false) 34 | const [alertDialogBody, setAlertDialogBody] = useState('') 35 | 36 | const onEmailChange = e => setEmail(e.target.value) 37 | const onPasswordChange = e => setPassword(e.target.value) 38 | 39 | /** 40 | * Redux block 41 | */ 42 | const dispatch = useDispatch() 43 | // const user = useState(state => state.users.user) 44 | 45 | useEffect(() => { 46 | onMount() 47 | }, []) 48 | 49 | const onMount = async () => { 50 | try { 51 | const response = await api.ping() 52 | dispatch(setUser(response)) 53 | initUser() 54 | } catch (err) { 55 | dispatch(logout()) 56 | setOpen(true) 57 | } 58 | } 59 | 60 | const initUser = async () => { 61 | const budgets = (await dispatch(fetchBudgets())).payload 62 | // @TODO: better way to set 'default' budget? Maybe a flag on the budget object 63 | await dispatch(setActiveBudget({ budgetId: budgets[0].id })) 64 | await dispatch(setCurrentMonth({ month: formatMonthFromDateString(new Date()) })) 65 | await dispatch(setAccounts(budgets[0].accounts)) 66 | 67 | // @TODO: get all categories 68 | await dispatch(fetchCategories()) 69 | 70 | // Fetch all account transactions 71 | await Promise.all( 72 | budgets[0].accounts.map(account => { 73 | return dispatch( 74 | fetchAccountTransactions({ 75 | accountId: account.id, 76 | }), 77 | ) 78 | }), 79 | ) 80 | 81 | await dispatch(fetchPayees()) 82 | await dispatch(fetchAvailableMonths()) 83 | 84 | // done 85 | dispatch(setInitComplete(true)) 86 | } 87 | 88 | const handleLogin = async () => { 89 | try { 90 | await dispatch( 91 | login({ 92 | email, 93 | password, 94 | }), 95 | ) 96 | 97 | await initUser() 98 | } catch (err) { 99 | setAlertDialogBody('Failed to log in') 100 | setAlertDialogOpen(true) 101 | } 102 | } 103 | 104 | const userCreation = async () => { 105 | if (!email || !password) { 106 | setAlertDialogBody('Please enter an email address and password to create a new account') 107 | setAlertDialogOpen(true) 108 | return 109 | } 110 | 111 | try { 112 | await api.createUser(email, password) 113 | } catch (err) { 114 | const response = JSON.parse(err.request.response) 115 | 116 | setAlertDialogBody(response?.message || 'Failed to create user') 117 | setAlertDialogOpen(true) 118 | 119 | throw err 120 | } 121 | 122 | await dispatch( 123 | login({ 124 | email, 125 | password, 126 | }), 127 | ) 128 | // Create initial budget 129 | const newBudget = (await dispatch(createBudget({ name: 'My Budget' }))).payload 130 | await dispatch(setActiveBudget({ budgetId: newBudget.id })) 131 | 132 | // Create initial items such as category group, categories, etc. 133 | const newCategoryGroup = ( 134 | await dispatch( 135 | createCategoryGroup({ 136 | name: 'Expenses', 137 | }), 138 | ) 139 | ).payload 140 | 141 | await Promise.all([ 142 | dispatch( 143 | createCategory({ 144 | categoryGroupId: newCategoryGroup.id, 145 | name: 'Rent', 146 | }), 147 | ), 148 | dispatch( 149 | createCategory({ 150 | categoryGroupId: newCategoryGroup.id, 151 | name: 'Electric', 152 | }), 153 | ), 154 | dispatch( 155 | createCategory({ 156 | categoryGroupId: newCategoryGroup.id, 157 | name: 'Water', 158 | }), 159 | ), 160 | ]) 161 | 162 | await initUser() 163 | } 164 | 165 | const onKeyPress = e => { 166 | switch (e.key) { 167 | case 'Enter': 168 | return handleLogin() 169 | } 170 | } 171 | 172 | return ( 173 |
174 | setAlertDialogOpen(false)} /> 175 | false}> 176 | Login 177 | 178 | Login to start budgeting! 179 | 190 | 200 | 201 | 202 | 203 | 204 | 205 | 206 |
207 | ) 208 | } 209 | -------------------------------------------------------------------------------- /frontend/src/components/ReconcileForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | import TextField from '@mui/material/TextField' 4 | import Button from '@mui/material/Button' 5 | import Popover from '@mui/material/Popover' 6 | import Box from '@mui/material/Box' 7 | import { bindPopover } from 'material-ui-popup-state/hooks' 8 | import Stack from '@mui/material/Stack' 9 | import { toUnit } from 'dinero.js' 10 | import Typography from '@mui/material/Typography' 11 | import { Currency } from '../utils/Currency' 12 | import ButtonGroup from '@mui/material/ButtonGroup' 13 | import { editAccount, fetchAccountTransactions } from '../redux/slices/Accounts' 14 | 15 | export default function ReconcileForm(props) { 16 | /** 17 | * Redux block 18 | */ 19 | const dispatch = useDispatch() 20 | 21 | const [balanceCorrectAnswer, setBalanceCorrectAnswer] = useState(null) 22 | const [balance, setBalance] = useState(toUnit(props.balance)) 23 | 24 | useEffect(() => { 25 | setBalance(toUnit(props.balance)) 26 | }, [props.balance]) 27 | 28 | const close = () => { 29 | props.popupState.close() 30 | setBalance(null) 31 | setBalanceCorrectAnswer(null) 32 | } 33 | 34 | const submit = async () => { 35 | await dispatch( 36 | editAccount({ 37 | id: props.accountId, 38 | balance: Currency.inputToDinero(balance), 39 | }), 40 | ) 41 | await dispatch(fetchAccountTransactions({ accountId: props.accountId })) 42 | close() 43 | } 44 | 45 | return ( 46 | 55 | 56 | {balanceCorrectAnswer === null && ( 57 | 58 | Is this your current balance? 59 | {Currency.intlFormat(props.balance)} 60 | 61 | 64 | 67 | 68 | 69 | )} 70 | {balanceCorrectAnswer === false && ( 71 | <> 72 | setBalance(event.target.value)} 79 | label="Balance" 80 | type="text" 81 | variant="standard" 82 | /> 83 | 84 | 87 | 88 | 89 | )} 90 | 91 | 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /frontend/src/components/Settings/Account.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Box from '@mui/material/Box' 3 | import TextField from '@mui/material/TextField' 4 | import Button from '@mui/material/Button' 5 | import Stack from '@mui/material/Stack' 6 | import { useDispatch } from 'react-redux' 7 | import { updateUser } from '../../redux/slices/Users' 8 | 9 | export default function Account({ index, value, close, ...props }) { 10 | const dispatch = useDispatch() 11 | 12 | const [email, setEmail] = useState(props.email) 13 | const [currentPassword, setCurrentPassword] = useState('') 14 | const [password, setPassword] = useState('') 15 | const [passwordAgain, setPasswordAgain] = useState('') 16 | 17 | const submit = async () => { 18 | if (password !== passwordAgain) { 19 | // error 20 | } 21 | 22 | if (password && !currentPassword) { 23 | // error 24 | } 25 | 26 | const payload = { email, ...(password && currentPassword && { password, currentPassword }) } 27 | 28 | await dispatch(updateUser(payload)) 29 | close() 30 | } 31 | 32 | return ( 33 | 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/components/Settings/Budget.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Box from '@mui/material/Box' 3 | import TextField from '@mui/material/TextField' 4 | import Button from '@mui/material/Button' 5 | import Stack from '@mui/material/Stack' 6 | import Select from '@mui/material/Select' 7 | import MenuItem from '@mui/material/MenuItem' 8 | import InputLabel from '@mui/material/InputLabel' 9 | import { useSelector, useDispatch } from 'react-redux' 10 | import { selectActiveBudget, updateBudget } from '../../redux/slices/Budgets' 11 | import { Currency } from '../../utils/Currency' 12 | 13 | export default function Account({ index, value, ...props }) { 14 | const dispatch = useDispatch() 15 | 16 | const budget = useSelector(selectActiveBudget) 17 | const [budgetName, setBudgetName] = useState(budget.name) 18 | const [currency, setCurrency] = useState(budget.currency) 19 | 20 | const submit = async () => { 21 | if (!budgetName) { 22 | // error 23 | } 24 | 25 | await dispatch( 26 | updateBudget({ 27 | name: budgetName, 28 | currency, 29 | }), 30 | ) 31 | props.close() 32 | 33 | if (currency !== budget.currency) { 34 | window.location.reload(false) 35 | } 36 | } 37 | 38 | return ( 39 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/components/Settings/Settings.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Modal from '@mui/material/Modal' 3 | import Tabs from '@mui/material/Tabs' 4 | import Tab from '@mui/material/Tab' 5 | import Typography from '@mui/material/Typography' 6 | import Box from '@mui/material/Box' 7 | import Account from './Account' 8 | import Budget from './Budget' 9 | import { useSelector } from 'react-redux' 10 | 11 | function TabPanel(props) { 12 | const { children, value, index, ...other } = props 13 | 14 | return ( 15 | 28 | ) 29 | } 30 | 31 | function a11yProps(index) { 32 | return { 33 | id: `simple-tab-${index}`, 34 | 'aria-controls': `simple-tabpanel-${index}`, 35 | } 36 | } 37 | 38 | export default function Settings({ open, close }) { 39 | const email = useSelector(state => state.users.user.email) 40 | 41 | const [value, setValue] = useState(0) 42 | 43 | const tabs = [ 44 | { 45 | name: 'Account', 46 | Component: Account, 47 | props: { 48 | email: email, 49 | }, 50 | }, 51 | { 52 | name: 'Budget', 53 | Component: Budget, 54 | props: {}, 55 | }, 56 | ] 57 | 58 | const handleChange = (event, newValue) => { 59 | setValue(newValue) 60 | } 61 | 62 | return ( 63 |
64 | 65 | 79 | 80 | 81 | {tabs.map((config, index) => ( 82 | 83 | ))} 84 | 85 | 86 | {tabs.map((config, index) => { 87 | return 88 | })} 89 | 90 | 91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | // import wdyr from './wdyr' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import './index.css' 6 | import App from './App' 7 | import reportWebVitals from './reportWebVitals' 8 | import { Provider } from 'react-redux' 9 | import { store } from './redux/store' 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById('root'), 18 | ) 19 | 20 | // If you want to start measuring performance in your app, pass a function 21 | // to log results (for example: reportWebVitals(console.log)) 22 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 23 | reportWebVitals() 24 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/pages/Account.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { useParams } from 'react-router' 4 | import AccountTable from '../components/AccountTable/AccountTable' 5 | import { useNavigate } from 'react-router-dom' 6 | import { accountsSelectors } from '../redux/slices/Accounts' 7 | import AccountDetails from '../components/AccountDetails' 8 | import Stack from '@mui/material/Stack' 9 | import Box from '@mui/material/Box' 10 | import { useTheme } from '@mui/styles' 11 | 12 | export default function Account() { 13 | const navigate = useNavigate() 14 | const theme = useTheme() 15 | 16 | const params = useParams() 17 | const account = useSelector(state => accountsSelectors.selectById(state, params.accountId)) 18 | 19 | useEffect(() => { 20 | if (!account) { 21 | navigate('/') 22 | } 23 | }, []) 24 | 25 | return ( 26 | 32 | 38 | {account && } 39 | 40 | 41 | 49 | {account && } 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/pages/Budget.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BudgetTable from '../components/BudgetTable/BudgetTable' 3 | import BudgetDetails from '../components/BudgetDetails' 4 | import Box from '@mui/material/Box' 5 | import Stack from '@mui/material/Stack' 6 | import { useTheme } from '@mui/styles' 7 | 8 | export default function Budget() { 9 | const theme = useTheme() 10 | 11 | return ( 12 | 18 | 24 | 25 | 26 | 27 | 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/App.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const appSlice = createSlice({ 4 | name: 'app', 5 | 6 | initialState: { 7 | theme: localStorage.getItem('theme') || 'dark', 8 | }, 9 | 10 | reducers: { 11 | setTheme: (state, { payload }) => { 12 | localStorage.setItem('theme', payload) 13 | state.theme = payload 14 | }, 15 | }, 16 | }) 17 | 18 | export const { setTheme } = appSlice.actions 19 | 20 | export default appSlice.reducer 21 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/Budgets.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk, createEntityAdapter, createSelector } from '@reduxjs/toolkit' 2 | import api from '../../api' 3 | import { formatMonthFromDateString } from '../../utils/Date' 4 | import { fetchBudgetMonth } from './BudgetMonths' 5 | import { Currency } from '../../utils/Currency' 6 | 7 | export const createBudget = createAsyncThunk('budgets/create', async ({ name }) => { 8 | return await api.createBudget(name) 9 | }) 10 | 11 | export const updateBudget = createAsyncThunk('budgets/update', async ({ name, currency }, { getState }) => { 12 | const store = getState() 13 | return await api.updateBudget(store.budgets.activeBudgetId, name, currency) 14 | }) 15 | 16 | export const fetchBudgets = createAsyncThunk('budgets/fetchBudgets', async () => { 17 | return await api.fetchBudgets() 18 | }) 19 | 20 | export const refreshBudget = createAsyncThunk('budgets/refreshBudget', async (_, { getState }) => { 21 | const store = getState() 22 | return await api.fetchBudget(store.budgets.activeBudgetId) 23 | }) 24 | 25 | export const fetchAvailableMonths = createAsyncThunk('budgets/fetchMonths', async (_, { getState }) => { 26 | const store = getState() 27 | return await api.fetchBudgetMonths(store.budgets.activeBudgetId) 28 | }) 29 | 30 | export const setActiveBudget = createAsyncThunk('budgets/setActiveBudget', async ({ budgetId }) => { 31 | await api.fetchBudget(budgetId) 32 | return budgetId 33 | }) 34 | 35 | export const setCurrentMonth = createAsyncThunk('budgets/setCurrentMonth', async ({ month }, { dispatch }) => { 36 | dispatch(fetchBudgetMonth({ month })) 37 | return month 38 | }) 39 | 40 | export const budgetsAdapter = createEntityAdapter() 41 | 42 | const budgetsSlice = createSlice({ 43 | name: 'budgets', 44 | 45 | initialState: budgetsAdapter.getInitialState({ 46 | activeBudgetId: null, 47 | currentMonth: formatMonthFromDateString(new Date()), 48 | availableMonths: [], 49 | }), 50 | 51 | reducers: {}, 52 | 53 | extraReducers: builder => { 54 | builder 55 | .addCase(createBudget.fulfilled, (state, { payload }) => { 56 | budgetsAdapter.setOne(state, payload) 57 | }) 58 | .addCase(updateBudget.fulfilled, (state, { payload }) => { 59 | budgetsAdapter.upsertOne(state, payload) 60 | }) 61 | .addCase(fetchBudgets.fulfilled, (state, { payload }) => { 62 | budgetsAdapter.setAll( 63 | state, 64 | payload.map(({ accounts, ...budget }) => budget), 65 | ) 66 | }) 67 | .addCase(refreshBudget.fulfilled, (state, { payload: { accounts, ...budget } }) => { 68 | budgetsAdapter.upsertOne(state, budget) 69 | }) 70 | .addCase(fetchAvailableMonths.fulfilled, (state, { payload }) => { 71 | state.availableMonths = payload.map(budgetMonth => budgetMonth.month).sort() 72 | }) 73 | .addCase(setActiveBudget.fulfilled, (state, { payload }) => { 74 | state.activeBudgetId = payload 75 | Currency.setCurrency(state.entities[payload].currency) 76 | }) 77 | .addCase(setCurrentMonth.fulfilled, (state, { payload }) => { 78 | state.currentMonth = payload 79 | }) 80 | }, 81 | }) 82 | 83 | export const budgetSelectors = budgetsAdapter.getSelectors(state => state.budgets) 84 | export const selectActiveBudget = createSelector( 85 | [state => state.budgets.activeBudgetId, state => state.budgets.entities], 86 | (activeBudgetId, budgets) => budgets[activeBudgetId], 87 | ) 88 | 89 | export default budgetsSlice.reducer 90 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/Categories.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk, createEntityAdapter, createSelector } from '@reduxjs/toolkit' 2 | import api from '../../api' 3 | 4 | export const createCategory = createAsyncThunk( 5 | 'categories/createCategory', 6 | async ({ name, categoryGroupId }, { getState }) => { 7 | const store = getState() 8 | return await api.createCategory(name, categoryGroupId, store.budgets.activeBudgetId) 9 | }, 10 | ) 11 | 12 | export const updateCategory = createAsyncThunk( 13 | 'categories/updateCategory', 14 | async ({ id, name, order, categoryGroupId }, { getState }) => { 15 | const store = getState() 16 | const category = await api.updateCategory(id, name, order, categoryGroupId, store.budgets.activeBudgetId) 17 | return category 18 | }, 19 | ) 20 | 21 | const categoriesAdapter = createEntityAdapter() 22 | 23 | const categoriesSlice = createSlice({ 24 | name: 'categories', 25 | 26 | initialState: categoriesAdapter.getInitialState({ 27 | selected: null, 28 | }), 29 | 30 | reducers: { 31 | setCategories: (state, { payload }) => { 32 | categoriesAdapter.setAll(state, payload) 33 | }, 34 | 35 | setSelectedCategory: (state, { payload }) => { 36 | state.selected = payload 37 | }, 38 | }, 39 | 40 | extraReducers: builder => { 41 | builder.addCase(createCategory.fulfilled, (state, { payload }) => { 42 | categoriesAdapter.addOne(state, payload) 43 | }) 44 | 45 | builder.addCase(updateCategory.fulfilled, (state, { payload }) => { 46 | categoriesAdapter.upsertOne(state, payload) 47 | }) 48 | }, 49 | }) 50 | 51 | export const { setCategories, setSelectedCategory } = categoriesSlice.actions 52 | 53 | export const categoriesSelectors = categoriesAdapter.getSelectors(state => state.categories) 54 | export const selectCategoryToGroupMap = createSelector(categoriesSelectors.selectAll, categories => 55 | categories.reduce((all, cat) => { 56 | all[cat.id] = cat 57 | return all 58 | }, {}), 59 | ) 60 | 61 | export default categoriesSlice.reducer 62 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/CategoryGroups.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit' 2 | import api from '../../api' 3 | import { setCategories } from './Categories' 4 | 5 | export const fetchCategories = createAsyncThunk('categories/fetch', async (_, { dispatch, getState }) => { 6 | const store = getState() 7 | const response = await api.fetchCategories(store.budgets.activeBudgetId) 8 | 9 | const categories = response.reduce((acc, group) => { 10 | return acc.concat(group.categories) 11 | }, []) 12 | 13 | dispatch(setCategories(categories)) 14 | 15 | return response 16 | }) 17 | 18 | export const createCategoryGroup = createAsyncThunk( 19 | 'categories/createCategoryGroup', 20 | async ({ name }, { getState }) => { 21 | const store = getState() 22 | return await api.createCategoryGroup(name, store.budgets.activeBudgetId) 23 | }, 24 | ) 25 | 26 | export const updateCategoryGroup = createAsyncThunk( 27 | 'categories/updateCategoryGroup', 28 | async ({ id, name, order }, { getState }) => { 29 | const store = getState() 30 | return await api.updateCategoryGroup(id, name, order, store.budgets.activeBudgetId) 31 | }, 32 | ) 33 | 34 | const categoryGroupsAdapter = createEntityAdapter() 35 | 36 | const categoryGroupsSlice = createSlice({ 37 | name: 'categoryGroups', 38 | 39 | initialState: categoryGroupsAdapter.getInitialState(), 40 | 41 | reducers: {}, 42 | 43 | extraReducers: builder => { 44 | builder 45 | .addCase(fetchCategories.fulfilled, (state, { payload }) => { 46 | categoryGroupsAdapter.setAll(state, payload) 47 | }) 48 | .addCase(createCategoryGroup.fulfilled, (state, { payload }) => { 49 | categoryGroupsAdapter.addOne(state, payload) 50 | }) 51 | .addCase(updateCategoryGroup.fulfilled, (state, { payload }) => { 52 | categoryGroupsAdapter.upsertOne(state, payload) 53 | }) 54 | }, 55 | }) 56 | 57 | export const categoryGroupsSelectors = categoryGroupsAdapter.getSelectors(state => state.categoryGroups) 58 | 59 | export default categoryGroupsSlice.reducer 60 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/Payees.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk, createEntityAdapter, createSelector } from '@reduxjs/toolkit' 2 | import api from '../../api' 3 | 4 | export const createPayee = createAsyncThunk('payees/create', async ({ name }, { getState }) => { 5 | const store = getState() 6 | return await api.createPayee(name, store.budgets.activeBudgetId) 7 | }) 8 | 9 | export const fetchPayees = createAsyncThunk('payees/fetch', async (_, { getState }) => { 10 | const store = getState() 11 | return await api.fetchPayees(store.budgets.activeBudgetId) 12 | }) 13 | 14 | const payeesAdapter = createEntityAdapter({ 15 | // Assume IDs are stored in a field other than `book.id` 16 | // selectId: (payee) => payee.id, 17 | // Keep the "all IDs" array sorted based on book titles 18 | // sortComparer: (a, b) => a.title.localeCompare(b.title), 19 | }) 20 | 21 | const payeesSlice = createSlice({ 22 | name: 'payees', 23 | 24 | initialState: payeesAdapter.getInitialState(), 25 | 26 | reducers: { 27 | // setAccounts: (state, { payload }) => { 28 | // state.accounts = payload 29 | // // Map all accounts to make lookups faster 30 | // payload.map(account => { 31 | // payeesSlice.caseReducers.mapIdToAccount(state, { payload: { accountId: account.id, account } }) 32 | // }) 33 | // }, 34 | // mapIdToAccount: (state, { payload: { accountId, account }}) => { 35 | // state.accountById[accountId] = account 36 | // }, 37 | }, 38 | 39 | extraReducers: builder => { 40 | builder 41 | .addCase(createPayee.fulfilled, (state, { payload }) => { 42 | payeesAdapter.addOne(state, payload) 43 | }) 44 | .addCase(fetchPayees.fulfilled, (state, { payload }) => { 45 | payeesAdapter.setAll(state, payload) 46 | }) 47 | }, 48 | }) 49 | 50 | // export const { setAccounts, mapIdToAccount } = payeesSlice.actions 51 | export const payeesSelectors = payeesAdapter.getSelectors(state => state.payees) 52 | export const selectPayeesMap = createSelector(payeesSelectors.selectAll, payees => 53 | Object.values(payees).reduce((acc, payee) => { 54 | acc[payee.id] = payee.name 55 | return acc 56 | }, {}), 57 | ) 58 | 59 | export default payeesSlice.reducer 60 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/Users.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' 2 | import api from '../../api' 3 | 4 | export const login = createAsyncThunk('user/login', async ({ email, password }) => { 5 | return await api.login(email, password) 6 | }) 7 | 8 | export const updateUser = createAsyncThunk('user/update', async ({ email, password, currentPassword }) => { 9 | return await api.updateUser(email, password, currentPassword) 10 | }) 11 | 12 | const userSlice = createSlice({ 13 | name: 'users', 14 | initialState: { 15 | user: {}, 16 | initComplete: false, 17 | }, 18 | reducers: { 19 | logout: state => { 20 | state = { 21 | user: {}, 22 | initComplete: false, 23 | } 24 | }, 25 | 26 | setUser: (state, action) => { 27 | state.user = action.payload 28 | }, 29 | 30 | setInitComplete: (state, action) => { 31 | state.initComplete = action.payload 32 | }, 33 | }, 34 | extraReducers: { 35 | [login.fulfilled]: (state, action) => { 36 | state.user = action.payload 37 | }, 38 | 39 | [updateUser.fulfilled]: (state, action) => { 40 | state.user = action.payload 41 | }, 42 | 43 | [login.rejected]: state => { 44 | throw new Error() 45 | }, 46 | }, 47 | }) 48 | 49 | export const { logout, setUser, setInitComplete } = userSlice.actions 50 | 51 | export default userSlice.reducer 52 | -------------------------------------------------------------------------------- /frontend/src/redux/slices/index.js: -------------------------------------------------------------------------------- 1 | import users from './Users' 2 | import budgets from './Budgets' 3 | import accounts from './Accounts' 4 | import categories from './Categories' 5 | import categoryGroups from './CategoryGroups' 6 | import payees from './Payees' 7 | import { budgetMonths, categoryMonths } from './BudgetMonths' 8 | import app from './App' 9 | 10 | export const reducers = { 11 | users, 12 | budgets, 13 | accounts, 14 | categories, 15 | categoryGroups, 16 | payees, 17 | budgetMonths, 18 | categoryMonths, 19 | app, 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { reducers } from './slices' 3 | 4 | export const store = configureStore({ 5 | reducer: reducers, 6 | devTools: process.env.NODE_ENV !== 'production', 7 | // preloadedState: initialState, 8 | }) 9 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/utils/Currency.js: -------------------------------------------------------------------------------- 1 | import { dinero, toFormat, isPositive, isNegative, multiply, toUnit } from 'dinero.js' 2 | import { USD } from '@dinero.js/currencies' 3 | import * as currencies from '@dinero.js/currencies' 4 | 5 | export class Currency { 6 | static CURRENCY = 'USD' 7 | 8 | static getCurrency() { 9 | return Currency.CURRENCY 10 | } 11 | 12 | static setCurrency(currency) { 13 | Currency.CURRENCY = currency 14 | } 15 | 16 | static getAvailableCurrencies() { 17 | return Object.keys(currencies) 18 | } 19 | 20 | static inputToDinero(amount) { 21 | return dinero({ 22 | amount: parseInt(`${parseFloat(amount).toFixed(2)}`.replace('.', '')), 23 | currency: currencies[Currency.getCurrency()], 24 | }) 25 | } 26 | 27 | static inputToValue(amount) { 28 | return dineroToValue(Currency.inputToDinero(amount)) 29 | } 30 | 31 | static intlFormat(dineroObject, locale, options = {}) { 32 | if (!dineroObject) { 33 | dineroObject = dinero({ amount: 0, currency: currencies[Currency.getCurrency()] }) 34 | } 35 | function transformer({ amount, currency }) { 36 | return amount.toLocaleString(locale, { 37 | ...options, 38 | style: 'currency', 39 | currency: currency.code, 40 | }) 41 | } 42 | 43 | return toFormat(dineroObject, transformer) 44 | } 45 | 46 | static valueToDinero(value) { 47 | return dinero({ amount: value, currency: currencies[Currency.getCurrency()] }) 48 | } 49 | } 50 | 51 | export function valueToUnit(value) { 52 | return toUnit(Currency.valueToDinero(value), { digits: 2 }) 53 | } 54 | 55 | export function dineroToValue(dineroObj) { 56 | return dineroObj.toJSON().amount 57 | } 58 | 59 | export function getBalanceColor(amount, theme) { 60 | if (isNegative(amount)) { 61 | return theme.palette.error.main 62 | } 63 | 64 | return theme.palette.success.main 65 | } 66 | 67 | export class ToAPI { 68 | static transformTransaction(transaction) { 69 | return { 70 | ...transaction, 71 | amount: transaction.amount.toJSON().amount, 72 | } 73 | } 74 | } 75 | 76 | export class FromAPI { 77 | static transformBudget(budget) { 78 | budget.toBeBudgeted = dinero({ amount: budget.toBeBudgeted, currency: currencies[Currency.getCurrency()] }) 79 | budget.accounts = budget.accounts.map(account => FromAPI.transformAccount(account)) 80 | 81 | return budget 82 | } 83 | 84 | static transformAccount(account) { 85 | return { 86 | ...account, 87 | balance: dinero({ amount: account.balance, currency: currencies[Currency.getCurrency()] }), 88 | cleared: dinero({ amount: account.cleared, currency: currencies[Currency.getCurrency()] }), 89 | uncleared: dinero({ amount: account.uncleared, currency: currencies[Currency.getCurrency()] }), 90 | } 91 | } 92 | 93 | static transformTransaction(transaction) { 94 | const amount = dinero({ amount: transaction.amount, currency: currencies[Currency.getCurrency()] }) 95 | return { 96 | ...transaction, 97 | amount: amount, 98 | inflow: isPositive(amount) ? amount : dinero({ amount: 0, currency: currencies[Currency.getCurrency()] }), 99 | outflow: isNegative(amount) 100 | ? multiply(amount, -1) 101 | : dinero({ amount: 0, currency: currencies[Currency.getCurrency()] }), 102 | } 103 | } 104 | 105 | static transformBudgetMonth(budgetMonth) { 106 | return { 107 | ...budgetMonth, 108 | income: dinero({ amount: budgetMonth.income, currency: currencies[Currency.getCurrency()] }), 109 | activity: dinero({ amount: budgetMonth.activity, currency: currencies[Currency.getCurrency()] }), 110 | budgeted: dinero({ amount: budgetMonth.budgeted, currency: currencies[Currency.getCurrency()] }), 111 | underfunded: dinero({ amount: budgetMonth.underfunded, currency: currencies[Currency.getCurrency()] }), 112 | } 113 | } 114 | 115 | static transformCategoryMonth(categoryMonth) { 116 | return { 117 | ...categoryMonth, 118 | budgeted: dinero({ amount: categoryMonth.budgeted, currency: currencies[Currency.getCurrency()] }), 119 | activity: dinero({ amount: categoryMonth.activity, currency: currencies[Currency.getCurrency()] }), 120 | balance: dinero({ amount: categoryMonth.balance, currency: currencies[Currency.getCurrency()] }), 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /frontend/src/utils/Date.js: -------------------------------------------------------------------------------- 1 | export function getDateFromString(date) { 2 | let [year, month, day] = date.split('-').map(val => parseInt(val)) 3 | 4 | // Month is zero-indexed in JS... 5 | month = month - 1 6 | if (month < 0) { 7 | month = 11 8 | year = year - 1 9 | } 10 | 11 | return new Date(year, month, day) 12 | } 13 | 14 | export function formatMonthFromDateString(date) { 15 | let tempDate = new Date(date) 16 | tempDate.setHours(0) 17 | tempDate.setDate(1) 18 | return tempDate.toISOString().split('T')[0] 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/utils/Export.js: -------------------------------------------------------------------------------- 1 | import { CsvBuilder } from 'filefy' 2 | 3 | export const ExportCsv = (columns, data = [], filename = 'data', delimiter = ',') => { 4 | try { 5 | let finalData = data 6 | // Grab first item for data array, make sure it is also an array. 7 | // If it is an object, 'flatten' it into an array of strings. 8 | if (data.length && !Array.isArray(data[0])) { 9 | if (typeof data[0] === 'object') { 10 | // Turn data into an array of string arrays, without the `tableData` prop 11 | finalData = data.map(row => 12 | columns.map(col => 13 | col.exportTransformer ? col.exportTransformer(row.original[col.accessor]) : row.original[col.accessor], 14 | ), 15 | ) 16 | } 17 | } 18 | const builder = new CsvBuilder(filename + '.csv') 19 | builder 20 | .setDelimeter(delimiter) 21 | .setColumns(columns.map(col => col.title)) 22 | .addRows(Array.from(finalData)) 23 | .exportFile() 24 | } catch (err) { 25 | console.error(`error in ExportCsv : ${err}`) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/utils/Store.js: -------------------------------------------------------------------------------- 1 | import { createSelectorCreator, defaultMemoize } from 'reselect' 2 | import _ from 'lodash' 3 | 4 | // create a "selector creator" that uses lodash.isequal instead of === 5 | export const createDeepEqualSelector = createSelectorCreator(defaultMemoize, _.isEqual) 6 | -------------------------------------------------------------------------------- /frontend/src/utils/Table.js: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react' 2 | import AddBox from '@mui/icons-material/AddBox' 3 | import ArrowDownward from '@mui/icons-material/ArrowDownward' 4 | import Check from '@mui/icons-material/Check' 5 | import ChevronLeft from '@mui/icons-material/ChevronLeft' 6 | import ChevronRight from '@mui/icons-material/ChevronRight' 7 | import Clear from '@mui/icons-material/Clear' 8 | import DeleteOutline from '@mui/icons-material/DeleteOutline' 9 | import Edit from '@mui/icons-material/Edit' 10 | import FilterList from '@mui/icons-material/FilterList' 11 | import FirstPage from '@mui/icons-material/FirstPage' 12 | import LastPage from '@mui/icons-material/LastPage' 13 | import Remove from '@mui/icons-material/Remove' 14 | import SaveAlt from '@mui/icons-material/SaveAlt' 15 | import Search from '@mui/icons-material/Search' 16 | import ViewColumn from '@mui/icons-material/ViewColumn' 17 | 18 | export const TableIcons = { 19 | Add: forwardRef((props, ref) => ), 20 | Check: forwardRef((props, ref) => ), 21 | Clear: forwardRef((props, ref) => ), 22 | Delete: forwardRef((props, ref) => ), 23 | DetailPanel: forwardRef((props, ref) => ), 24 | Edit: forwardRef((props, ref) => ), 25 | Export: forwardRef((props, ref) => ), 26 | Filter: forwardRef((props, ref) => ), 27 | FirstPage: forwardRef((props, ref) => ), 28 | LastPage: forwardRef((props, ref) => ), 29 | NextPage: forwardRef((props, ref) => ), 30 | PreviousPage: forwardRef((props, ref) => ), 31 | ResetSearch: forwardRef((props, ref) => ), 32 | Search: forwardRef((props, ref) => ), 33 | SortArrow: forwardRef((props, ref) => ), 34 | ThirdStateCheck: forwardRef((props, ref) => ), 35 | ViewColumn: forwardRef((props, ref) => ), 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/utils/useInterval.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export default function (callback, delay) { 4 | const savedCallback = useRef() 5 | // Remember the last callbac 6 | useEffect(() => { 7 | savedCallback.current = callback 8 | }, [callback]) 9 | 10 | // Set up the interval 11 | useEffect(() => { 12 | function tick() { 13 | savedCallback.current() 14 | } 15 | if (delay !== null) { 16 | const id = setInterval(tick, delay) 17 | return () => { 18 | clearInterval(id) 19 | } 20 | } 21 | }, [callback, delay]) 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/wdyr.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | if (process.env.NODE_ENV === 'development') { 4 | const whyDidYouRender = require('@welldone-software/why-did-you-render'); 5 | whyDidYouRender(React, { 6 | trackAllPureComponents: true, 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /images/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/images/account.png -------------------------------------------------------------------------------- /images/budget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linuxserver/budge/311c038665f0c1e1acc60fe202399fa7a50d9469/images/budget.png -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | const NodeGeocoder = require('node-geocoder') 3 | const geocoder = NodeGeocoder({ 4 | provider: 'openstreetmap' 5 | }) 6 | 7 | 8 | const res1 = await geocoder.geocode('2000 sageleaf ct'); 9 | console.log(res1) 10 | 11 | // // OpenCage advanced usage example 12 | // const res = await geocoder.geocode({ 13 | // address: '29 champs elysée', 14 | // countryCode: 'fr', 15 | // minConfidence: 0.5, 16 | // limit: 5 17 | // }); 18 | 19 | // Reverse example 20 | 21 | const res = await geocoder.reverse({ lat: 35.6195209, lon: -78.6273667 }); 22 | console.log(res) 23 | })() 24 | -------------------------------------------------------------------------------- /ynab/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ynab_import", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "import.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@dinero.js/currencies": "^2.0.0-alpha.8", 14 | "csv-parse": "^4.16.3", 15 | "dinero.js": "^2.0.0-alpha.8", 16 | "got": "^11.8.3", 17 | "prompt": "^1.2.0" 18 | }, 19 | "type": "module" 20 | } 21 | --------------------------------------------------------------------------------