From 5154f63f3409a063dc8a306904dab8a541515cbe Mon Sep 17 00:00:00 2001 From: Alex Phillips Date: Sat, 11 Dec 2021 19:52:07 -0500 Subject: [PATCH 01/11] refactored completely to use typeorm data mapper instead of active record --- backend/src/controllers/AccountsController.ts | 48 +- backend/src/controllers/BudgetsController.ts | 23 +- .../src/controllers/CategoriesController.ts | 42 +- backend/src/controllers/PayeesController.ts | 15 +- backend/src/controllers/RootController.ts | 5 +- .../src/controllers/TransactionsController.ts | 41 +- backend/src/controllers/UsersController.ts | 17 +- backend/src/entities/Account.ts | 57 +- backend/src/entities/Base.ts | 38 +- backend/src/entities/Budget.ts | 56 +- backend/src/entities/BudgetMonth.ts | 57 +- backend/src/entities/Category.ts | 6 +- backend/src/entities/CategoryGroup.ts | 2 +- backend/src/entities/CategoryMonth.ts | 147 +---- backend/src/entities/Payee.ts | 2 +- backend/src/entities/Transaction.ts | 518 ++---------------- backend/src/entities/User.ts | 2 +- backend/src/middleware/authentication.ts | 3 +- backend/src/repositories/BudgetMonths.ts | 44 ++ backend/src/repositories/CategoryMonths.ts | 41 ++ .../src/repositories/TransactionRepository.ts | 22 - backend/src/repositories/UserRepository.ts | 12 - backend/src/repositories/index.ts | 6 - backend/src/server.ts | 5 + backend/src/subscribers/AccountSubscriber.ts | 74 +++ backend/src/subscribers/BudgetSubscriber.ts | 66 +++ .../subscribers/CategoryMonthSubscriber.ts | 132 +++++ .../src/subscribers/TransactionSubscriber.ts | 500 +++++++++++++++++ ynab/import.js | 142 ++--- 29 files changed, 1120 insertions(+), 1003 deletions(-) create mode 100644 backend/src/repositories/BudgetMonths.ts create mode 100644 backend/src/repositories/CategoryMonths.ts delete mode 100644 backend/src/repositories/TransactionRepository.ts delete mode 100644 backend/src/repositories/UserRepository.ts delete mode 100644 backend/src/repositories/index.ts create mode 100644 backend/src/subscribers/AccountSubscriber.ts create mode 100644 backend/src/subscribers/BudgetSubscriber.ts create mode 100644 backend/src/subscribers/CategoryMonthSubscriber.ts create mode 100644 backend/src/subscribers/TransactionSubscriber.ts diff --git a/backend/src/controllers/AccountsController.ts b/backend/src/controllers/AccountsController.ts index 3ff1d1a..2e0fae7 100644 --- a/backend/src/controllers/AccountsController.ts +++ b/backend/src/controllers/AccountsController.ts @@ -9,6 +9,7 @@ import { Transaction, TransactionStatus } from '../entities/Transaction' import { Category } from '../entities/Category' import { USD } from '@dinero.js/currencies' import { dinero, isZero, subtract } from 'dinero.js' +import { getRepository } from 'typeorm' @Tags('Accounts') @Route('budgets/{budgetId}/accounts') @@ -39,7 +40,7 @@ export class AccountsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne({ id: budgetId, userId: request.user.id }) + const budget = await getRepository(Budget).findOne({ id: budgetId, userId: request.user.id }) if (!budget) { this.setStatus(404) return { @@ -47,12 +48,12 @@ export class AccountsController extends Controller { } } - const account: Account = Account.create({ + const account: Account = getRepository(Account).create({ ...requestBody, balance: dinero({ amount: requestBody.balance, currency: USD }), budgetId, }) - await account.save() + await getRepository(Account).save(account) // Create a transaction for the starting balance of the account if (requestBody.balance !== 0) { @@ -64,13 +65,14 @@ export class AccountsController extends Controller { amount = amount * -1 // Inverse balance for CCs break case AccountTypes.Bank: - const inflowCategory = await Category.findOne({ budgetId: account.budgetId, inflow: true }) + const inflowCategory = await getRepository(Category).findOne({ budgetId: account.budgetId, inflow: true }) categoryId = inflowCategory.id break } - const startingBalancePayee = await Payee.findOne({ budgetId, name: 'Starting Balance', internal: true }) - const startingBalanceTransaction = Transaction.create({ + const startingBalancePayee = await getRepository(Payee).findOne({ budgetId, name: 'Starting Balance', internal: true }) + console.log('creating starting balance') + const startingBalanceTransaction = getRepository(Transaction).create({ budgetId, accountId: account.id, payeeId: startingBalancePayee.id, @@ -80,15 +82,15 @@ export class AccountsController extends Controller { memo: 'Starting Balance', status: TransactionStatus.Reconciled, }) - await startingBalanceTransaction.save() + await getRepository(Transaction).save(startingBalanceTransaction) } // Reload account to get the new balanace after the 'initial' transaction was created - await account.reload() + // await account.reload() return { message: 'success', - data: await account.toResponseModel(), + data: await (await getRepository(Account).findOne(account.id)).toResponseModel(), } } catch (err) { console.log(err) @@ -124,7 +126,7 @@ export class AccountsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -132,7 +134,7 @@ export class AccountsController extends Controller { } } - const account = await Account.findOne(id) + let account = await getRepository(Account).findOne(id) if (!account) { this.setStatus(404) return { @@ -142,16 +144,16 @@ export class AccountsController extends Controller { if (requestBody.name !== account.name) { account.name = requestBody.name - await account.save() + await getRepository(Account).update(account.id, account) } if (requestBody.balance) { // Reconcile the account const difference = subtract(dinero({ amount: requestBody.balance, currency: USD }), account.cleared) if (!isZero(difference)) { - const reconciliationPayee = await Payee.findOne({ budgetId, name: 'Reconciliation Balance Adjustment', internal: true }) - const inflowCategory = await Category.findOne({ budgetId: account.budgetId, inflow: true }) - const startingBalanceTransaction = Transaction.create({ + const reconciliationPayee = await getRepository(Payee).findOne({ budgetId, name: 'Reconciliation Balance Adjustment', internal: true }) + const inflowCategory = await getRepository(Category).findOne({ budgetId: account.budgetId, inflow: true }) + const startingBalanceTransaction = getRepository(Transaction).create({ budgetId, accountId: account.id, payeeId: reconciliationPayee.id, @@ -161,17 +163,17 @@ export class AccountsController extends Controller { memo: 'Reconciliation Transaction', status: TransactionStatus.Reconciled, }) - await startingBalanceTransaction.save() + await getRepository(Transaction).save(startingBalanceTransaction) } - const clearedTransactions = await Transaction.find({ accountId: account.id, status: TransactionStatus.Cleared }) + const clearedTransactions = await getRepository(Transaction).find({ accountId: account.id, status: TransactionStatus.Cleared }) console.log(clearedTransactions) for (const transaction of clearedTransactions) { transaction.status = TransactionStatus.Reconciled - await transaction.save() + await getRepository(Transaction).save(transaction) } - await account.reload() + account = await getRepository(Account).findOne(account.id) } return { @@ -211,7 +213,7 @@ export class AccountsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne({ id: budgetId, userId: request.user.id }) + const budget = await getRepository(Budget).findOne({ id: budgetId, userId: request.user.id }) if (!budget) { this.setStatus(404) return { @@ -219,7 +221,7 @@ export class AccountsController extends Controller { } } - const accounts = await Account.find({ where: { budgetId } }) + const accounts = await getRepository(Account).find({ where: { budgetId } }) return { message: 'success', @@ -256,7 +258,7 @@ export class AccountsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne({ id: budgetId, userId: request.user.id }) + const budget = await getRepository(Budget).findOne({ id: budgetId, userId: request.user.id }) if (!budget) { this.setStatus(404) return { @@ -264,7 +266,7 @@ export class AccountsController extends Controller { } } - const account = await Account.findOne(accountId) + const account = await getRepository(Account).findOne(accountId) return { message: 'success', diff --git a/backend/src/controllers/BudgetsController.ts b/backend/src/controllers/BudgetsController.ts index e636b9f..f10af7a 100644 --- a/backend/src/controllers/BudgetsController.ts +++ b/backend/src/controllers/BudgetsController.ts @@ -6,6 +6,7 @@ import { BudgetRequest, BudgetResponse, BudgetsResponse } from '../models/Budget import { AccountTypes } from '../entities/Account' import { BudgetMonth } from '../entities/BudgetMonth' import { BudgetMonthsResponse, BudgetMonthWithCategoriesResponse } from '../models/BudgetMonth' +import { getRepository } from 'typeorm' @Tags('Budgets') @Route('budgets') @@ -51,7 +52,7 @@ export class BudgetsController extends Controller { }) public async getBudgets(@Request() request: ExpressRequest): Promise { try { - const budgets = await Budget.find({ where: { userId: request.user.id }, relations: ['accounts'] }) + const budgets = await getRepository(Budget).find({ where: { userId: request.user.id }, relations: ['accounts'] }) return { message: 'success', data: await Promise.all(budgets.map(budget => budget.toResponseModel())), @@ -83,9 +84,9 @@ export class BudgetsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget: Budget = Budget.create({ ...requestBody }) + const budget: Budget = getRepository(Budget).create({ ...requestBody }) budget.user = request.user - await budget.save() + await getRepository(Budget).save(budget) return { message: 'success', @@ -118,7 +119,7 @@ export class BudgetsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - let budget: Budget = await Budget.findOne(id) + let budget: Budget = await getRepository(Budget).findOne(id) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -157,7 +158,7 @@ export class BudgetsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - let budget: Budget = await Budget.findOne(id) + let budget: Budget = await getRepository(Budget).findOne(id) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) @@ -166,7 +167,7 @@ export class BudgetsController extends Controller { } } - budget = await Budget.merge(budget, { ...requestBody }) + budget = await getRepository(Budget).merge(budget, { ...requestBody }) return { message: 'success', @@ -202,7 +203,7 @@ export class BudgetsController extends Controller { @Path() budgetId: string, @Request() request: ExpressRequest, ): Promise { - let budget: Budget = await Budget.findOne(budgetId) + let budget: Budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) @@ -211,7 +212,7 @@ export class BudgetsController extends Controller { } } - const budgetMonths = await BudgetMonth.find({ budgetId }) + const budgetMonths = await getRepository(BudgetMonth).find({ budgetId }) return { message: 'success', @@ -255,7 +256,7 @@ export class BudgetsController extends Controller { @Path() month: string, @Request() request: ExpressRequest, ): Promise { - let budget: Budget = await Budget.findOne(budgetId) + let budget: Budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) @@ -264,11 +265,11 @@ export class BudgetsController extends Controller { } } - let budgetMonth = await BudgetMonth.findOne({ budgetId, month }) + let budgetMonth = await getRepository(BudgetMonth).findOne({ budgetId, month }) if (!budgetMonth) { // If we don't have a budget month, then no transactions were created against that month, // so send down an 'empty' budget month for the UI to work with - budgetMonth = BudgetMonth.create({ + budgetMonth = getRepository(BudgetMonth).create({ budgetId, month, }) diff --git a/backend/src/controllers/CategoriesController.ts b/backend/src/controllers/CategoriesController.ts index 6471788..0e5b13c 100644 --- a/backend/src/controllers/CategoriesController.ts +++ b/backend/src/controllers/CategoriesController.ts @@ -11,6 +11,8 @@ import { CategoryMonthRequest, CategoryMonthResponse, CategoryMonthsResponse } f import { CategoryMonth } from '../entities/CategoryMonth' import { USD } from '@dinero.js/currencies' import { dinero } from 'dinero.js' +import { getCustomRepository, getRepository } from 'typeorm' +import { CategoryMonths } from '../repositories/CategoryMonths' @Tags('Categories') @Route('budgets/{budgetId}/categories') @@ -40,7 +42,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -48,7 +50,7 @@ export class CategoriesController extends Controller { } } - const categoryGroups: CategoryGroup[] = await CategoryGroup.find({ where: { budgetId } }) + const categoryGroups: CategoryGroup[] = await getRepository(CategoryGroup).find({ where: { budgetId } }) return { message: 'success', @@ -83,7 +85,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -91,11 +93,11 @@ export class CategoriesController extends Controller { } } - const categoryGroup: CategoryGroup = CategoryGroup.create({ + const categoryGroup: CategoryGroup = getRepository(CategoryGroup).create({ ...requestBody, budgetId, }) - await categoryGroup.save() + await getRepository(CategoryGroup).save(categoryGroup) return { message: 'success', @@ -131,7 +133,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -139,9 +141,9 @@ export class CategoriesController extends Controller { } } - const categoryGroup = await CategoryGroup.findOne(id) + const categoryGroup = await getRepository(CategoryGroup).findOne(id) categoryGroup.name = requestBody.name - await categoryGroup.save() + await getRepository(CategoryGroup).update(categoryGroup.id, categoryGroup) return { message: 'success', @@ -176,7 +178,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -184,11 +186,11 @@ export class CategoriesController extends Controller { } } - const category: Category = Category.create({ + const category: Category = getRepository(Category).create({ ...requestBody, budgetId, }) - await category.save() + await getRepository(Category).save(category) return { message: 'success', @@ -224,7 +226,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -232,7 +234,7 @@ export class CategoriesController extends Controller { } } - const category = await Category.findOne(id, { relations: ['categoryGroup'] }) + const category = await getRepository(Category).findOne(id, { relations: ['categoryGroup'] }) category.name = requestBody.name if (category.categoryGroupId !== requestBody.categoryGroupId) { @@ -240,13 +242,14 @@ export class CategoriesController extends Controller { category.categoryGroupId = requestBody.categoryGroupId } - await category.save() + await getRepository(Category).update(category.id, category) return { message: 'success', data: await category.toResponseModel(), } } catch (err) { + console.log(err) return { message: err.message } } } @@ -277,7 +280,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -285,8 +288,9 @@ export class CategoriesController extends Controller { } } - const categoryMonth = await CategoryMonth.findOrCreate(budgetId, categoryId, month) - await categoryMonth.update({ budgeted: dinero({ amount: requestBody.budgeted, currency: USD }) }) + const categoryMonth = await getCustomRepository(CategoryMonths).findOrCreate(budgetId, categoryId, month) + categoryMonth.update({ budgeted: dinero({ amount: requestBody.budgeted, currency: USD }) }) + await getRepository(CategoryMonth).save(categoryMonth) return { message: 'success', @@ -324,7 +328,7 @@ export class CategoriesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -332,7 +336,7 @@ export class CategoriesController extends Controller { } } - const categoryMonths = await CategoryMonth.find({ categoryId }) + const categoryMonths = await getRepository(CategoryMonth).find({ categoryId }) return { message: 'success', diff --git a/backend/src/controllers/PayeesController.ts b/backend/src/controllers/PayeesController.ts index 898c07f..35e98cd 100644 --- a/backend/src/controllers/PayeesController.ts +++ b/backend/src/controllers/PayeesController.ts @@ -4,6 +4,7 @@ import { ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { PayeeRequest, PayeeResponse, PayeesResponse } from '../models/Payee' import { Payee } from '../entities/Payee' +import { getRepository } from 'typeorm' @Tags('Payees') @Route('budgets/{budgetId}/payees') @@ -30,7 +31,7 @@ export class PayeesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -38,11 +39,11 @@ export class PayeesController extends Controller { } } - const payee = Payee.create({ + const payee = getRepository(Payee).create({ ...requestBody, budgetId, }) - await payee.save() + await getRepository(Payee).save(payee) return { message: 'success', @@ -76,7 +77,7 @@ export class PayeesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -84,7 +85,7 @@ export class PayeesController extends Controller { } } - const payees = await Payee.find({ where: { budgetId } }) + const payees = await getRepository(Payee).find({ where: { budgetId } }) return { message: 'success', @@ -117,7 +118,7 @@ export class PayeesController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -125,7 +126,7 @@ export class PayeesController extends Controller { } } - const payee = await Payee.findOne(payeeId) + const payee = await getRepository(Payee).findOne(payeeId) return { message: 'success', diff --git a/backend/src/controllers/RootController.ts b/backend/src/controllers/RootController.ts index e27eb55..266ccfa 100644 --- a/backend/src/controllers/RootController.ts +++ b/backend/src/controllers/RootController.ts @@ -3,6 +3,7 @@ import { Get, Security, Route, Post, Body, Controller, Tags, Example, Request } import { User } from '../entities' import { LoginRequest, ExpressRequest } from './requests' import { ErrorResponse } from './responses' +import { getRepository } from 'typeorm' @Route() export class RootController extends Controller { @@ -23,7 +24,7 @@ export class RootController extends Controller { }) public async login(@Body() requestBody: LoginRequest, @Request() request: ExpressRequest): Promise { const { email, password } = requestBody - const user: User = await User.findOne({ email }) + const user: User = await getRepository(User).findOne({ email }) if (!user) { this.setStatus(403) @@ -75,7 +76,7 @@ export class RootController extends Controller { }) public async getMe(@Request() request: ExpressRequest): Promise { try { - const user: User = await User.findOne({ email: request.user.email }) + const user: User = await getRepository(User).findOne({ email: request.user.email }) return { data: await user.toResponseModel(), diff --git a/backend/src/controllers/TransactionsController.ts b/backend/src/controllers/TransactionsController.ts index d389713..9f48192 100644 --- a/backend/src/controllers/TransactionsController.ts +++ b/backend/src/controllers/TransactionsController.ts @@ -7,6 +7,7 @@ import { Transaction, TransactionStatus } from '../entities/Transaction' import { TransactionRequest, TransactionResponse, TransactionsResponse } from '../models/Transaction' import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' +import { getCustomRepository, getManager, getRepository } from 'typeorm' @Tags('Budgets') @Route('budgets/{budgetId}') @@ -37,7 +38,7 @@ export class TransactionsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -45,12 +46,17 @@ export class TransactionsController extends Controller { } } - const transaction = await Transaction.createNew({ - budgetId, - ...requestBody, - amount: dinero({ amount: requestBody.amount, currency: USD }), - date: new Date(requestBody.date), - handleTransfers: true, + const transaction = await getManager().transaction(async transactionalEntityManager => { + const transaction = transactionalEntityManager.getRepository(Transaction).create({ + budgetId, + ...requestBody, + amount: dinero({ amount: requestBody.amount, currency: USD }), + date: new Date(requestBody.date), + }) + transaction.setHandleTransfers(true) + await transactionalEntityManager.getRepository(Transaction).save(transaction) + + return transaction }) return { @@ -90,7 +96,7 @@ export class TransactionsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -101,13 +107,14 @@ export class TransactionsController extends Controller { // Load in original transaction to check if the amount has been altered // and updated the category month accordingly // @TODO: remove relation to test db transactions - const transaction = await Transaction.findOne(transactionId, { relations: ['account'] }) - await transaction.update({ + const transaction = await getRepository(Transaction).findOne(transactionId, { relations: ['account'] }) + transaction.update({ ...requestBody, amount: dinero({ amount: requestBody.amount, currency: USD }), date: new Date(requestBody.date), // @TODO: this is hacky and I don't like it, but the update keeps date as a string and breaks the sanitize function - handleTransfers: true, }) + transaction.setHandleTransfers(true) + await getRepository(Transaction).update(transaction.id, transaction) return { message: 'success', @@ -133,7 +140,7 @@ export class TransactionsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -141,9 +148,9 @@ export class TransactionsController extends Controller { } } - const transaction = await Transaction.findOne(transactionId) - transaction.handleTransfers = true - await transaction.remove() + const transaction = await getRepository(Transaction).findOne(transactionId) + transaction.setHandleTransfers(true) + await getRepository(Transaction).remove(transaction) return { message: 'success', @@ -182,7 +189,7 @@ export class TransactionsController extends Controller { @Request() request: ExpressRequest, ): Promise { try { - const budget = await Budget.findOne(budgetId) + const budget = await getRepository(Budget).findOne(budgetId) if (!budget || budget.userId !== request.user.id) { this.setStatus(404) return { @@ -190,7 +197,7 @@ export class TransactionsController extends Controller { } } - const account = await Account.findOne(accountId, { relations: ['transactions'] }) + const account = await getRepository(Account).findOne(accountId, { relations: ['transactions'] }) return { message: 'success', diff --git a/backend/src/controllers/UsersController.ts b/backend/src/controllers/UsersController.ts index 81a8183..2b700c3 100644 --- a/backend/src/controllers/UsersController.ts +++ b/backend/src/controllers/UsersController.ts @@ -3,6 +3,7 @@ import { Get, Route, Path, Security, Post, Patch, Body, Controller, Tags, Reques import { User } from '../entities' import { ExpressRequest, UserCreateRequest, UserUpdateRequest } from './requests' import { ErrorResponse } from './responses' +import { getManager, getRepository } from 'typeorm' @Tags('Users') @Route('users') @@ -23,15 +24,19 @@ export class UsersController extends Controller { public async createUser(@Body() requestBody: UserCreateRequest): Promise { const { email } = requestBody - const emailCheck: User = await User.findOne({ email }) + const emailCheck: User = await getRepository(User).findOne({ email }) if (emailCheck) { this.setStatus(400) return { message: 'Email already exists' } } try { - const newUser: User = User.create({ ...requestBody }) - await newUser.save() + const newUser = await getManager().transaction(async transactionalEntityManager => { + const newUser: User = transactionalEntityManager.getRepository(User).create({ ...requestBody }) + await transactionalEntityManager.getRepository(User).save(newUser) + return newUser + }); + return { message: 'success', data: await newUser.toResponseModel(), @@ -60,7 +65,7 @@ export class UsersController extends Controller { }) public async getUserByEmail(@Path() email: string): Promise { try { - const user: User = await User.findOne({ email }) + const user: User = await getRepository(User).findOne({ email }) return { data: await user.toResponseModel(), @@ -119,8 +124,8 @@ export class UsersController extends Controller { delete requestBody.currentPassword try { - let user: User = await User.findOne(request.user.id) - user = await User.merge(user, { ...requestBody }) + let user: User = await getRepository(User).findOne(request.user.id) + user = await getRepository(User).merge(user, { ...requestBody }) return { data: await user.toResponseModel(), message: 'success', diff --git a/backend/src/entities/Account.ts b/backend/src/entities/Account.ts index 8f418ad..45be271 100644 --- a/backend/src/entities/Account.ts +++ b/backend/src/entities/Account.ts @@ -4,21 +4,16 @@ import { OneToOne, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, OneToMany, JoinColumn, - AfterInsert, - BeforeUpdate, } from 'typeorm' import { Budget } from './Budget' import { Transaction } from './Transaction' import { Payee } from './Payee' -import { Category } from './Category' -import { CategoryGroup, CreditCardGroupName } from './CategoryGroup' import { Dinero } from '@dinero.js/core' -import { add, dinero } from 'dinero.js' +import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' @@ -29,7 +24,7 @@ export enum AccountTypes { } @Entity('accounts') -export class Account extends BaseEntity { +export class Account { @PrimaryGeneratedColumn('uuid') id: string @@ -91,54 +86,6 @@ export class Account extends BaseEntity { @JoinColumn() transferPayee: Promise - @AfterInsert() - private async createCreditCardCategory(): Promise { - if (this.type === AccountTypes.CreditCard) { - // Create CC payments category if it doesn't exist - const ccGroup = - (await CategoryGroup.findOne({ - budgetId: this.budgetId, - name: CreditCardGroupName, - })) || - CategoryGroup.create({ - budgetId: this.budgetId, - name: CreditCardGroupName, - locked: true, - }) - - await ccGroup.save() - - // Create payment tracking category - const paymentCategory = Category.create({ - budgetId: this.budgetId, - categoryGroupId: ccGroup.id, - trackingAccountId: this.id, - name: this.name, - locked: true, - }) - await paymentCategory.save() - } - } - - @AfterInsert() - private async createAccountPayee() { - const payee = Payee.create({ - budgetId: this.budgetId, - name: `Transfer : ${this.name}`, - transferAccountId: this.id, - }) - - // @TODO: I wish there was a better way around this - await payee.save() - this.transferPayeeId = payee.id - await this.save() - } - - @BeforeUpdate() - private calculateBalance(): void { - this.balance = add(this.cleared, this.uncleared) - } - public async toResponseModel(): Promise { return { id: this.id, diff --git a/backend/src/entities/Base.ts b/backend/src/entities/Base.ts index f7b3798..c714e50 100644 --- a/backend/src/entities/Base.ts +++ b/backend/src/entities/Base.ts @@ -1,37 +1,3 @@ -import { BudgetModel } from '../models/Budget' -import { - Entity, - PrimaryGeneratedColumn, - Column, - BaseEntity, - CreateDateColumn, - ManyToOne, - OneToMany, - AfterInsert, - PrimaryColumn, - BeforeInsert, -} from 'typeorm' -import { User } from './User' -import { Account } from './Account' -import { CategoryGroup } from './CategoryGroup' -import { Category } from './Category' -import { BudgetMonth } from './BudgetMonth' -import { Transaction } from './Transaction' -import { getMonthString, getMonthStringFromNow } from '../utils' -import { Payee } from './Payee' -import { Dinero } from '@dinero.js/core' -import { dinero } from 'dinero.js' -import { USD } from '@dinero.js/currencies' -import { CurrencyDBTransformer } from '../models/Currency' - -export class Base extends BaseEntity { - @PrimaryGeneratedColumn('uuid') - id: string - - // @BeforeInsert() - // private setId(): void { - // if (!this.id) { - // this.id = uuidv4() - // } - // } +export class Base { + // eventsEnabled: boolean = true } diff --git a/backend/src/entities/Budget.ts b/backend/src/entities/Budget.ts index c3895b0..153b820 100644 --- a/backend/src/entities/Budget.ts +++ b/backend/src/entities/Budget.ts @@ -3,13 +3,10 @@ import { Entity, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, OneToMany, AfterInsert, - PrimaryColumn, - BeforeInsert, } from 'typeorm' import { User } from './User' import { Account } from './Account' @@ -26,7 +23,7 @@ import { CurrencyDBTransformer } from '../models/Currency' import { Base } from './Base' @Entity('budgets') -export class Budget extends BaseEntity { +export class Budget { @PrimaryGeneratedColumn('uuid') id: string @@ -85,57 +82,6 @@ export class Budget extends BaseEntity { @OneToMany(() => Transaction, transaction => transaction.budget, { cascade: true }) transactions: Promise - @AfterInsert() - private async initialBudgetSetup(): Promise { - const today = getMonthString() - const prevMonth = getMonthStringFromNow(-1) - const nextMonth = getMonthStringFromNow(1) - - // Create initial budget months - await Promise.all( - [prevMonth, today, nextMonth].map(month => { - return BudgetMonth.create({ budgetId: this.id, month }).save() - }), - ) - - // Create internal categories - const internalCategoryGroup = CategoryGroup.create({ - budgetId: this.id, - name: 'Internal Category', - internal: true, - locked: true, - }) - await internalCategoryGroup.save() - - await Promise.all( - ['To be Budgeted'].map(name => { - const internalCategory = Category.create({ - budgetId: this.id, - name: name, - categoryGroupId: internalCategoryGroup.id, - inflow: true, - locked: true, - }) - return internalCategory.save() - }), - ) - - // Create special 'Starting Balance' payee - const startingBalancePayee = Payee.create({ - budgetId: this.id, - name: 'Starting Balance', - internal: true, - }) - await startingBalancePayee.save() - - const reconciliationPayee = Payee.create({ - budgetId: this.id, - name: 'Reconciliation Balance Adjustment', - internal: true, - }) - await reconciliationPayee.save() - } - public async toResponseModel(): Promise { return { id: this.id, diff --git a/backend/src/entities/BudgetMonth.ts b/backend/src/entities/BudgetMonth.ts index f08987b..80b588b 100644 --- a/backend/src/entities/BudgetMonth.ts +++ b/backend/src/entities/BudgetMonth.ts @@ -4,26 +4,21 @@ import { AfterLoad, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, Index, OneToMany, - BeforeInsert, BeforeUpdate, - PrimaryColumn, } from 'typeorm' import { Budget } from './Budget' import { CategoryMonth } from './CategoryMonth' -import { getMonthStringFromNow } from '../utils' import { Dinero, toSnapshot } from '@dinero.js/core' import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' -import { Base } from './Base' @Entity('budget_months') -export class BudgetMonth extends BaseEntity { +export class BudgetMonth { @PrimaryGeneratedColumn('uuid') id: string @@ -81,24 +76,6 @@ export class BudgetMonth extends BaseEntity { @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.budgetMonth, { cascade: true }) categories: Promise - originalIncome: Dinero = dinero({ amount: 0, currency: USD }) - - originalBudgeted: Dinero = dinero({ amount: 0, currency: USD }) - - originalActivity: Dinero = dinero({ amount: 0, currency: USD }) - - @AfterLoad() - private async loadInitialValues(): Promise { - this.originalIncome = this.income - this.originalBudgeted = this.budgeted - this.originalActivity = this.activity - } - - @BeforeUpdate() - private async test(): Promise { - // console.log(this) - } - public async toResponseModel(): Promise { return { id: this.id, @@ -112,36 +89,4 @@ export class BudgetMonth extends BaseEntity { updated: this.updated ? this.updated.toISOString() : new Date().toISOString(), } } - - public static async findOrCreate(budgetId: string, month: string): Promise { - let budgetMonth: BudgetMonth = await BudgetMonth.findOne({ budgetId, month }) - if (!budgetMonth) { - const budget = await Budget.findOne(budgetId) - const months = await budget.getMonths() - - let newBudgetMonth - let counter = 1 - let direction = 1 - - if (months[0] > month) { - direction = -1 - counter = -1 - } - - // iterate over all months until we hit the first budget month - do { - newBudgetMonth = BudgetMonth.create({ - budgetId, - month: getMonthStringFromNow(counter), - }) - await newBudgetMonth.save({ transaction: false }) - newBudgetMonth.budget = Promise.resolve(budget) - counter = counter + direction - } while (newBudgetMonth.month !== month) - - return newBudgetMonth - } - - return budgetMonth - } } diff --git a/backend/src/entities/Category.ts b/backend/src/entities/Category.ts index b3c2411..91abe42 100644 --- a/backend/src/entities/Category.ts +++ b/backend/src/entities/Category.ts @@ -3,13 +3,10 @@ import { Entity, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, OneToMany, Index, - PrimaryColumn, - BeforeInsert, } from 'typeorm' import { CategoryGroup } from './CategoryGroup' import { CategoryMonth } from './CategoryMonth' @@ -18,7 +15,7 @@ import { Budget } from '.' import { Base } from './Base' @Entity('categories') -export class Category extends BaseEntity { +export class Category extends Base { @PrimaryGeneratedColumn('uuid') id: string @@ -29,6 +26,7 @@ export class Category extends BaseEntity { @Column({ type: 'varchar', nullable: false }) categoryGroupId: string + @Index({ unique: true }) @Column({ type: 'varchar', nullable: true }) trackingAccountId: string diff --git a/backend/src/entities/CategoryGroup.ts b/backend/src/entities/CategoryGroup.ts index d05b307..5bc0759 100644 --- a/backend/src/entities/CategoryGroup.ts +++ b/backend/src/entities/CategoryGroup.ts @@ -18,7 +18,7 @@ import { Base } from './Base' export const CreditCardGroupName = 'Credit Card Payments' @Entity('category_groups') -export class CategoryGroup extends BaseEntity { +export class CategoryGroup extends Base { @PrimaryGeneratedColumn('uuid') id: string diff --git a/backend/src/entities/CategoryMonth.ts b/backend/src/entities/CategoryMonth.ts index 56e2413..bc82403 100644 --- a/backend/src/entities/CategoryMonth.ts +++ b/backend/src/entities/CategoryMonth.ts @@ -1,30 +1,29 @@ import { CategoryMonthModel } from '../models/CategoryMonth' import { Entity, - BeforeInsert, - AfterInsert, - AfterUpdate, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, Index, AfterLoad, - PrimaryColumn, } from 'typeorm' import { BudgetMonth } from './BudgetMonth' import { Category } from './Category' -import { formatMonthFromDateString, getDateFromString } from '../utils' -import { Budget } from '.' import { Dinero } from '@dinero.js/core' -import { add, equal, dinero, subtract, isPositive, isNegative } from 'dinero.js' +import { add, dinero, subtract } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' import { Base } from './Base' +export type CategoryMonthOriginalValues = { + budgeted: Dinero + activity: Dinero + balance: Dinero +} + @Entity('category_months') -export class CategoryMonth extends BaseEntity { +export class CategoryMonth extends Base { @PrimaryGeneratedColumn('uuid') id: string @@ -79,128 +78,20 @@ export class CategoryMonth extends BaseEntity { @ManyToOne(() => BudgetMonth, budgetMonth => budgetMonth.categories) budgetMonth: Promise - originalBudgeted: Dinero = dinero({ amount: 0, currency: USD }) - - originalActivity: Dinero = dinero({ amount: 0, currency: USD }) - - originalBalance: Dinero = dinero({ amount: 0, currency: USD }) + original: CategoryMonthOriginalValues = { + budgeted: dinero({ amount: 0, currency: USD }), + activity: dinero({ amount: 0, currency: USD }), + balance: dinero({ amount: 0, currency: USD }), + } @AfterLoad() private storeOriginalValues(): void { - this.originalBudgeted = this.budgeted - this.originalActivity = this.activity - this.originalBalance = this.balance + this.original.budgeted = { ...this.budgeted } + this.original.activity = { ...this.activity } + this.original.balance = { ...this.balance } } - public static async findOrCreate(budgetId: string, categoryId: string, month: string): Promise { - let categoryMonth: CategoryMonth = await CategoryMonth.findOne( - { categoryId, month: month }, - { relations: ['budgetMonth'] }, - ) - if (!categoryMonth) { - const budgetMonth = await BudgetMonth.findOrCreate(budgetId, month) - categoryMonth = CategoryMonth.create({ - budgetMonthId: budgetMonth.id, - categoryId, - month: month, - // @TODO: I DON'T KNOW WHY I HAVE TO SPECIFY 0s HERE AND NOT ABOVE WHEN CREATING BUDGET MONTH!!! AHHH!!! - activity: dinero({ amount: 0, currency: USD }), - balance: dinero({ amount: 0, currency: USD }), - budgeted: dinero({ amount: 0, currency: USD }), - }) - await categoryMonth.save() - categoryMonth.budgetMonth = Promise.resolve(budgetMonth) - } - - return categoryMonth - } - - /** - * Get the previous month's 'balance' as this will be the 'carry over' amount for this new month - */ - @BeforeInsert() - private async getInitialBalance(): Promise { - const prevMonth = getDateFromString(this.month) - prevMonth.setMonth(prevMonth.getMonth() - 1) - const prevCategoryMonth = await CategoryMonth.findOne({ - categoryId: this.categoryId, - month: formatMonthFromDateString(prevMonth), - }) - if (prevCategoryMonth && isPositive(prevCategoryMonth.balance)) { - this.balance = add(prevCategoryMonth.balance, add(this.budgeted, this.activity)) - } - } - - /** - * == RECURSIVE == - * - * Cascade the new assigned and activity amounts up into the parent budget month for new totals. - * Also, cascade the new balance of this month into the next month to update the carry-over amount. - */ - @AfterInsert() - @AfterUpdate() - public async bookkeeping(): Promise { - const category = await Category.findOne(this.categoryId) - - // Update budget month activity and and budgeted - const budgetMonth = await BudgetMonth.findOne(this.budgetMonthId) - const budget = await Budget.findOne(budgetMonth.budgetId) - - budgetMonth.budgeted = add(budgetMonth.budgeted, subtract(this.budgeted, this.originalBudgeted)) - budgetMonth.activity = add(budgetMonth.activity, subtract(this.activity, this.originalActivity)) - budget.toBeBudgeted = add(budget.toBeBudgeted, subtract(this.originalBudgeted, this.budgeted)) - - if (category.inflow) { - budgetMonth.income = add(budgetMonth.income, subtract(this.activity, this.originalActivity)) - budget.toBeBudgeted = add(budget.toBeBudgeted, subtract(this.activity, this.originalActivity)) - } - - // Underfunded only counts for non-CC accounts as a negative CC value could mean cash bach for that month - if (!category.trackingAccountId) { - if (isNegative(this.originalBalance)) { - budgetMonth.underfunded = add(budgetMonth.underfunded, this.originalBalance) - } - if (isNegative(this.balance)) { - budgetMonth.underfunded = subtract(budgetMonth.underfunded, this.balance) - } - } - - await budget.save() - await budgetMonth.save() - - const nextMonth = getDateFromString(this.month) - nextMonth.setMonth(nextMonth.getMonth() + 1) - const nextBudgetMonth = await BudgetMonth.findOne({ - budgetId: category.budgetId, - month: formatMonthFromDateString(nextMonth), - }) - if (!nextBudgetMonth) { - return - } - - const nextCategoryMonth = await CategoryMonth.findOrCreate( - nextBudgetMonth.budgetId, - this.categoryId, - nextBudgetMonth.month, - ) - - if (isPositive(this.balance) || category.trackingAccountId) { - nextCategoryMonth.balance = add(this.balance, add(nextCategoryMonth.budgeted, nextCategoryMonth.activity)) - } else { - // If the next month's balance already matched it's activity, no need to keep cascading - const calculatedNextMonth = add(nextCategoryMonth.budgeted, nextCategoryMonth.activity) - if (equal(nextCategoryMonth.balance, calculatedNextMonth)) { - return - } - - nextCategoryMonth.balance = calculatedNextMonth - } - - // await CategoryMonth.update(nextCategoryMonth.id, { balance: nextCategoryMonth.balance }) - await nextCategoryMonth.save() - } - - public async update({ activity, budgeted }: { [key: string]: Dinero }): Promise { + public update({ activity, budgeted }: { [key: string]: Dinero }) { if (activity !== undefined) { this.activity = add(this.activity, activity) this.balance = add(this.balance, activity) @@ -210,10 +101,6 @@ export class CategoryMonth extends BaseEntity { this.budgeted = add(this.budgeted, budgetedDifference) this.balance = add(this.balance, budgetedDifference) } - - await this.save() - - return this } public async toResponseModel(): Promise { diff --git a/backend/src/entities/Payee.ts b/backend/src/entities/Payee.ts index b4ab345..baede89 100644 --- a/backend/src/entities/Payee.ts +++ b/backend/src/entities/Payee.ts @@ -16,7 +16,7 @@ import { Transaction } from './Transaction' import { Base } from './Base' @Entity('payees') -export class Payee extends BaseEntity { +export class Payee extends Base { @PrimaryGeneratedColumn('uuid') id: string diff --git a/backend/src/entities/Transaction.ts b/backend/src/entities/Transaction.ts index 368cef0..74a9ff5 100644 --- a/backend/src/entities/Transaction.ts +++ b/backend/src/entities/Transaction.ts @@ -15,6 +15,8 @@ import { BeforeUpdate, BeforeRemove, PrimaryColumn, + getRepository, + Index, } from 'typeorm' import { Account, AccountTypes } from './Account' import { Category } from './Category' @@ -34,8 +36,21 @@ export enum TransactionStatus { Reconciled, } +export type TransactionFlags = { + handleTransfers: boolean + eventsEnabled: boolean +} + +export type TransactionOriginalValues = { + payeeId: string + categoryId: string + amount: Dinero + date: Date + status: TransactionStatus +} + @Entity('transactions') -export class Transaction extends BaseEntity { +export class Transaction extends Base { @PrimaryGeneratedColumn('uuid') id: string @@ -51,6 +66,7 @@ export class Transaction extends BaseEntity { @Column({ type: 'varchar', nullable: true }) transferAccountId: string + @Index() @Column({ type: 'varchar', nullable: true, default: null }) transferTransactionId: string @@ -103,484 +119,46 @@ export class Transaction extends BaseEntity { @ManyToOne(() => Category, category => category.transactions) category: Promise - handleTransfers: boolean = false + flags: TransactionFlags = { + handleTransfers: false, + eventsEnabled: true, + } - originalPayeeId: string | null = null - - originalTransferTransactionId: string | null = null - - categoryMonth: CategoryMonth - - originalCategoryId: string = '' - - originalAmount: Dinero = dinero({ amount: 0, currency: USD }) - - originalDate: Date = new Date() - - originalStatus: TransactionStatus = null + original: TransactionOriginalValues = { + payeeId: '', + categoryId: '', + amount: dinero({ amount: 0, currency: USD }), + date: new Date(), + status: 0, + } @AfterLoad() private storeOriginalValues() { - this.originalPayeeId = this.payeeId - this.originalCategoryId = this.categoryId - this.originalAmount = this.amount - this.originalDate = this.date - this.originalStatus = this.status + this.original.payeeId = this.payeeId + this.original.categoryId = this.categoryId + this.original.amount = { ...this.amount } + this.original.date = { ...this.date } + this.original.status = this.status } - public static async createNew(partial: DeepPartial): Promise { - // Create transaction - const transaction = Transaction.create(partial) - if (partial.handleTransfers === true) { - transaction.handleTransfers = true - } - - await transaction.save() - - return transaction + public getHandleTransfers(): boolean { + return this.flags.handleTransfers } - public async update(partial: DeepPartial): Promise { + public setHandleTransfers(enabled: boolean) { + this.flags.handleTransfers = enabled + } + + public getEventsEnabled(): boolean { + return this.flags.eventsEnabled + } + + public setEventsEnabled(enabled: boolean) { + this.flags.eventsEnabled = enabled + } + + public update(partial: DeepPartial) { Object.assign(this, partial) - if (partial.handleTransfers === true) { - this.handleTransfers = true - } - await this.save() - - return this - } - - @BeforeInsert() - private async checkCreateTransferTransaction() { - if (this.handleTransfers === false) { - return - } - this.handleTransfers = false - - const payee = await Payee.findOne(this.payeeId) - if (payee.transferAccountId === null) { - // No transfer needed - return - } - - // Set a dummy transfer transaction ID since we don't have one yet... hacky, but works - this.transferTransactionId = '0' - } - - @BeforeInsert() - @BeforeUpdate() - private async createCategoryMonth(): Promise { - if (this.categoryId) { - // First, ensure category month exists - this.categoryMonth = await CategoryMonth.findOrCreate( - this.budgetId, - this.categoryId, - formatMonthFromDateString(this.date), - ) - } - - const account = await this.getAccount() - - // Create category month for CC tracking category - if (account.type === AccountTypes.CreditCard) { - // First, ensure category month exists - const trackingCategory = await Category.findOne({ trackingAccountId: account.id }) - await CategoryMonth.findOrCreate( - this.budgetId, - trackingCategory.id, - this.getMonth(), - ) - } - } - - @AfterInsert() - private async afterInsert(): Promise { - await this.updateAccountBalanceOnAdd() - await this.bookkeepingOnAdd() - await this.createTransferTransaction() - } - - private async bookkeepingOnAdd(): Promise { - const account = await Account.findOne({ id: this.accountId }) - - // No bookkeeping necessary for tracking accounts - if (account.type === AccountTypes.Tracking) { - return - } - - const payee = await Payee.findOne(this.payeeId) - const transferAccount = payee.transferAccountId ? await Account.findOne(payee.transferAccountId) : null - - // If this is a transfer to a budget account, no need to update categories and budgets. Money doesn't 'go anywhere'. UNLESS it's a CC!! - if (this.transferTransactionId !== null && transferAccount.type !== AccountTypes.Tracking) { - if (account.type === AccountTypes.CreditCard) { - // Update CC category - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const ccCategoryMonth = await CategoryMonth.findOrCreate(this.budgetId, ccCategory.id, this.getMonth()) - await ccCategoryMonth.update({ activity: multiply(this.amount, -1) }) - } - return - } - - if (!this.categoryId) { - return - } - - const category = await this.category - - // If this is inflow and a CC, bail out - don't update budget or category months as the - // 'inflow' will be accounted for in the difference of the payment you allocate. - if (category.inflow === true && account.type === AccountTypes.CreditCard) { - return - } - - if (category.inflow === false || account.type !== AccountTypes.CreditCard) { - // Cascade category month - await this.categoryMonth.update({ activity: this.amount }) - } - - if (account.type === AccountTypes.CreditCard) { - // Update CC category - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const ccCategoryMonth = await CategoryMonth.findOrCreate(this.budgetId, ccCategory.id, this.getMonth()) - await ccCategoryMonth.update({ activity: multiply(this.amount, -1) }) - } - } - - private async createTransferTransaction(): Promise { - if (this.transferTransactionId !== '0') { - return - } - - const transferTransaction = Transaction.create({ - budgetId: this.budgetId, - accountId: (await this.getPayee()).transferAccountId, - payeeId: (await this.getAccount()).transferPayeeId, - transferAccountId: (await this.getAccount()).id, - transferTransactionId: this.id, - amount: multiply(this.amount, -1), - date: this.date, - status: TransactionStatus.Pending, - }) - - await transferTransaction.save() - - this.payeeId = (await transferTransaction.getAccount()).transferPayeeId - this.transferAccountId = (await transferTransaction.getAccount()).id - this.transferTransactionId = transferTransaction.id - - // Perform update here so that the listener hooks don't get called - await Transaction.update(this.id, { - payeeId: this.payeeId, - transferAccountId: this.transferAccountId, - transferTransactionId: this.transferTransactionId, - }) - } - - private async updateAccountBalanceOnAdd(): Promise { - const account = await this.getAccount() - - switch (this.status) { - case TransactionStatus.Pending: - account.uncleared = add(account.uncleared, this.amount) - break - case TransactionStatus.Cleared: - case TransactionStatus.Reconciled: - default: - account.cleared = add(account.cleared, this.amount) - break - } - - await account.save() - } - - @AfterUpdate() - private async updateAccountBalanceOnUpdate(): Promise { - if (this.amount === this.originalAmount && this.status === this.originalStatus) { - // amount and status hasn't changed, no balance update necessary - return - } - - // First, 'undo' the original amount / status then add in new. Easier than a bunch of if statements - const account = await Account.findOne(this.accountId) - - switch (this.originalStatus) { - case TransactionStatus.Pending: - account.uncleared = subtract(account.uncleared, this.originalAmount) - break - case TransactionStatus.Cleared: - case TransactionStatus.Reconciled: - default: - account.cleared = subtract(account.cleared, this.originalAmount) - break - } - - switch (this.status) { - case TransactionStatus.Pending: - account.uncleared = add(account.uncleared, this.amount) - break - case TransactionStatus.Cleared: - case TransactionStatus.Reconciled: - default: - account.cleared = add(account.cleared, this.amount) - break - } - - await account.save() - } - - @AfterRemove() - private async updateAccountBalanceOnRemove(): Promise { - // First, 'undo' the original amount / status then add in new. Easier than a bunch of if statements - const account = await this.getAccount() - - switch (this.status) { - case TransactionStatus.Pending: - account.uncleared = subtract(account.uncleared, this.amount) - break - case TransactionStatus.Cleared: - case TransactionStatus.Reconciled: - default: - account.cleared = subtract(account.cleared, this.amount) - break - } - - await account.save() - } - - @BeforeUpdate() - private async updateTransferTransaction() { - if (this.handleTransfers === false) { - return - } - this.handleTransfers = false - - // If the payees, dates, and amounts haven't changed, bail - if ( - this.payeeId === this.originalPayeeId && - this.amount === this.originalAmount && - formatMonthFromDateString(this.date) === formatMonthFromDateString(this.originalDate) - ) { - return - } - - if (this.payeeId === this.originalPayeeId && this.transferTransactionId) { - // Payees are the same, just update details - const transferTransaction = await Transaction.findOne({ transferTransactionId: this.id }) - await transferTransaction.update({ - amount: multiply(this.amount, -1), - date: this.date, - }) - return - } - - if (this.payeeId !== this.originalPayeeId) { - // If the payee has changed, delete the transfer transaction before proceeding - if (this.transferTransactionId) { - const transferTransaction = await Transaction.findOne({ transferTransactionId: this.id }) - await transferTransaction.remove() - } - - this.transferTransactionId = null - - // Now create a new transfer transaction if necessary - const payee = await Payee.findOne(this.payeeId) - if (payee.transferAccountId !== null) { - const transferTransaction = await this.buildTransferTransaction() - await transferTransaction.save() - this.transferTransactionId = transferTransaction.id - } - } - } - - @AfterUpdate() - private async bookkeepingOnUpdate(): Promise { - const account = await this.account - - // No bookkeeping necessary for tracking accounts - if (account.type === AccountTypes.Tracking) { - return - } - - // @TODO: hanle update of transactions when going to / from a transfer to / from a non-transfer - - if (this.transferTransactionId !== null) { - // If this is a transfer, no need to update categories and budgets. Money doesn't 'go anywhere'. UNLESS it's a CC!!! - if (account.type === AccountTypes.CreditCard) { - // Update CC category - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const originalCCMonth = await CategoryMonth.findOne({ - categoryId: ccCategory.id, - month: formatMonthFromDateString(this.originalDate), - }) - await originalCCMonth.update({ activity: this.originalAmount }) - - const currentCCMonth = await CategoryMonth.findOrCreate(this.budgetId, ccCategory.id, this.getMonth()) - await currentCCMonth.update({ activity: multiply(this.amount, -1) }) - } - - return - } - - const category = await this.category - const originalCategory = await Category.findOne(this.originalCategoryId) - - let activity = subtract(this.amount, this.originalAmount) - - if ( - this.originalCategoryId !== this.categoryId || - formatMonthFromDateString(this.originalDate) !== formatMonthFromDateString(this.date) - ) { - // Cat or month has changed so the activity is the entirety of the transaction - activity = this.amount - - // Revert original category, if set - if (this.originalCategoryId) { - if (originalCategory && originalCategory.inflow === false || account.type !== AccountTypes.CreditCard) { - // Category or month has changed, so reset 'original' amount - const originalCategoryMonth = await CategoryMonth.findOne( - { categoryId: this.originalCategoryId, month: formatMonthFromDateString(this.originalDate) }, - { relations: ['budgetMonth'] }, - ) - - await originalCategoryMonth.update({ activity: multiply(this.originalAmount, -1) }) - } - - if (account.type === AccountTypes.CreditCard) { - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - - /** - * Don't update CC months for original or current CC category month - * if this is inflow. - */ - - if (originalCategory.inflow === false) { - const originalCCMonth = await CategoryMonth.findOne({ - categoryId: ccCategory.id, - month: formatMonthFromDateString(this.originalDate), - }) - await originalCCMonth.update({ activity: this.originalAmount }) - } - } - } - - // Apply to new category - if (this.categoryId) { - if (category.inflow === false || account.type !== AccountTypes.CreditCard) { - await this.categoryMonth.update({ activity }) - } - - if (account.type === AccountTypes.CreditCard) { - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - - /** - * Don't update CC months for original or current CC category month - * if this is inflow. - */ - - if (category && category.inflow === false) { - const currentCCMonth = await CategoryMonth.findOrCreate(this.budgetId, ccCategory.id, this.getMonth()) - await currentCCMonth.update({ activity: multiply(this.amount, -1) }) - } - } - } - } else { - if (!this.categoryId) { - return - } - - if (category.inflow === true && account.type === AccountTypes.CreditCard) { - return - } - - if (account.type === AccountTypes.CreditCard) { - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const currentCCMonth = await CategoryMonth.findOrCreate(this.budgetId, ccCategory.id, this.getMonth()) - await currentCCMonth.update({ activity: multiply(this.amount, -1) }) - } - } - } - - @BeforeRemove() - private async deleteTransferTransaction() { - if (this.transferTransactionId === null) { - return - } - - const transferTransaction = await Transaction.findOne({ transferTransactionId: this.id }) - transferTransaction.transferTransactionId = null - await transferTransaction.remove() - } - - @AfterRemove() - private async bookkeepingOnDelete(): Promise { - const account = await this.getAccount() - - // No bookkeeping necessary for tracking accounts - if (account.type === AccountTypes.Tracking) { - return - } - - const payee = await Payee.findOne(this.payeeId) - const transferAccount = payee.transferAccountId ? await Account.findOne(payee.transferAccountId) : null - - // If this is a transfer, no need to update categories and budgets. Money doesn't 'go anywhere'. UNLESS it's a CC!!! - if (this.transferTransactionId !== null && transferAccount.type !== AccountTypes.Tracking) { - if (account.type === AccountTypes.CreditCard) { - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const ccCategoryMonth = await CategoryMonth.findOne({ categoryId: ccCategory.id, month: this.getMonth() }) - await ccCategoryMonth.update({ activity: this.amount }) - } - - return - } - - if (!this.categoryId) { - return - } - - const category = await Category.findOne(this.categoryId) - - if (category.inflow === true && account.type === AccountTypes.CreditCard) { - return - } - - if (this.categoryId) { - const originalCategoryMonth = await CategoryMonth.findOne( - { categoryId: this.categoryId, month: formatMonthFromDateString(this.date) }, - { relations: ['budgetMonth'] }, - ) - await originalCategoryMonth.update({ activity: multiply(this.amount, -1) }) - } - - // Check if we need to update a CC category - if (account.type === AccountTypes.CreditCard) { - // Update CC category - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const ccCategoryMonth = await CategoryMonth.findOne({ categoryId: ccCategory.id, month: this.getMonth() }) - await ccCategoryMonth.update({ activity: this.amount }) - } - } - - private async buildTransferTransaction(): Promise { - return Transaction.create({ - budgetId: this.budgetId, - accountId: (await (await this.getPayee()).transferAccount).id, - payeeId: (await this.getAccount()).transferPayeeId, - transferAccountId: (await this.getAccount()).id, - transferTransactionId: this.id, - amount: multiply(this.amount, -1), - date: this.date, - status: TransactionStatus.Pending, - }) - } - - public async getAccount(): Promise { - return await Account.findOne(this.accountId) - } - - public async getPayee(): Promise { - return await Payee.findOne(this.payeeId) } public async toResponseModel(): Promise { diff --git a/backend/src/entities/User.ts b/backend/src/entities/User.ts index 42acda7..9f707f4 100644 --- a/backend/src/entities/User.ts +++ b/backend/src/entities/User.ts @@ -19,7 +19,7 @@ import { Budget } from './Budget' import { Base } from './Base' @Entity('users') -export class User extends BaseEntity { +export class User { @PrimaryGeneratedColumn('uuid') id: string diff --git a/backend/src/middleware/authentication.ts b/backend/src/middleware/authentication.ts index 957a0a6..19276c1 100644 --- a/backend/src/middleware/authentication.ts +++ b/backend/src/middleware/authentication.ts @@ -3,6 +3,7 @@ import { Request } from 'express' import { User } from '../entities' import config from '../config' import { logger } from '../config/winston' +import { getRepository } from 'typeorm' export async function expressAuthentication( request: Request, @@ -23,7 +24,7 @@ export async function expressAuthentication( jwtPayload = jwt.verify(token, config.jwtSecret) as any const { userId, email } = jwtPayload - user = await User.findOne(userId) + user = await getRepository(User).findOne(userId) } catch (err) {} } diff --git a/backend/src/repositories/BudgetMonths.ts b/backend/src/repositories/BudgetMonths.ts new file mode 100644 index 0000000..33bfdc5 --- /dev/null +++ b/backend/src/repositories/BudgetMonths.ts @@ -0,0 +1,44 @@ +import { Budget } from "../entities"; +import { EntityRepository, EntityTarget, Repository } from "typeorm"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { formatMonthFromDateString } from "../utils"; + +@EntityRepository(BudgetMonth) +export class BudgetMonths extends Repository { + async findOrCreate(budgetId: string, month: string): Promise { + let budgetMonth: BudgetMonth = await this.findOne({ budgetId, month }) + if (!budgetMonth) { + const budget = await this.manager.getRepository(Budget).findOne(budgetId) + const months = await budget.getMonths() + + let newBudgetMonth + let counter = 1 + let direction = 1 + let monthFrom = new Date() + monthFrom.setDate(1) + + if (month < months[0]) { + monthFrom = new Date(`${months[0]}T12:00:00`) + direction = -1 + counter = -1 + } else if (month > months[months.length - 1]) { + monthFrom = new Date(`${months[months.length - 1]}T12:00:00`) + } + + // iterate over all months until we hit the first budget month + do { + monthFrom.setMonth(monthFrom.getMonth() + counter) + newBudgetMonth = this.create({ + budgetId, + month: formatMonthFromDateString(monthFrom), + }) + await this.save(newBudgetMonth) + newBudgetMonth.budget = Promise.resolve(budget) + } while (newBudgetMonth.month !== month) + + return newBudgetMonth + } + + return budgetMonth + } +} diff --git a/backend/src/repositories/CategoryMonths.ts b/backend/src/repositories/CategoryMonths.ts new file mode 100644 index 0000000..d4965db --- /dev/null +++ b/backend/src/repositories/CategoryMonths.ts @@ -0,0 +1,41 @@ +import { EntityRepository, EntityTarget, ObjectType, Repository, UpdateResult } from "typeorm"; +import { CategoryMonth } from "../entities/CategoryMonth"; +import { dinero } from "dinero.js"; +import { USD } from "@dinero.js/currencies"; +import { BudgetMonths } from "./BudgetMonths"; + +@EntityRepository(CategoryMonth) +export class CategoryMonths extends Repository { + async findOrCreate(budgetId: string, categoryId: string, month: string): Promise { + let categoryMonth: CategoryMonth = await this.findOne( + { categoryId, month: month }, + { relations: ['budgetMonth'] }, + ) + + if (!categoryMonth) { + const budgetMonth = await this.manager.getCustomRepository(BudgetMonths).findOrCreate(budgetId, month) + categoryMonth = this.create({ + budgetMonthId: budgetMonth.id, + categoryId, + month: month, + // @TODO: I DON'T KNOW WHY I HAVE TO SPECIFY 0s HERE AND NOT ABOVE WHEN CREATING BUDGET MONTH!!! AHHH!!! + activity: dinero({ amount: 0, currency: USD }), + balance: dinero({ amount: 0, currency: USD }), + budgeted: dinero({ amount: 0, currency: USD }), + }) + await this.save(categoryMonth) + categoryMonth.budgetMonth = Promise.resolve(budgetMonth) + } + + return categoryMonth + } + + async update(categoryMonth: CategoryMonth): Promise { + const originalValues = categoryMonth.original + delete categoryMonth.original + const result = await this.manager.getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth) + categoryMonth.original = originalValues + + return result + } +} diff --git a/backend/src/repositories/TransactionRepository.ts b/backend/src/repositories/TransactionRepository.ts deleted file mode 100644 index 87a4685..0000000 --- a/backend/src/repositories/TransactionRepository.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DeepPartial, EntityRepository, Repository } from 'typeorm' -import { Transaction } from '../entities/Transaction' -import { formatMonthFromDateString } from '../utils' -import { CategoryMonth } from '../entities/CategoryMonth' - -@EntityRepository() -export class TransactionRepository extends Repository { - public static async foobar(budgetId: string, partial: DeepPartial): Promise { - // Create transaction - const transaction = Transaction.create(partial) - - const categoryMonth = await CategoryMonth.findOrCreate( - budgetId, - transaction.categoryId, - formatMonthFromDateString(transaction.date), - ) - - await categoryMonth.update({ activity: transaction.amount }) - - return transaction - } -} diff --git a/backend/src/repositories/UserRepository.ts b/backend/src/repositories/UserRepository.ts deleted file mode 100644 index 54ada54..0000000 --- a/backend/src/repositories/UserRepository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { EntityRepository, Repository } from 'typeorm' -import { User } from '../entities/User' - -@EntityRepository() -export class UserRepository extends Repository { - // findByName(firstName: string, lastName: string) { - // return this.createQueryBuilder("user") - // .where("user.firstName = :firstName", { firstName }) - // .andWhere("user.lastName = :lastName", { lastName }) - // .getMany(); - // } -} diff --git a/backend/src/repositories/index.ts b/backend/src/repositories/index.ts deleted file mode 100644 index 67cdf44..0000000 --- a/backend/src/repositories/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { getConnectionManager } from 'typeorm' -import { UserRepository } from './UserRepository' - -const userRepository = getConnectionManager().get('default').getRepository(UserRepository) - -export { userRepository } diff --git a/backend/src/server.ts b/backend/src/server.ts index d3890dd..028c3a9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,6 +9,11 @@ import dbConfig from '../ormconfig' // await sleep(5000) } + process.on('unhandledRejection', error => { + // Won't execute + console.log('unhandledRejection', error); + }); + await createConnection(dbConfig) const server = app.listen(config.port, '0.0.0.0', () => { diff --git a/backend/src/subscribers/AccountSubscriber.ts b/backend/src/subscribers/AccountSubscriber.ts new file mode 100644 index 0000000..4aec001 --- /dev/null +++ b/backend/src/subscribers/AccountSubscriber.ts @@ -0,0 +1,74 @@ +import { Budget } from "../entities/Budget"; +import { EntitySubscriberInterface, EventSubscriber, getManager, InsertEvent, UpdateEvent } from "typeorm"; +import { getMonthString, getMonthStringFromNow } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { CategoryGroup, CreditCardGroupName } from "../entities/CategoryGroup"; +import { Category } from "../entities/Category"; +import { Payee } from "../entities/Payee"; +import { Account, AccountTypes } from "../entities/Account"; +import { add } from "dinero.js"; + +@EventSubscriber() +export class AccountSubscriber implements EntitySubscriberInterface { + listenTo() { + return Account; + } + + async afterInsert(event: InsertEvent) { + await this.createCreditCardCategory(event) + await this.createAccountPayee(event) + } + + private async createAccountPayee(event: InsertEvent) { + const account = event.entity + const manager = event.manager + + const payee = manager.create(Payee, { + budgetId: account.budgetId, + name: `Transfer : ${account.name}`, + transferAccountId: account.id, + }) + + // @TODO: I wish there was a better way around this + await manager.save(Payee, payee) + account.transferPayeeId = payee.id + await manager.save(Account, account) + } + + private async createCreditCardCategory(event: InsertEvent) { + const account = event.entity + const manager = event.manager + + if (account.type === AccountTypes.CreditCard) { + // Create CC payments category if it doesn't exist + const ccGroup = + (await manager.findOne(CategoryGroup, { + budgetId: account.budgetId, + name: CreditCardGroupName, + })) || + manager.create(CategoryGroup, { + budgetId: account.budgetId, + name: CreditCardGroupName, + locked: true, + }) + + await manager.save(CategoryGroup, ccGroup) + + // Create payment tracking category + const paymentCategory = manager.create(Category, { + budgetId: account.budgetId, + categoryGroupId: ccGroup.id, + trackingAccountId: account.id, + name: account.name, + locked: true, + }) + await manager.save(Category, paymentCategory) + } + } + + async beforeUpdate(event: UpdateEvent) { + const account = event.entity + + account.balance = add(account.cleared, account.uncleared) + } +} diff --git a/backend/src/subscribers/BudgetSubscriber.ts b/backend/src/subscribers/BudgetSubscriber.ts new file mode 100644 index 0000000..47661d6 --- /dev/null +++ b/backend/src/subscribers/BudgetSubscriber.ts @@ -0,0 +1,66 @@ +import { Budget } from "../entities/Budget"; +import { EntitySubscriberInterface, EventSubscriber, getManager, InsertEvent } from "typeorm"; +import { getMonthString, getMonthStringFromNow } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { CategoryGroup } from "../entities/CategoryGroup"; +import { Category } from "../entities/Category"; +import { Payee } from "../entities/Payee"; + +@EventSubscriber() +export class BudgetSubscriber implements EntitySubscriberInterface { + listenTo() { + return Budget; + } + + async afterInsert(event: InsertEvent) { + const manager = event.manager + const budget = event.entity + + const today = getMonthString() + const prevMonth = getMonthStringFromNow(-1) + const nextMonth = getMonthStringFromNow(1) + + // Create initial budget months + for (const month of [prevMonth, today, nextMonth]) { + const newBudgetMonth = manager.create(BudgetMonth, { budgetId: budget.id, month }) + await manager.save(BudgetMonth, newBudgetMonth) + } + + // Create internal categories + const internalCategoryGroup = manager.create(CategoryGroup, { + budgetId: budget.id, + name: 'Internal Category', + internal: true, + locked: true, + }) + await manager.save(CategoryGroup, internalCategoryGroup) + + await Promise.all( + ['To be Budgeted'].map(name => { + const internalCategory = manager.create(Category, { + budgetId: budget.id, + name: name, + categoryGroupId: internalCategoryGroup.id, + inflow: true, + locked: true, + }) + return manager.save(Category, internalCategory) + }), + ) + + // Create special 'Starting Balance' payee + const startingBalancePayee = manager.create(Payee, { + budgetId: budget.id, + name: 'Starting Balance', + internal: true, + }) + await manager.save(Payee, startingBalancePayee) + + const reconciliationPayee = manager.create(Payee, { + budgetId: budget.id, + name: 'Reconciliation Balance Adjustment', + internal: true, + }) + await manager.save(Payee, reconciliationPayee) + } +} diff --git a/backend/src/subscribers/CategoryMonthSubscriber.ts b/backend/src/subscribers/CategoryMonthSubscriber.ts new file mode 100644 index 0000000..6dd0062 --- /dev/null +++ b/backend/src/subscribers/CategoryMonthSubscriber.ts @@ -0,0 +1,132 @@ +import { Budget } from "../entities/Budget"; +import { EntityManager, EntitySubscriberInterface, EventSubscriber, getManager, InsertEvent, Repository, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString, getDateFromString, getMonthString, getMonthStringFromNow } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { CategoryGroup, CreditCardGroupName } from "../entities/CategoryGroup"; +import { Category } from "../entities/Category"; +import { Payee } from "../entities/Payee"; +import { Account, AccountTypes } from "../entities/Account"; +import { add, equal, isNegative, isPositive, subtract } from "dinero.js"; +import { CategoryMonth, CategoryMonthOriginalValues } from "../entities/CategoryMonth"; +import { CategoryMonths } from "../repositories/CategoryMonths"; + +let originalValues: CategoryMonthOriginalValues + +@EventSubscriber() +export class CategoryMonthSubscriber implements EntitySubscriberInterface { + listenTo() { + return CategoryMonth; + } + + storeTransientValues(entity: CategoryMonth) { + originalValues = entity.original + delete entity.original + } + + restoreTransientValues(entity: CategoryMonth) { + entity.original = originalValues + } + + /** + * Get the previous month's 'balance' as this will be the 'carry over' amount for this new month + */ + async beforeInsert(event: InsertEvent) { + const categoryMonth = event.entity + const manager = event.manager + + const prevMonth = getDateFromString(categoryMonth.month) + prevMonth.setMonth(prevMonth.getMonth() - 1) + const prevCategoryMonth = await manager.findOne(CategoryMonth, { + categoryId: categoryMonth.categoryId, + month: formatMonthFromDateString(prevMonth), + }) + if (prevCategoryMonth && isPositive(prevCategoryMonth.balance)) { + categoryMonth.balance = add(prevCategoryMonth.balance, add(categoryMonth.budgeted, categoryMonth.activity)) + } + + this.storeTransientValues(event.entity) + } + + async beforeUpdate(event: UpdateEvent) { + this.storeTransientValues(event.entity as CategoryMonth) + } + + async afterInsert(event: InsertEvent) { + this.restoreTransientValues(event.entity) + + await this.bookkeeping(event.entity as CategoryMonth, event.manager) + } + + async afterUpdate(event: UpdateEvent) { + this.restoreTransientValues(event.entity as CategoryMonth) + + await this.bookkeeping(event.entity as CategoryMonth, event.manager) + } + + /** + * == RECURSIVE == + * + * Cascade the new assigned and activity amounts up into the parent budget month for new totals. + * Also, cascade the new balance of this month into the next month to update the carry-over amount. + */ + private async bookkeeping(categoryMonth: CategoryMonth, manager: EntityManager) { + const category = await manager.findOne(Category, categoryMonth.categoryId) + + // Update budget month activity and and budgeted + const budgetMonth = await manager.findOne(BudgetMonth, categoryMonth.budgetMonthId) + const budget = await manager.findOne(Budget, budgetMonth.budgetId) + + budgetMonth.budgeted = add(budgetMonth.budgeted, subtract(categoryMonth.budgeted, categoryMonth.original.budgeted)) + budgetMonth.activity = add(budgetMonth.activity, subtract(categoryMonth.activity, categoryMonth.original.activity)) + budget.toBeBudgeted = add(budget.toBeBudgeted, subtract(categoryMonth.original.budgeted, categoryMonth.budgeted)) + + if (category.inflow) { + budgetMonth.income = add(budgetMonth.income, subtract(categoryMonth.activity, categoryMonth.original.activity)) + budget.toBeBudgeted = add(budget.toBeBudgeted, subtract(categoryMonth.activity, categoryMonth.original.activity)) + } + + // Underfunded only counts for non-CC accounts as a negative CC value could mean cash bach for that month + if (!category.trackingAccountId) { + if (isNegative(categoryMonth.original.balance)) { + budgetMonth.underfunded = add(budgetMonth.underfunded, categoryMonth.original.balance) + } + if (isNegative(categoryMonth.balance)) { + budgetMonth.underfunded = subtract(budgetMonth.underfunded, categoryMonth.balance) + } + } + + await manager.save(Budget, budget) + await manager.save(BudgetMonth, budgetMonth) + + const nextMonth = getDateFromString(categoryMonth.month) + nextMonth.setMonth(nextMonth.getMonth() + 1) + const nextBudgetMonth = await manager.findOne(BudgetMonth, { + budgetId: category.budgetId, + month: formatMonthFromDateString(nextMonth), + }) + if (!nextBudgetMonth) { + return + } + + const nextCategoryMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate( + nextBudgetMonth.budgetId, + categoryMonth.categoryId, + nextBudgetMonth.month, + ) + + if (isPositive(categoryMonth.balance) || category.trackingAccountId) { + nextCategoryMonth.balance = add(categoryMonth.balance, add(nextCategoryMonth.budgeted, nextCategoryMonth.activity)) + } else { + // If the next month's balance already matched it's activity, no need to keep cascading + const calculatedNextMonth = add(nextCategoryMonth.budgeted, nextCategoryMonth.activity) + if (equal(nextCategoryMonth.balance, calculatedNextMonth)) { + return + } + + nextCategoryMonth.balance = calculatedNextMonth + } + + // await CategoryMonth.update(nextCategoryMonth.id, { balance: nextCategoryMonth.balance }) + await manager.save(CategoryMonth, nextCategoryMonth) + } +} diff --git a/backend/src/subscribers/TransactionSubscriber.ts b/backend/src/subscribers/TransactionSubscriber.ts new file mode 100644 index 0000000..17f3b9d --- /dev/null +++ b/backend/src/subscribers/TransactionSubscriber.ts @@ -0,0 +1,500 @@ +import { Budget } from "../entities/Budget"; +import { EntityManager, EntitySubscriberInterface, EventSubscriber, getManager, getRepository, InsertEvent, RemoveEvent, Repository, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString, getDateFromString, getMonthString, getMonthStringFromNow } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { CategoryGroup, CreditCardGroupName } from "../entities/CategoryGroup"; +import { Category } from "../entities/Category"; +import { Payee } from "../entities/Payee"; +import { Account, AccountTypes } from "../entities/Account"; +import { add, equal, isNegative, isPositive, multiply, subtract } from "dinero.js"; +import { CategoryMonth } from "../entities/CategoryMonth"; +import { Transaction, TransactionFlags, TransactionOriginalValues, TransactionStatus } from "../entities/Transaction"; +import { CategoryMonths } from "../repositories/CategoryMonths"; + +@EventSubscriber() +export class TransactionSubscriber implements EntitySubscriberInterface { + listenTo() { + return Transaction; + } + + async beforeInsert(event: InsertEvent) { + if (event.entity.getEventsEnabled() === false) { + return + } + + await this.checkCreateTransferTransaction(event.entity as Transaction, event.manager) + await this.createCategoryMonth(event.entity as Transaction, event.manager) + } + + async beforeUpdate(event: UpdateEvent) { + if (event.entity.getEventsEnabled() === false) { + return + } + + await this.createCategoryMonth(event.entity as Transaction, event.manager) + await this.updateTransferTransaction(event.entity as Transaction, event.manager) + } + + async afterInsert(event: InsertEvent) { + if (event.entity.getEventsEnabled() === false) { + return + } + + await this.updateAccountBalanceOnAdd(event.entity as Transaction, event.manager) + await this.bookkeepingOnAdd(event.entity as Transaction, event.manager) + await this.createTransferTransaction(event.entity as Transaction, event.manager) + } + + async afterUpdate(event: UpdateEvent) { + if (event.entity.getEventsEnabled() === false) { + return + } + + await this.updateAccountBalanceOnUpdate(event.entity as Transaction, event.manager) + await this.bookkeepingOnUpdate(event.entity as Transaction, event.manager) + } + + async beforeRemove(event: RemoveEvent) { + if (event.entity.getEventsEnabled() === false) { + return + } + + const transaction = event.entity + const manager = event.manager + + if (transaction.transferTransactionId === null) { + return + } + + const transferTransaction = await manager.findOne(Transaction, { transferTransactionId: transaction.id }) + transferTransaction.transferTransactionId = null + await manager.remove(Transaction, transferTransaction) + } + + async afterRemove(event: RemoveEvent) { + if (event.entity.getEventsEnabled() === false) { + return + } + + await this.updateAccountBalanceOnRemove(event.entity as Transaction, event.manager) + await this.bookkeepingOnDelete(event.entity as Transaction, event.manager) + } + + async checkCreateTransferTransaction(transaction: Transaction, manager: EntityManager) { + if (transaction.getHandleTransfers() === false) { + return + } + transaction.setHandleTransfers(false) + + const payee = await manager.findOne(Payee, transaction.payeeId) + if (payee.transferAccountId === null) { + // No transfer needed + return + } + + // Set a dummy transfer transaction ID since we don't have one yet... hacky, but works + transaction.transferTransactionId = '0' + } + + private async createCategoryMonth(transaction: Transaction, manager: EntityManager) { + if (transaction.categoryId) { + // First, ensure category month exists + await manager.getCustomRepository(CategoryMonths).findOrCreate( + transaction.budgetId, + transaction.categoryId, + formatMonthFromDateString(transaction.date), + ) + } + + const account = await manager.getRepository(Account).findOne(transaction.accountId) + + // Create category month for CC tracking category + if (account.type === AccountTypes.CreditCard) { + // First, ensure category month exists + const trackingCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + await manager.getCustomRepository(CategoryMonths).findOrCreate( + transaction.budgetId, + trackingCategory.id, + transaction.getMonth(), + ) + } + } + + private async bookkeepingOnAdd(transaction: Transaction, manager: EntityManager) { + const account = await manager.findOne(Account, { id: transaction.accountId }) + + // No bookkeeping necessary for tracking accounts + if (account.type === AccountTypes.Tracking) { + return + } + + const payee = await manager.findOne(Payee, transaction.payeeId) + const transferAccount = payee.transferAccountId ? await manager.findOne(Account, payee.transferAccountId) : null + + // If this is a transfer to a budget account, no need to update categories and budgets. Money doesn't 'go anywhere'. UNLESS it's a CC!! + if (transaction.transferTransactionId !== null && transferAccount.type !== AccountTypes.Tracking) { + if (account.type === AccountTypes.CreditCard) { + // Update CC category + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const ccCategoryMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate(transaction.budgetId, ccCategory.id, transaction.getMonth()) + ccCategoryMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getCustomRepository(CategoryMonths).save(ccCategoryMonth) + } + return + } + + if (!transaction.categoryId) { + return + } + + const category = await getRepository(Category).findOne(transaction.categoryId) + + // If this is inflow and a CC, bail out - don't update budget or category months as the + // 'inflow' will be accounted for in the difference of the payment you allocate. + if (category.inflow === true && account.type === AccountTypes.CreditCard) { + return + } + + if (category.inflow === false || account.type !== AccountTypes.CreditCard) { + // Cascade category month + const transactionCategoryMonth = await manager.getRepository(CategoryMonth).findOne({ categoryId: transaction.categoryId, month: transaction.getMonth() }) + transactionCategoryMonth.update({ activity: transaction.amount }) + await manager.getRepository(CategoryMonth).update(transactionCategoryMonth.id, transactionCategoryMonth) + } + + if (account.type === AccountTypes.CreditCard) { + // Update CC category + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const ccCategoryMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate(transaction.budgetId, ccCategory.id, transaction.getMonth()) + ccCategoryMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getCustomRepository(CategoryMonths).save(ccCategoryMonth) + } + } + + private async createTransferTransaction(transaction: Transaction, manager: EntityManager) { + if (transaction.transferTransactionId !== '0') { + return + } + + const account = await manager.getRepository(Account).findOne(transaction.accountId) + const payee = await manager.getRepository(Payee).findOne(transaction.payeeId) + + const transferTransaction = manager.create(Transaction, { + budgetId: transaction.budgetId, + accountId: payee.transferAccountId, + payeeId: account.transferPayeeId, + transferAccountId: account.id, + transferTransactionId: transaction.id, + amount: multiply(transaction.amount, -1), + date: transaction.date, + status: TransactionStatus.Pending, + }) + + await manager.save(Transaction, transferTransaction) + + const transferAccount = await manager.getRepository(Account).findOne(transferTransaction.accountId) + + transaction.payeeId = transferAccount.transferPayeeId + transaction.transferAccountId = transferAccount.id + transaction.transferTransactionId = transferTransaction.id + + // Perform update here so that the listener hooks don't get called + transaction.setEventsEnabled(false) + await manager.getRepository(Transaction).save(transaction) + } + + private async updateAccountBalanceOnAdd(transaction: Transaction, manager: EntityManager) { + const account = await manager.getRepository(Account).findOne(transaction.accountId) + + switch (transaction.status) { + case TransactionStatus.Pending: + account.uncleared = add(account.uncleared, transaction.amount) + break + case TransactionStatus.Cleared: + case TransactionStatus.Reconciled: + default: + account.cleared = add(account.cleared, transaction.amount) + break + } + + await manager.save(Account, account) + } + + private async updateAccountBalanceOnUpdate(transaction: Transaction, manager: EntityManager) { + const getRepository = manager.getRepository + + if (transaction.amount === transaction.original.amount && transaction.status === transaction.original.status) { + // amount and status hasn't changed, no balance update necessary + return + } + + // First, 'undo' the original amount / status then add in new. Easier than a bunch of if statements + const account = await manager.findOne(Account, transaction.accountId) + + switch (transaction.original.status) { + case TransactionStatus.Pending: + account.uncleared = subtract(account.uncleared, transaction.original.amount) + break + case TransactionStatus.Cleared: + case TransactionStatus.Reconciled: + default: + account.cleared = subtract(account.cleared, transaction.original.amount) + break + } + + switch (transaction.status) { + case TransactionStatus.Pending: + account.uncleared = add(account.uncleared, transaction.amount) + break + case TransactionStatus.Cleared: + case TransactionStatus.Reconciled: + default: + account.cleared = add(account.cleared, transaction.amount) + break + } + + await manager.save(Account, account) + } + + private async updateAccountBalanceOnRemove(transaction: Transaction, manager: EntityManager) { + // First, 'undo' the original amount / status then add in new. Easier than a bunch of if statements + const account = await manager.getRepository(Account).findOne(transaction.accountId) + + switch (transaction.status) { + case TransactionStatus.Pending: + account.uncleared = subtract(account.uncleared, transaction.amount) + break + case TransactionStatus.Cleared: + case TransactionStatus.Reconciled: + default: + account.cleared = subtract(account.cleared, transaction.amount) + break + } + + await manager.save(Account, account) + } + + private async updateTransferTransaction(transaction: Transaction, manager: EntityManager) { + if (transaction.getHandleTransfers() === false) { + return + } + transaction.setHandleTransfers(false) + + // If the payees, dates, and amounts haven't changed, bail + if ( + transaction.payeeId === transaction.original.payeeId && + transaction.amount === transaction.original.amount && + formatMonthFromDateString(transaction.date) === formatMonthFromDateString(transaction.original.date) + ) { + return + } + + if (transaction.payeeId === transaction.original.payeeId && transaction.transferTransactionId) { + // Payees are the same, just update details + const transferTransaction = await manager.findOne(Transaction, { transferTransactionId: transaction.id }) + transferTransaction.update({ + amount: multiply(transaction.amount, -1), + date: transaction.date, + }) + await manager.getRepository(Transaction).save(transferTransaction) + return + } + + if (transaction.payeeId !== transaction.original.payeeId) { + // If the payee has changed, delete the transfer transaction before proceeding + if (transaction.transferTransactionId) { + const transferTransaction = await manager.findOne(Transaction, { transferTransactionId: transaction.id }) + await manager.remove(Transaction, transferTransaction) + } + + transaction.transferTransactionId = null + + // Now create a new transfer transaction if necessary + const payee = await manager.findOne(Payee, transaction.payeeId) + if (payee.transferAccountId !== null) { + const account = await manager.getRepository(Account).findOne(transaction.accountId) + const transactionPayee = await manager.getRepository(Payee).findOne() + const transferAccount = await manager.getRepository(Account).findOne(payee.transferAccountId) + + const transferTransaction = await manager.getRepository(Transaction).create({ + budgetId: transaction.budgetId, + accountId: transferAccount.id, + payeeId: account.transferPayeeId, + transferAccountId: account.id, + transferTransactionId: transaction.id, + amount: multiply(transaction.amount, -1), + date: transaction.date, + status: TransactionStatus.Pending, + }) + await manager.save(Transaction, transferTransaction) + transaction.transferTransactionId = transferTransaction.id + } + } + } + + private async bookkeepingOnUpdate(transaction: Transaction, manager: EntityManager) { + const account = await transaction.account + + // No bookkeeping necessary for tracking accounts + if (account.type === AccountTypes.Tracking) { + return + } + + // @TODO: hanle update of transactions when going to / from a transfer to / from a non-transfer + + if (transaction.transferTransactionId !== null) { + // If this is a transfer, no need to update categories and budgets. Money doesn't 'go anywhere'. UNLESS it's a CC!!! + if (account.type === AccountTypes.CreditCard) { + // Update CC category + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const originalCCMonth = await manager.findOne(CategoryMonth, { + categoryId: ccCategory.id, + month: formatMonthFromDateString(transaction.original.date), + }) + await originalCCMonth.update({ activity: transaction.original.amount }) + + const currentCCMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate(transaction.budgetId, ccCategory.id, transaction.getMonth()) + currentCCMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getCustomRepository(CategoryMonths).save(currentCCMonth) + } + + return + } + + const category = await transaction.category + const originalCategory = await manager.findOne(Category, transaction.original.categoryId) + + let activity = subtract(transaction.amount, transaction.original.amount) + + if ( + transaction.original.categoryId !== transaction.categoryId || + formatMonthFromDateString(transaction.original.date) !== formatMonthFromDateString(transaction.date) + ) { + // Cat or month has changed so the activity is the entirety of the transaction + activity = transaction.amount + + // Revert original category, if set + if (transaction.original.categoryId) { + if (originalCategory && originalCategory.inflow === false || account.type !== AccountTypes.CreditCard) { + // Category or month has changed, so reset 'original' amount + const originalCategoryMonth = await manager.getRepository(CategoryMonth).findOne( + { categoryId: transaction.original.categoryId, month: formatMonthFromDateString(transaction.original.date) }, + { relations: ['budgetMonth'] }, + ) + + originalCategoryMonth.update({ activity: multiply(transaction.original.amount, -1) }) + await manager.getRepository(CategoryMonth).update(originalCategoryMonth.id, originalCategoryMonth) + } + + if (account.type === AccountTypes.CreditCard) { + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + + /** + * Don't update CC months for original or current CC category month + * if this is inflow. + */ + + if (originalCategory.inflow === false) { + const originalCCMonth = await manager.findOne(CategoryMonth, { + categoryId: ccCategory.id, + month: formatMonthFromDateString(transaction.original.date), + }) + originalCCMonth.update({ activity: transaction.original.amount }) + await manager.getRepository(CategoryMonth).update(originalCCMonth.id, originalCCMonth) + } + } + } + + // Apply to new category + if (transaction.categoryId) { + if (category.inflow === false || account.type !== AccountTypes.CreditCard) { + const transactionCategoryMonth = await manager.getRepository(CategoryMonth).findOne({ categoryId: transaction.categoryId, month: transaction.getMonth() }) + transactionCategoryMonth.update({ activity }) + await manager.getRepository(CategoryMonth).update(transactionCategoryMonth.id, transactionCategoryMonth) + } + + if (account.type === AccountTypes.CreditCard) { + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + + /** + * Don't update CC months for original or current CC category month + * if this is inflow. + */ + + if (category && category.inflow === false) { + const currentCCMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate(transaction.budgetId, ccCategory.id, transaction.getMonth()) + currentCCMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getCustomRepository(CategoryMonths).save(currentCCMonth) + } + } + } + } else { + if (!transaction.categoryId) { + return + } + + if (category.inflow === true && account.type === AccountTypes.CreditCard) { + return + } + + if (account.type === AccountTypes.CreditCard) { + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const currentCCMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate(transaction.budgetId, ccCategory.id, transaction.getMonth()) + currentCCMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getCustomRepository(CategoryMonths).save(currentCCMonth) + } + } + } + + private async bookkeepingOnDelete(transaction: Transaction, manager: EntityManager) { + const account = await manager.getRepository(Account).findOne(transaction.accountId) + + // No bookkeeping necessary for tracking accounts + if (account.type === AccountTypes.Tracking) { + return + } + + const payee = await manager.findOne(Payee, transaction.payeeId) + const transferAccount = payee.transferAccountId ? await manager.findOne(Account, payee.transferAccountId) : null + + // If this is a transfer, no need to update categories and budgets. Money doesn't 'go anywhere'. UNLESS it's a CC!!! + if (transaction.transferTransactionId !== null && transferAccount.type !== AccountTypes.Tracking) { + if (account.type === AccountTypes.CreditCard) { + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const ccCategoryMonth = await manager.findOne(CategoryMonth, { categoryId: ccCategory.id, month: transaction.getMonth() }) + ccCategoryMonth.update({ activity: transaction.amount }) + await manager.getRepository(CategoryMonth).update(ccCategoryMonth.id, ccCategoryMonth) + } + + return + } + + if (!transaction.categoryId) { + return + } + + const category = await manager.findOne(Category, transaction.categoryId) + + if (category.inflow === true && account.type === AccountTypes.CreditCard) { + return + } + + if (transaction.categoryId) { + const originalCategoryMonth = await manager.findOne(CategoryMonth, + { categoryId: transaction.categoryId, month: formatMonthFromDateString(transaction.date) }, + { relations: ['budgetMonth'] }, + ) + originalCategoryMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(originalCategoryMonth.id, originalCategoryMonth) + } + + // Check if we need to update a CC category + if (account.type === AccountTypes.CreditCard) { + // Update CC category + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const ccCategoryMonth = await manager.findOne(CategoryMonth, { categoryId: ccCategory.id, month: transaction.getMonth() }) + ccCategoryMonth.update({ activity: transaction.amount }) + await manager.getRepository(CategoryMonth).update(ccCategoryMonth.id, ccCategoryMonth) + } + } +} diff --git a/ynab/import.js b/ynab/import.js index be3daf0..622eb2e 100644 --- a/ynab/import.js +++ b/ynab/import.js @@ -389,12 +389,12 @@ class YNAB { description: 'Budget CSV Location', name: 'budget', required: true, - default: "/config/budget.csv", + default: "budget.csv", }, { description: 'Register CSV Location', name: 'register', required: true, - default: "/config/register.csv", + default: "register.csv", }] )) const ynab = new YNAB('', '', fileInfo.register, fileInfo.budget) @@ -404,19 +404,7 @@ class YNAB { process.exit(1) } - let category_groups = await ynab.getCategories() - for (let category_group of category_groups) { - // Skip auto-generated CC group / categories - if (category_group.name === 'Credit Card Payments') { - continue - } - - const categoryGroup = await budge.findOrCreateCategoryGroup(category_group.name) - for (let ynab_category of category_group['categories']) { - const category = await budge.findOrCreateCategory(ynab_category.name, categoryGroup.id) - } - } - + const importMap = [] let ynab_accounts = await ynab.getAccounts() for (let ynab_account of ynab_accounts) { const run = (await prompt.get({ @@ -444,8 +432,8 @@ class YNAB { newAccountType = (await prompt.get({ name: 'accounttype', message: 'Account type is unknown, specify the account type: 0 = Bank, 1 = Credit Card, 2 = Tracking', - validator: /0|1|2|skip/, - warning: 'Must respond 0, 1, 2 or skip', + validator: /0|1|2/, + warning: 'Must respond 0, 1, or 2', default: 0, })).accounttype break @@ -454,12 +442,40 @@ class YNAB { continue } - if (!newAccountType || newAccountType === 'skip') { + const transfer = (await prompt.get({ + name: 'transfer', + message: `Run transfers for account ${ynab_account.name}?`, + validator: /y[es]*|n[o]?/, + warning: 'Must respond yes or no', + default: 'no' + })).transfer + + importMap.push({ + ...ynab_account, + run_transfers: transfer, + new_account_type: newAccountType, + }) + } + + let category_groups = await ynab.getCategories() + for (let category_group of category_groups) { + // Skip auto-generated CC group / categories + if (category_group.name === 'Credit Card Payments') { continue } - const transactions = await ynab.getTransactions() + const categoryGroup = await budge.findOrCreateCategoryGroup(category_group.name) + for (let ynab_category of category_group['categories']) { + const category = await budge.findOrCreateCategory(ynab_category.name, categoryGroup.id) + } + } + const transactions = (await ynab.getTransactions()).sort(function(a,b){ + return a.date - b.date + }) + + // Have to create all accounts before importing transactions because we handle transfers too + for (const ynab_account of importMap) { let account = null // Create account with starting balance @@ -467,13 +483,17 @@ class YNAB { if (transaction.payee_name == "Starting Balance" && transaction.account_name === ynab_account.name) { console.log(`Setting starting balance of account ${ynab_account.name} to ${toUnit(transaction.amount)}`) - if (newAccountType == 1) { - account = await budge.findOrCreateAccount(ynab_account.name, newAccountType, multiply(transaction.amount, -1), transaction.date) + if (ynab_account.new_account_type == 1) { + account = await budge.findOrCreateAccount(ynab_account.name, ynab_account.new_account_type, multiply(transaction.amount, -1), transaction.date) } else { - account = await budge.findOrCreateAccount(ynab_account.name, newAccountType, transaction.amount, transaction.date) + account = await budge.findOrCreateAccount(ynab_account.name, ynab_account.new_account_type, transaction.amount, transaction.date) } } } + } + + for (const ynab_account of importMap) { + const account = await budge.findOrCreateAccount(ynab_account.name) // Pull in transactions, but skip transfers until all accounts are in for (let transaction of transactions) { @@ -505,61 +525,47 @@ class YNAB { status: transaction.status, }) } - } - - for (let ynab_account of ynab_accounts) { - const run = (await prompt.get({ - name: 'yesno', - message: `Run transfers for account ${ynab_account.name}?`, - validator: /y[es]*|n[o]?/, - warning: 'Must respond yes or no', - default: 'no' - })).yesno - - if (run === 'no') { - continue - } - - const transactions = await ynab.getTransactions() - - const account = await budge.findOrCreateAccount(ynab_account.name) // Pull in transfer transactions - for (let transaction of transactions) { - if (transaction.account_name !== ynab_account.name) { - continue - } + if (ynab_account.run_transfers === 'yes') { + for (let transaction of transactions) { + if (transaction.account_name !== ynab_account.name) { + continue + } - if (!transaction.payee_name.match(/Transfer : /)) { - continue - } + if (!transaction.payee_name.match(/Transfer : /)) { + continue + } - console.log(`Importing transaction: ${transaction.payee_name}, ${toUnit(transaction.amount)}, ${transaction.date.toISOString().split('T')[0]}`) - let category = null - if (transaction.category_name === 'Ready to Assign') { - transaction.category_name = 'To be Budgeted' - } - if (transaction.category_name) { - category = await budge.findOrCreateCategory(transaction.category_name) - } + console.log(`Importing transaction: ${transaction.payee_name}, ${toUnit(transaction.amount)}, ${transaction.date.toISOString().split('T')[0]}`) + let category = null + if (transaction.category_name === 'Ready to Assign') { + transaction.category_name = 'To be Budgeted' + } + if (transaction.category_name) { + category = await budge.findOrCreateCategory(transaction.category_name) + } - const payee = await budge.findOrCreatePayee(transaction.payee_name) - await budge.postTransaciton({ - accountId: account.id, - payeeId: payee.id, - amount: transaction.amount.toJSON().amount, - date: transaction.date.toISOString().split('T')[0], - memo: transaction.memo, - categoryId: category ? category.id : null, - status: transaction.status, - }) + const payee = await budge.findOrCreatePayee(transaction.payee_name) + await budge.postTransaciton({ + accountId: account.id, + payeeId: payee.id, + amount: transaction.amount.toJSON().amount, + date: transaction.date.toISOString().split('T')[0], + memo: transaction.memo, + categoryId: category ? category.id : null, + status: transaction.status, + }) + } } } const categoryMonths = await ynab.getCategoryMonths() for (const categoryMonth of categoryMonths) { - const category = await budge.findOrCreateCategory(categoryMonth.name) - console.log(`Updating category month ${category.name} - ${categoryMonth.month}: ${categoryMonth.budgeted}`) - await budge.updateCategoryMonth(category.id, categoryMonth.month, categoryMonth.budgeted) + if (parseInt(categoryMonth.budgeted) !== 0) { + const category = await budge.findOrCreateCategory(categoryMonth.name) + console.log(`Updating category month ${category.name} - ${categoryMonth.month}: ${categoryMonth.budgeted}`) + await budge.updateCategoryMonth(category.id, categoryMonth.month, categoryMonth.budgeted) + } } })() From bbfc7b8c03299d152bd26932bb1ce9d1b0b78605 Mon Sep 17 00:00:00 2001 From: Alex Phillips Date: Mon, 13 Dec 2021 06:18:20 -0500 Subject: [PATCH 02/11] more tweaks to entity management, tests now fixed, in UI, budget table categories are now draggable and sortable --- backend/src/controllers/AccountsController.ts | 13 +- backend/src/controllers/BudgetsController.ts | 4 +- .../src/controllers/CategoriesController.ts | 77 +++++- backend/src/controllers/PayeesController.ts | 4 +- backend/src/controllers/RootController.ts | 2 +- .../src/controllers/TransactionsController.ts | 30 ++- backend/src/controllers/UsersController.ts | 4 +- backend/src/controllers/requests.ts | 2 +- backend/src/entities/Account.ts | 15 +- backend/src/entities/Base.ts | 3 - backend/src/entities/Budget.ts | 23 +- backend/src/entities/BudgetMonth.ts | 18 +- backend/src/entities/Category.ts | 26 +- backend/src/entities/CategoryGroup.ts | 23 +- backend/src/entities/CategoryMonth.ts | 51 +++- backend/src/entities/Payee.ts | 10 +- backend/src/entities/Transaction.ts | 126 +++++----- backend/src/entities/User.ts | 5 +- backend/src/entities/index.ts | 5 - backend/src/middleware/authentication.ts | 2 +- backend/src/models/Category.ts | 6 + backend/src/models/CategoryGroup.ts | 8 +- backend/src/repositories/BudgetMonths.ts | 10 +- backend/src/repositories/CategoryMonths.ts | 11 +- backend/src/subscribers/AccountSubscriber.ts | 19 +- backend/src/subscribers/BudgetSubscriber.ts | 12 +- .../subscribers/CategoryMonthSubscriber.ts | 66 ++--- .../src/subscribers/TransactionSubscriber.ts | 209 ++++++++-------- backend/test/BudgE.test.js | 231 +++++++++++------- frontend/src/api.js | 14 +- .../src/components/BudgetTable/BudgetTable.js | 195 ++++++++++----- .../BudgetTable/BudgetTableHeader.js | 1 + frontend/src/components/CategoryForm.js | 1 + frontend/src/components/CategoryGroupForm.js | 1 + frontend/src/components/Drawer.js | 4 +- frontend/src/pages/Account.js | 4 +- frontend/src/redux/slices/Categories.js | 8 +- 37 files changed, 760 insertions(+), 483 deletions(-) delete mode 100644 backend/src/entities/Base.ts delete mode 100644 backend/src/entities/index.ts diff --git a/backend/src/controllers/AccountsController.ts b/backend/src/controllers/AccountsController.ts index 2e0fae7..0b8529b 100644 --- a/backend/src/controllers/AccountsController.ts +++ b/backend/src/controllers/AccountsController.ts @@ -1,5 +1,5 @@ import { Get, Put, Route, Path, Security, Post, Patch, Body, Controller, Tags, Request, Example } from 'tsoa' -import { Budget } from '../entities' +import { Budget } from '../entities/Budget' import { ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { Account, AccountTypes } from '../entities/Account' @@ -53,7 +53,7 @@ export class AccountsController extends Controller { balance: dinero({ amount: requestBody.balance, currency: USD }), budgetId, }) - await getRepository(Account).save(account) + await getRepository(Account).insert(account) // Create a transaction for the starting balance of the account if (requestBody.balance !== 0) { @@ -71,7 +71,6 @@ export class AccountsController extends Controller { } const startingBalancePayee = await getRepository(Payee).findOne({ budgetId, name: 'Starting Balance', internal: true }) - console.log('creating starting balance') const startingBalanceTransaction = getRepository(Transaction).create({ budgetId, accountId: account.id, @@ -82,7 +81,7 @@ export class AccountsController extends Controller { memo: 'Starting Balance', status: TransactionStatus.Reconciled, }) - await getRepository(Transaction).save(startingBalanceTransaction) + await getRepository(Transaction).insert(startingBalanceTransaction) } // Reload account to get the new balanace after the 'initial' transaction was created @@ -144,7 +143,7 @@ export class AccountsController extends Controller { if (requestBody.name !== account.name) { account.name = requestBody.name - await getRepository(Account).update(account.id, account) + await getRepository(Account).update(account.id, account.getUpdatePayload()) } if (requestBody.balance) { @@ -163,14 +162,14 @@ export class AccountsController extends Controller { memo: 'Reconciliation Transaction', status: TransactionStatus.Reconciled, }) - await getRepository(Transaction).save(startingBalanceTransaction) + await getRepository(Transaction).insert(startingBalanceTransaction) } const clearedTransactions = await getRepository(Transaction).find({ accountId: account.id, status: TransactionStatus.Cleared }) console.log(clearedTransactions) for (const transaction of clearedTransactions) { transaction.status = TransactionStatus.Reconciled - await getRepository(Transaction).save(transaction) + await getRepository(Transaction).update(transaction.id, transaction.getUpdatePayload()) } account = await getRepository(Account).findOne(account.id) diff --git a/backend/src/controllers/BudgetsController.ts b/backend/src/controllers/BudgetsController.ts index f10af7a..aff114e 100644 --- a/backend/src/controllers/BudgetsController.ts +++ b/backend/src/controllers/BudgetsController.ts @@ -1,5 +1,5 @@ import { Get, Put, Route, Path, Security, Post, Body, Controller, Tags, Request, Example } from 'tsoa' -import { Budget } from '../entities' +import { Budget } from '../entities/Budget' import { ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { BudgetRequest, BudgetResponse, BudgetsResponse } from '../models/Budget' @@ -86,7 +86,7 @@ export class BudgetsController extends Controller { try { const budget: Budget = getRepository(Budget).create({ ...requestBody }) budget.user = request.user - await getRepository(Budget).save(budget) + await getRepository(Budget).insert(budget) return { message: 'success', diff --git a/backend/src/controllers/CategoriesController.ts b/backend/src/controllers/CategoriesController.ts index 0e5b13c..eeb107a 100644 --- a/backend/src/controllers/CategoriesController.ts +++ b/backend/src/controllers/CategoriesController.ts @@ -1,5 +1,5 @@ import { Get, Put, Route, Path, Security, Post, Body, Controller, Tags, Request, Example } from 'tsoa' -import { Budget } from '../entities' +import { Budget } from '../entities/Budget' import { ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { CategoryGroup } from '../entities/CategoryGroup' @@ -31,6 +31,7 @@ export class CategoriesController extends Controller { name: 'Emergency Fund', locked: false, internal: false, + order: 0, categories: [], created: '2011-10-05T14:48:00.000Z', updated: '2011-10-05T14:48:00.000Z', @@ -74,6 +75,7 @@ export class CategoriesController extends Controller { name: 'Expenses', locked: false, internal: false, + order: 0, categories: [], created: '2011-10-05T14:48:00.000Z', updated: '2011-10-05T14:48:00.000Z', @@ -97,7 +99,7 @@ export class CategoriesController extends Controller { ...requestBody, budgetId, }) - await getRepository(CategoryGroup).save(categoryGroup) + await getRepository(CategoryGroup).insert(categoryGroup) return { message: 'success', @@ -121,6 +123,7 @@ export class CategoriesController extends Controller { name: 'Expenses', locked: false, internal: false, + order: 0, categories: [], created: '2011-10-05T14:48:00.000Z', updated: '2011-10-05T14:48:00.000Z', @@ -143,7 +146,34 @@ export class CategoriesController extends Controller { const categoryGroup = await getRepository(CategoryGroup).findOne(id) categoryGroup.name = requestBody.name - await getRepository(CategoryGroup).update(categoryGroup.id, categoryGroup) + + if (categoryGroup.order !== requestBody.order) { + // re-order category groups + categoryGroup.order = requestBody.order + console.log(categoryGroup.order) + + let categoryGroups = (await getRepository(CategoryGroup).find({ budgetId })).map(group => { + if (group.id === categoryGroup.id) { + return categoryGroup + } + + return group + }) + categoryGroups = categoryGroups.sort((a, b) => { + if (a.order === b.order) { + return a.name > b.name ? -1 : 1 + } + return a.order < b.order ? -1 : 1 + }) + categoryGroups = categoryGroups.map((group, index) => { + group.order = index + return group + }) + + await getRepository(CategoryGroup).save(categoryGroups) + } else { + await getRepository(CategoryGroup).update(categoryGroup.id, categoryGroup.getUpdatePayload()) + } return { message: 'success', @@ -168,6 +198,7 @@ export class CategoriesController extends Controller { name: 'Expenses', inflow: false, locked: false, + order: 0, created: '2011-10-05T14:48:00.000Z', updated: '2011-10-05T14:48:00.000Z', }, @@ -190,7 +221,7 @@ export class CategoriesController extends Controller { ...requestBody, budgetId, }) - await getRepository(Category).save(category) + await getRepository(Category).insert(category) return { message: 'success', @@ -215,6 +246,7 @@ export class CategoriesController extends Controller { name: 'Expenses', inflow: false, locked: false, + order: 0, created: '2011-10-05T14:48:00.000Z', updated: '2011-10-05T14:48:00.000Z', }, @@ -236,13 +268,36 @@ export class CategoriesController extends Controller { const category = await getRepository(Category).findOne(id, { relations: ['categoryGroup'] }) - category.name = requestBody.name - if (category.categoryGroupId !== requestBody.categoryGroupId) { - delete category.categoryGroup - category.categoryGroupId = requestBody.categoryGroupId - } + const originalCategoryGroupId = category.categoryGroupId + const updateOrder = category.categoryGroupId !== requestBody.categoryGroupId || category.order !== requestBody.order - await getRepository(Category).update(category.id, category) + category.name = requestBody.name + category.order = requestBody.order + delete category.categoryGroup + category.categoryGroupId = requestBody.categoryGroupId + + if (updateOrder === true) { + let categories = await getRepository(Category).find({ categoryGroupId: category.categoryGroupId }) + if (originalCategoryGroupId !== category.categoryGroupId) { + categories.push(category) + } else { + categories = categories.map(oldCategory => oldCategory.id === category.id ? category : oldCategory) + } + + categories.sort((a, b) => { + if (a.order === b.order) { + return a.name < b.name ? -1 : 1 + } + return a.order < b.order ? -1 : 1 + }) + categories = categories.map((cat, index) => { + cat.order = index + return cat + }) + await getRepository(Category).save(categories) + } else { + await getRepository(Category).update(category.id, category.getUpdatePayload()) + } return { message: 'success', @@ -290,7 +345,7 @@ export class CategoriesController extends Controller { const categoryMonth = await getCustomRepository(CategoryMonths).findOrCreate(budgetId, categoryId, month) categoryMonth.update({ budgeted: dinero({ amount: requestBody.budgeted, currency: USD }) }) - await getRepository(CategoryMonth).save(categoryMonth) + await getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth.getUpdatePayload()) return { message: 'success', diff --git a/backend/src/controllers/PayeesController.ts b/backend/src/controllers/PayeesController.ts index 35e98cd..15b09b0 100644 --- a/backend/src/controllers/PayeesController.ts +++ b/backend/src/controllers/PayeesController.ts @@ -1,5 +1,5 @@ import { Get, Route, Path, Security, Post, Body, Controller, Tags, Request, Example } from 'tsoa' -import { Budget } from '../entities' +import { Budget } from '../entities/Budget' import { ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { PayeeRequest, PayeeResponse, PayeesResponse } from '../models/Payee' @@ -43,7 +43,7 @@ export class PayeesController extends Controller { ...requestBody, budgetId, }) - await getRepository(Payee).save(payee) + await getRepository(Payee).insert(payee) return { message: 'success', diff --git a/backend/src/controllers/RootController.ts b/backend/src/controllers/RootController.ts index 266ccfa..ed75fc8 100644 --- a/backend/src/controllers/RootController.ts +++ b/backend/src/controllers/RootController.ts @@ -1,6 +1,6 @@ import { LoginResponse, LogoutResponse, UserResponse } from '../models/User' import { Get, Security, Route, Post, Body, Controller, Tags, Example, Request } from 'tsoa' -import { User } from '../entities' +import { User } from '../entities/User' import { LoginRequest, ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { getRepository } from 'typeorm' diff --git a/backend/src/controllers/TransactionsController.ts b/backend/src/controllers/TransactionsController.ts index 9f48192..ba45415 100644 --- a/backend/src/controllers/TransactionsController.ts +++ b/backend/src/controllers/TransactionsController.ts @@ -1,13 +1,13 @@ import { Get, Put, Delete, Route, Path, Security, Post, Body, Controller, Tags, Request, Example } from 'tsoa' -import { Budget } from '../entities' +import { Budget } from '../entities/Budget' import { ExpressRequest } from './requests' import { ErrorResponse } from './responses' import { Account } from '../entities/Account' -import { Transaction, TransactionStatus } from '../entities/Transaction' +import { Transaction, TransactionCache, TransactionStatus } from '../entities/Transaction' import { TransactionRequest, TransactionResponse, TransactionsResponse } from '../models/Transaction' import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' -import { getCustomRepository, getManager, getRepository } from 'typeorm' +import { getManager, getRepository } from 'typeorm' @Tags('Budgets') @Route('budgets/{budgetId}') @@ -53,8 +53,8 @@ export class TransactionsController extends Controller { amount: dinero({ amount: requestBody.amount, currency: USD }), date: new Date(requestBody.date), }) - transaction.setHandleTransfers(true) - await transactionalEntityManager.getRepository(Transaction).save(transaction) + TransactionCache.enableTransfers(transaction.id) + await transactionalEntityManager.getRepository(Transaction).insert(transaction) return transaction }) @@ -107,14 +107,18 @@ export class TransactionsController extends Controller { // Load in original transaction to check if the amount has been altered // and updated the category month accordingly // @TODO: remove relation to test db transactions - const transaction = await getRepository(Transaction).findOne(transactionId, { relations: ['account'] }) - transaction.update({ - ...requestBody, - amount: dinero({ amount: requestBody.amount, currency: USD }), - date: new Date(requestBody.date), // @TODO: this is hacky and I don't like it, but the update keeps date as a string and breaks the sanitize function + const transaction = await getManager().transaction(async transactionalEntityManager => { + const transaction = await transactionalEntityManager.getRepository(Transaction).findOne(transactionId, { relations: ['account'] }) + transaction.update({ + ...requestBody, + amount: dinero({ amount: requestBody.amount, currency: USD }), + ...requestBody.date && { date: new Date(requestBody.date) }, // @TODO: this is hacky and I don't like it, but the update keeps date as a string and breaks the sanitize function + }) + TransactionCache.enableTransfers(transaction.id) + await transactionalEntityManager.getRepository(Transaction).update(transaction.id, transaction.getUpdatePayload()) + + return transaction }) - transaction.setHandleTransfers(true) - await getRepository(Transaction).update(transaction.id, transaction) return { message: 'success', @@ -149,7 +153,7 @@ export class TransactionsController extends Controller { } const transaction = await getRepository(Transaction).findOne(transactionId) - transaction.setHandleTransfers(true) + TransactionCache.enableTransfers(transactionId) await getRepository(Transaction).remove(transaction) return { diff --git a/backend/src/controllers/UsersController.ts b/backend/src/controllers/UsersController.ts index 2b700c3..7e1596f 100644 --- a/backend/src/controllers/UsersController.ts +++ b/backend/src/controllers/UsersController.ts @@ -1,6 +1,6 @@ import { UserResponse } from '../models/User' import { Get, Route, Path, Security, Post, Patch, Body, Controller, Tags, Request, Example } from 'tsoa' -import { User } from '../entities' +import { User } from '../entities/User' import { ExpressRequest, UserCreateRequest, UserUpdateRequest } from './requests' import { ErrorResponse } from './responses' import { getManager, getRepository } from 'typeorm' @@ -33,7 +33,7 @@ export class UsersController extends Controller { try { const newUser = await getManager().transaction(async transactionalEntityManager => { const newUser: User = transactionalEntityManager.getRepository(User).create({ ...requestBody }) - await transactionalEntityManager.getRepository(User).save(newUser) + await transactionalEntityManager.getRepository(User).insert(newUser) return newUser }); diff --git a/backend/src/controllers/requests.ts b/backend/src/controllers/requests.ts index aed1f8f..3b1bbf2 100644 --- a/backend/src/controllers/requests.ts +++ b/backend/src/controllers/requests.ts @@ -1,5 +1,5 @@ import express from 'express' -import { User } from '../entities' +import { User } from '../entities/User' export interface ExpressRequest extends express.Request { user?: User diff --git a/backend/src/entities/Account.ts b/backend/src/entities/Account.ts index 45be271..b7ac14e 100644 --- a/backend/src/entities/Account.ts +++ b/backend/src/entities/Account.ts @@ -76,7 +76,7 @@ export class Account { /** * Has many transactions */ - @OneToMany(() => Transaction, transaction => transaction.account, { cascade: true }) + @OneToMany(() => Transaction, transaction => transaction.account) transactions: Promise /** @@ -86,6 +86,19 @@ export class Account { @JoinColumn() transferPayee: Promise + public getUpdatePayload() { + return { + id: this.id, + budgetId: this.budgetId, + transferPayeeId: this.transferPayeeId, + name: this.name, + type: this.type, + balance: {...this.balance}, + cleared: {...this.cleared}, + uncleared: {...this.uncleared}, + } + } + public async toResponseModel(): Promise { return { id: this.id, diff --git a/backend/src/entities/Base.ts b/backend/src/entities/Base.ts deleted file mode 100644 index c714e50..0000000 --- a/backend/src/entities/Base.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class Base { - // eventsEnabled: boolean = true -} diff --git a/backend/src/entities/Budget.ts b/backend/src/entities/Budget.ts index 153b820..73b84e7 100644 --- a/backend/src/entities/Budget.ts +++ b/backend/src/entities/Budget.ts @@ -6,7 +6,6 @@ import { CreateDateColumn, ManyToOne, OneToMany, - AfterInsert, } from 'typeorm' import { User } from './User' import { Account } from './Account' @@ -14,13 +13,10 @@ import { CategoryGroup } from './CategoryGroup' import { Category } from './Category' import { BudgetMonth } from './BudgetMonth' import { Transaction } from './Transaction' -import { getMonthString, getMonthStringFromNow } from '../utils' -import { Payee } from './Payee' import { Dinero } from '@dinero.js/core' import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' -import { Base } from './Base' @Entity('budgets') export class Budget { @@ -55,33 +51,42 @@ export class Budget { /** * Has many accounts */ - @OneToMany(() => Account, account => account.budget, { cascade: true }) + @OneToMany(() => Account, account => account.budget) accounts: Promise /** * Has many categories */ - @OneToMany(() => Category, category => category.budget, { cascade: true }) + @OneToMany(() => Category, category => category.budget) categories: Promise /** * Has many category groups */ - @OneToMany(() => CategoryGroup, categoryGroup => categoryGroup.budget, { cascade: true }) + @OneToMany(() => CategoryGroup, categoryGroup => categoryGroup.budget) categoryGroups: Promise /** * Has many budget months */ - @OneToMany(() => BudgetMonth, budgetMonth => budgetMonth.budget, { cascade: true }) + @OneToMany(() => BudgetMonth, budgetMonth => budgetMonth.budget) months: Promise /** * Has many budget transactions */ - @OneToMany(() => Transaction, transaction => transaction.budget, { cascade: true }) + @OneToMany(() => Transaction, transaction => transaction.budget) transactions: Promise + public getUpdatePayload() { + return { + id: this.id, + userId: this.userId, + name: this.name, + toBeBudgeted: {...this.toBeBudgeted}, + } + } + public async toResponseModel(): Promise { return { id: this.id, diff --git a/backend/src/entities/BudgetMonth.ts b/backend/src/entities/BudgetMonth.ts index 80b588b..6aaae9e 100644 --- a/backend/src/entities/BudgetMonth.ts +++ b/backend/src/entities/BudgetMonth.ts @@ -1,18 +1,16 @@ import { BudgetMonthModel } from '../models/BudgetMonth' import { Entity, - AfterLoad, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, Index, OneToMany, - BeforeUpdate, } from 'typeorm' import { Budget } from './Budget' import { CategoryMonth } from './CategoryMonth' -import { Dinero, toSnapshot } from '@dinero.js/core' +import { Dinero } from '@dinero.js/core' import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' @@ -73,9 +71,21 @@ export class BudgetMonth { /** * Has man category months */ - @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.budgetMonth, { cascade: true }) + @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.budgetMonth) categories: Promise + public getUpdatePayload() { + return { + id: this.id, + budgetId: this.budgetId, + month: this.month, + income: {...this.income}, + budgeted: {...this.budgeted}, + activity: {...this.activity}, + underfunded: {...this.underfunded}, + } + } + public async toResponseModel(): Promise { return { id: this.id, diff --git a/backend/src/entities/Category.ts b/backend/src/entities/Category.ts index 91abe42..804b284 100644 --- a/backend/src/entities/Category.ts +++ b/backend/src/entities/Category.ts @@ -11,11 +11,10 @@ import { import { CategoryGroup } from './CategoryGroup' import { CategoryMonth } from './CategoryMonth' import { Transaction } from './Transaction' -import { Budget } from '.' -import { Base } from './Base' +import { Budget } from './Budget' @Entity('categories') -export class Category extends Base { +export class Category { @PrimaryGeneratedColumn('uuid') id: string @@ -39,6 +38,9 @@ export class Category extends Base { @Column({ type: 'boolean', default: false }) locked: boolean + @Column({ type: 'int', default: 0 }) + order: number = 0 + @CreateDateColumn() created: Date @@ -60,15 +62,28 @@ export class Category extends Base { /** * Has many months */ - @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.category, { cascade: true }) + @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.category) categoryMonths: CategoryMonth[] /** * Has many transactions */ - @OneToMany(() => Transaction, transaction => transaction.category, { cascade: true }) + @OneToMany(() => Transaction, transaction => transaction.category) transactions: Transaction[] + public getUpdatePayload() { + return { + id: this.id, + budgetId: this.budgetId, + categoryGroupId: this.categoryGroupId, + trackingAccountId: this.trackingAccountId, + name: this.name, + inflow: this.inflow, + locked: this.locked, + order: this.order, + } + } + public async toResponseModel(): Promise { return { id: this.id, @@ -77,6 +92,7 @@ export class Category extends Base { name: this.name, inflow: this.inflow, locked: this.locked, + order: this.order, created: this.created.toISOString(), updated: this.updated.toISOString(), } diff --git a/backend/src/entities/CategoryGroup.ts b/backend/src/entities/CategoryGroup.ts index 5bc0759..38ccc9c 100644 --- a/backend/src/entities/CategoryGroup.ts +++ b/backend/src/entities/CategoryGroup.ts @@ -3,22 +3,18 @@ import { Entity, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, OneToMany, Index, - PrimaryColumn, - BeforeInsert, } from 'typeorm' import { Budget } from './Budget' import { Category } from './Category' -import { Base } from './Base' export const CreditCardGroupName = 'Credit Card Payments' @Entity('category_groups') -export class CategoryGroup extends Base { +export class CategoryGroup { @PrimaryGeneratedColumn('uuid') id: string @@ -35,6 +31,9 @@ export class CategoryGroup extends Base { @Column({ type: 'boolean', default: false }) locked: boolean + @Column({ type: 'int', default: 0 }) + order: number = 0 + @CreateDateColumn() created: Date @@ -50,9 +49,20 @@ export class CategoryGroup extends Base { /** * Has many categories */ - @OneToMany(() => Category, category => category.categoryGroup, { cascade: true, eager: true }) + @OneToMany(() => Category, category => category.categoryGroup, { eager: true }) categories: Promise + public getUpdatePayload() { + return { + id: this.id, + budgetId: this.budgetId, + name: this.name, + internal: this.internal, + locked: this.locked, + order: this.order, + } + } + public async toResponseModel(): Promise { return { id: this.id, @@ -60,6 +70,7 @@ export class CategoryGroup extends Base { name: this.name, internal: this.internal, locked: this.locked, + order: this.order, categories: await Promise.all((await this.categories).map(category => category.toResponseModel())), created: this.created.toISOString(), updated: this.updated.toISOString(), diff --git a/backend/src/entities/CategoryMonth.ts b/backend/src/entities/CategoryMonth.ts index bc82403..4f1754c 100644 --- a/backend/src/entities/CategoryMonth.ts +++ b/backend/src/entities/CategoryMonth.ts @@ -14,7 +14,6 @@ import { Dinero } from '@dinero.js/core' import { add, dinero, subtract } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' -import { Base } from './Base' export type CategoryMonthOriginalValues = { budgeted: Dinero @@ -22,8 +21,34 @@ export type CategoryMonthOriginalValues = { balance: Dinero } +export class CategoryMonthCache { + static cache: { [key: string]: CategoryMonthOriginalValues } = {} + + static transfers: string[] = [] + + public static get(id: string): CategoryMonthOriginalValues | null { + if (CategoryMonthCache.cache[id]) { + return CategoryMonthCache.cache[id] + } + + return { + budgeted: dinero({ amount: 0, currency: USD }), + activity: dinero({ amount: 0, currency: USD }), + balance: dinero({ amount: 0, currency: USD }), + } + } + + public static set(categoryMonth: CategoryMonth) { + CategoryMonthCache.cache[categoryMonth.id] = { + budgeted: {...categoryMonth.budgeted}, + activity: {...categoryMonth.activity}, + balance: {...categoryMonth.balance}, + } + } +} + @Entity('category_months') -export class CategoryMonth extends Base { +export class CategoryMonth { @PrimaryGeneratedColumn('uuid') id: string @@ -78,17 +103,21 @@ export class CategoryMonth extends Base { @ManyToOne(() => BudgetMonth, budgetMonth => budgetMonth.categories) budgetMonth: Promise - original: CategoryMonthOriginalValues = { - budgeted: dinero({ amount: 0, currency: USD }), - activity: dinero({ amount: 0, currency: USD }), - balance: dinero({ amount: 0, currency: USD }), - } - @AfterLoad() private storeOriginalValues(): void { - this.original.budgeted = { ...this.budgeted } - this.original.activity = { ...this.activity } - this.original.balance = { ...this.balance } + CategoryMonthCache.set(this) + } + + public getUpdatePayload() { + return { + id: this.id, + categoryId: this.categoryId, + budgetMonthId: this.budgetMonthId, + month: this.month, + budgeted: this.budgeted, + activity: this.activity, + balance: this.balance, + } } public update({ activity, budgeted }: { [key: string]: Dinero }) { diff --git a/backend/src/entities/Payee.ts b/backend/src/entities/Payee.ts index baede89..b3cb82f 100644 --- a/backend/src/entities/Payee.ts +++ b/backend/src/entities/Payee.ts @@ -4,19 +4,15 @@ import { JoinColumn, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, OneToMany, - PrimaryColumn, - BeforeInsert, } from 'typeorm' -import { Account } from '.' +import { Account } from './Account' import { PayeeModel } from '../models/Payee' import { Transaction } from './Transaction' -import { Base } from './Base' @Entity('payees') -export class Payee extends Base { +export class Payee { @PrimaryGeneratedColumn('uuid') id: string @@ -45,7 +41,7 @@ export class Payee extends Base { /** * Has many transactions */ - @OneToMany(() => Transaction, transaction => transaction.account, { cascade: true }) + @OneToMany(() => Transaction, transaction => transaction.account) transactions: Promise public async toResponseModel(): Promise { diff --git a/backend/src/entities/Transaction.ts b/backend/src/entities/Transaction.ts index 74a9ff5..5e229bc 100644 --- a/backend/src/entities/Transaction.ts +++ b/backend/src/entities/Transaction.ts @@ -2,33 +2,22 @@ import { TransactionModel } from '../models/Transaction' import { Entity, AfterLoad, - AfterRemove, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, DeepPartial, - AfterInsert, - BeforeInsert, - AfterUpdate, - BeforeUpdate, - BeforeRemove, - PrimaryColumn, - getRepository, Index, } from 'typeorm' -import { Account, AccountTypes } from './Account' +import { Account } from './Account' import { Category } from './Category' import { formatMonthFromDateString } from '../utils' -import { CategoryMonth } from './CategoryMonth' -import { Budget } from '.' +import { Budget } from './Budget' import { Payee } from './Payee' import { Dinero } from '@dinero.js/core' -import { add, dinero, multiply, subtract, isPositive } from 'dinero.js' +import { dinero } from 'dinero.js' import { USD } from '@dinero.js/currencies' import { CurrencyDBTransformer } from '../models/Currency' -import { Base } from './Base' export enum TransactionStatus { Pending, @@ -36,11 +25,6 @@ export enum TransactionStatus { Reconciled, } -export type TransactionFlags = { - handleTransfers: boolean - eventsEnabled: boolean -} - export type TransactionOriginalValues = { payeeId: string categoryId: string @@ -49,8 +33,55 @@ export type TransactionOriginalValues = { status: TransactionStatus } +export class TransactionCache { + static cache: { [key: string]: TransactionOriginalValues } = {} + + static transfers: string[] = [] + + public static get(id: string): TransactionOriginalValues | null { + if (TransactionCache.cache[id]) { + return TransactionCache.cache[id] + } + + return null + } + + public static set(transaction: Transaction) { + TransactionCache.cache[transaction.id] = { + payeeId: transaction.payeeId, + categoryId: transaction.categoryId, + amount: {...transaction.amount}, + date: new Date(transaction.date.getTime()), + status: transaction.status, + } + } + + public static enableTransfers(id: string) { + const index = TransactionCache.transfers.indexOf(id); + if (index === -1) { + TransactionCache.transfers.push(id) + } + } + + public static disableTransfers(id: string) { + const index = TransactionCache.transfers.indexOf(id); + if (index > -1) { + TransactionCache.transfers.splice(index, 1); + } + } + + public static transfersEnabled(id: string): boolean { + const index = TransactionCache.transfers.indexOf(id); + if (index > -1) { + return true + } + + return false + } +} + @Entity('transactions') -export class Transaction extends Base { +export class Transaction { @PrimaryGeneratedColumn('uuid') id: string @@ -119,48 +150,31 @@ export class Transaction extends Base { @ManyToOne(() => Category, category => category.transactions) category: Promise - flags: TransactionFlags = { - handleTransfers: false, - eventsEnabled: true, - } - - original: TransactionOriginalValues = { - payeeId: '', - categoryId: '', - amount: dinero({ amount: 0, currency: USD }), - date: new Date(), - status: 0, - } - @AfterLoad() private storeOriginalValues() { - this.original.payeeId = this.payeeId - this.original.categoryId = this.categoryId - this.original.amount = { ...this.amount } - this.original.date = { ...this.date } - this.original.status = this.status - } - - public getHandleTransfers(): boolean { - return this.flags.handleTransfers - } - - public setHandleTransfers(enabled: boolean) { - this.flags.handleTransfers = enabled - } - - public getEventsEnabled(): boolean { - return this.flags.eventsEnabled - } - - public setEventsEnabled(enabled: boolean) { - this.flags.eventsEnabled = enabled + TransactionCache.set(this) } public update(partial: DeepPartial) { Object.assign(this, partial) } + public getUpdatePayload() { + return { + id: this.id, + budgetId: this.budgetId, + accountId: this.accountId, + payeeId: this.payeeId, + transferAccountId: this.transferAccountId, + transferTransactionId: this.transferTransactionId, + categoryId: this.categoryId, + amount: this.amount, + date: this.date, + memo: this.memo, + status: this.status, + } + } + public async toResponseModel(): Promise { return { id: this.id, @@ -176,7 +190,7 @@ export class Transaction extends Base { } } - public getMonth(): string { - return formatMonthFromDateString(this.date) + public static getMonth(date: Date): string { + return formatMonthFromDateString(date) } } diff --git a/backend/src/entities/User.ts b/backend/src/entities/User.ts index 9f707f4..2fa48c3 100644 --- a/backend/src/entities/User.ts +++ b/backend/src/entities/User.ts @@ -2,21 +2,18 @@ import { Entity, PrimaryGeneratedColumn, Column, - BaseEntity, AfterLoad, BeforeUpdate, BeforeInsert, Index, CreateDateColumn, OneToMany, - PrimaryColumn, } from 'typeorm' import bcrypt from 'bcrypt' import jwt from 'jsonwebtoken' import config from '../config' import { UserModel } from '../models/User' import { Budget } from './Budget' -import { Base } from './Base' @Entity('users') export class User { @@ -32,7 +29,7 @@ export class User { private currentPassword: string - @OneToMany(() => Budget, budget => budget.user, { cascade: true }) + @OneToMany(() => Budget, budget => budget.user) budgets: Budget[] @CreateDateColumn() diff --git a/backend/src/entities/index.ts b/backend/src/entities/index.ts deleted file mode 100644 index b213a3e..0000000 --- a/backend/src/entities/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { User } from './User' -import { Budget } from './Budget' -import { Account } from './Account' - -export { User, Budget, Account } diff --git a/backend/src/middleware/authentication.ts b/backend/src/middleware/authentication.ts index 19276c1..7cb1bd7 100644 --- a/backend/src/middleware/authentication.ts +++ b/backend/src/middleware/authentication.ts @@ -1,6 +1,6 @@ import jwt from 'jsonwebtoken' import { Request } from 'express' -import { User } from '../entities' +import { User } from '../entities/User' import config from '../config' import { logger } from '../config/winston' import { getRepository } from 'typeorm' diff --git a/backend/src/models/Category.ts b/backend/src/models/Category.ts index e12965b..5842391 100644 --- a/backend/src/models/Category.ts +++ b/backend/src/models/Category.ts @@ -42,6 +42,11 @@ export interface CategoryModel { */ locked: boolean + /** + * Category ordering + */ + order: number + /** * Datetime transaction was created */ @@ -62,6 +67,7 @@ export interface CategoryModel { export interface CategoryRequest { categoryGroupId: string name: string + order: number } export type CategoryResponse = DataResponse diff --git a/backend/src/models/CategoryGroup.ts b/backend/src/models/CategoryGroup.ts index ce5e0b9..11f8a50 100644 --- a/backend/src/models/CategoryGroup.ts +++ b/backend/src/models/CategoryGroup.ts @@ -37,6 +37,11 @@ export interface CategoryGroupModel { */ locked: boolean + /** + * Category group ordering + */ + order: number + /** * Child categories */ @@ -57,11 +62,12 @@ export interface CategoryGroupModel { * @example { * "categoryGroupId": "abc123", * "name": "Emergency Fund", - * "categories": [] + * "order": 0, * } */ export interface CategoryGroupRequest { name: string + order: number } export type CategoryGroupResponse = DataResponse diff --git a/backend/src/repositories/BudgetMonths.ts b/backend/src/repositories/BudgetMonths.ts index 33bfdc5..5dfdbff 100644 --- a/backend/src/repositories/BudgetMonths.ts +++ b/backend/src/repositories/BudgetMonths.ts @@ -1,7 +1,7 @@ -import { Budget } from "../entities"; -import { EntityRepository, EntityTarget, Repository } from "typeorm"; -import { BudgetMonth } from "../entities/BudgetMonth"; -import { formatMonthFromDateString } from "../utils"; +import { Budget } from "../entities/Budget" +import { EntityRepository, Repository } from "typeorm" +import { BudgetMonth } from "../entities/BudgetMonth" +import { formatMonthFromDateString } from "../utils" @EntityRepository(BudgetMonth) export class BudgetMonths extends Repository { @@ -32,7 +32,7 @@ export class BudgetMonths extends Repository { budgetId, month: formatMonthFromDateString(monthFrom), }) - await this.save(newBudgetMonth) + await this.insert(newBudgetMonth) newBudgetMonth.budget = Promise.resolve(budget) } while (newBudgetMonth.month !== month) diff --git a/backend/src/repositories/CategoryMonths.ts b/backend/src/repositories/CategoryMonths.ts index d4965db..0333aa8 100644 --- a/backend/src/repositories/CategoryMonths.ts +++ b/backend/src/repositories/CategoryMonths.ts @@ -23,19 +23,10 @@ export class CategoryMonths extends Repository { balance: dinero({ amount: 0, currency: USD }), budgeted: dinero({ amount: 0, currency: USD }), }) - await this.save(categoryMonth) + await this.insert(categoryMonth) categoryMonth.budgetMonth = Promise.resolve(budgetMonth) } return categoryMonth } - - async update(categoryMonth: CategoryMonth): Promise { - const originalValues = categoryMonth.original - delete categoryMonth.original - const result = await this.manager.getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth) - categoryMonth.original = originalValues - - return result - } } diff --git a/backend/src/subscribers/AccountSubscriber.ts b/backend/src/subscribers/AccountSubscriber.ts index 4aec001..c20997e 100644 --- a/backend/src/subscribers/AccountSubscriber.ts +++ b/backend/src/subscribers/AccountSubscriber.ts @@ -1,7 +1,4 @@ -import { Budget } from "../entities/Budget"; -import { EntitySubscriberInterface, EventSubscriber, getManager, InsertEvent, UpdateEvent } from "typeorm"; -import { getMonthString, getMonthStringFromNow } from "../utils"; -import { BudgetMonth } from "../entities/BudgetMonth"; +import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from "typeorm"; import { CategoryGroup, CreditCardGroupName } from "../entities/CategoryGroup"; import { Category } from "../entities/Category"; import { Payee } from "../entities/Payee"; @@ -11,12 +8,14 @@ import { add } from "dinero.js"; @EventSubscriber() export class AccountSubscriber implements EntitySubscriberInterface { listenTo() { - return Account; + return Account; } async afterInsert(event: InsertEvent) { - await this.createCreditCardCategory(event) - await this.createAccountPayee(event) + await Promise.all([ + this.createCreditCardCategory(event), + this.createAccountPayee(event), + ]) } private async createAccountPayee(event: InsertEvent) { @@ -30,9 +29,9 @@ export class AccountSubscriber implements EntitySubscriberInterface { }) // @TODO: I wish there was a better way around this - await manager.save(Payee, payee) + await manager.insert(Payee, payee) account.transferPayeeId = payee.id - await manager.save(Account, account) + await manager.update(Account, account.id, account.getUpdatePayload()) } private async createCreditCardCategory(event: InsertEvent) { @@ -62,7 +61,7 @@ export class AccountSubscriber implements EntitySubscriberInterface { name: account.name, locked: true, }) - await manager.save(Category, paymentCategory) + await manager.insert(Category, paymentCategory) } } diff --git a/backend/src/subscribers/BudgetSubscriber.ts b/backend/src/subscribers/BudgetSubscriber.ts index 47661d6..f28a846 100644 --- a/backend/src/subscribers/BudgetSubscriber.ts +++ b/backend/src/subscribers/BudgetSubscriber.ts @@ -1,5 +1,5 @@ import { Budget } from "../entities/Budget"; -import { EntitySubscriberInterface, EventSubscriber, getManager, InsertEvent } from "typeorm"; +import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from "typeorm"; import { getMonthString, getMonthStringFromNow } from "../utils"; import { BudgetMonth } from "../entities/BudgetMonth"; import { CategoryGroup } from "../entities/CategoryGroup"; @@ -23,7 +23,7 @@ export class BudgetSubscriber implements EntitySubscriberInterface { // Create initial budget months for (const month of [prevMonth, today, nextMonth]) { const newBudgetMonth = manager.create(BudgetMonth, { budgetId: budget.id, month }) - await manager.save(BudgetMonth, newBudgetMonth) + await manager.insert(BudgetMonth, newBudgetMonth) } // Create internal categories @@ -33,7 +33,7 @@ export class BudgetSubscriber implements EntitySubscriberInterface { internal: true, locked: true, }) - await manager.save(CategoryGroup, internalCategoryGroup) + await manager.insert(CategoryGroup, internalCategoryGroup) await Promise.all( ['To be Budgeted'].map(name => { @@ -44,7 +44,7 @@ export class BudgetSubscriber implements EntitySubscriberInterface { inflow: true, locked: true, }) - return manager.save(Category, internalCategory) + return manager.insert(Category, internalCategory) }), ) @@ -54,13 +54,13 @@ export class BudgetSubscriber implements EntitySubscriberInterface { name: 'Starting Balance', internal: true, }) - await manager.save(Payee, startingBalancePayee) + await manager.insert(Payee, startingBalancePayee) const reconciliationPayee = manager.create(Payee, { budgetId: budget.id, name: 'Reconciliation Balance Adjustment', internal: true, }) - await manager.save(Payee, reconciliationPayee) + await manager.insert(Payee, reconciliationPayee) } } diff --git a/backend/src/subscribers/CategoryMonthSubscriber.ts b/backend/src/subscribers/CategoryMonthSubscriber.ts index 6dd0062..cbcec0c 100644 --- a/backend/src/subscribers/CategoryMonthSubscriber.ts +++ b/backend/src/subscribers/CategoryMonthSubscriber.ts @@ -1,32 +1,18 @@ import { Budget } from "../entities/Budget"; -import { EntityManager, EntitySubscriberInterface, EventSubscriber, getManager, InsertEvent, Repository, UpdateEvent } from "typeorm"; -import { formatMonthFromDateString, getDateFromString, getMonthString, getMonthStringFromNow } from "../utils"; +import { EntityManager, EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString, getDateFromString } from "../utils"; import { BudgetMonth } from "../entities/BudgetMonth"; -import { CategoryGroup, CreditCardGroupName } from "../entities/CategoryGroup"; import { Category } from "../entities/Category"; -import { Payee } from "../entities/Payee"; -import { Account, AccountTypes } from "../entities/Account"; -import { add, equal, isNegative, isPositive, subtract } from "dinero.js"; -import { CategoryMonth, CategoryMonthOriginalValues } from "../entities/CategoryMonth"; +import { add, isZero, isNegative, isPositive, subtract, equal } from "dinero.js"; +import { CategoryMonth, CategoryMonthCache } from "../entities/CategoryMonth"; import { CategoryMonths } from "../repositories/CategoryMonths"; -let originalValues: CategoryMonthOriginalValues - @EventSubscriber() export class CategoryMonthSubscriber implements EntitySubscriberInterface { listenTo() { return CategoryMonth; } - storeTransientValues(entity: CategoryMonth) { - originalValues = entity.original - delete entity.original - } - - restoreTransientValues(entity: CategoryMonth) { - entity.original = originalValues - } - /** * Get the previous month's 'balance' as this will be the 'carry over' amount for this new month */ @@ -43,23 +29,13 @@ export class CategoryMonthSubscriber implements EntitySubscriberInterface) { - this.storeTransientValues(event.entity as CategoryMonth) } async afterInsert(event: InsertEvent) { - this.restoreTransientValues(event.entity) - await this.bookkeeping(event.entity as CategoryMonth, event.manager) } async afterUpdate(event: UpdateEvent) { - this.restoreTransientValues(event.entity as CategoryMonth) - await this.bookkeeping(event.entity as CategoryMonth, event.manager) } @@ -71,39 +47,51 @@ export class CategoryMonthSubscriber implements EntitySubscriberInterface) { - if (event.entity.getEventsEnabled() === false) { - return - } - - await this.checkCreateTransferTransaction(event.entity as Transaction, event.manager) - await this.createCategoryMonth(event.entity as Transaction, event.manager) + await Promise.all([ + this.checkCreateTransferTransaction(event.entity as Transaction, event.manager), + this.createCategoryMonth(event.entity as Transaction, event.manager), + ]) } async beforeUpdate(event: UpdateEvent) { - if (event.entity.getEventsEnabled() === false) { - return - } + await Promise.all([ + this.createCategoryMonth(event.entity as Transaction, event.manager), + this.updateTransferTransaction(event.entity as Transaction, event.manager), - await this.createCategoryMonth(event.entity as Transaction, event.manager) - await this.updateTransferTransaction(event.entity as Transaction, event.manager) + this.updateAccountBalanceOnUpdate(event.entity as Transaction, event.manager), + this.bookkeepingOnUpdate(event.entity as Transaction, event.manager), + ]) } async afterInsert(event: InsertEvent) { - if (event.entity.getEventsEnabled() === false) { - return - } - - await this.updateAccountBalanceOnAdd(event.entity as Transaction, event.manager) - await this.bookkeepingOnAdd(event.entity as Transaction, event.manager) - await this.createTransferTransaction(event.entity as Transaction, event.manager) + await Promise.all([ + this.updateAccountBalanceOnAdd(event.entity as Transaction, event.manager), + this.bookkeepingOnAdd(event.entity as Transaction, event.manager), + this.createTransferTransaction(event.entity as Transaction, event.manager), + ]) } async afterUpdate(event: UpdateEvent) { - if (event.entity.getEventsEnabled() === false) { - return - } + await Promise.all([ - await this.updateAccountBalanceOnUpdate(event.entity as Transaction, event.manager) - await this.bookkeepingOnUpdate(event.entity as Transaction, event.manager) + ]) } async beforeRemove(event: RemoveEvent) { - if (event.entity.getEventsEnabled() === false) { - return - } - const transaction = event.entity const manager = event.manager @@ -72,19 +59,18 @@ export class TransactionSubscriber implements EntitySubscriberInterface) { - if (event.entity.getEventsEnabled() === false) { - return - } - - await this.updateAccountBalanceOnRemove(event.entity as Transaction, event.manager) - await this.bookkeepingOnDelete(event.entity as Transaction, event.manager) + await Promise.all([ + this.updateAccountBalanceOnRemove(event.entity as Transaction, event.manager), + this.bookkeepingOnDelete(event.entity as Transaction, event.manager), + ]) } async checkCreateTransferTransaction(transaction: Transaction, manager: EntityManager) { - if (transaction.getHandleTransfers() === false) { + // This is only called on INSERT. If the id is null AND the transferAccountId is null, + // then this is the 'origin' transfer transaction + if (transaction.transferAccountId) { return } - transaction.setHandleTransfers(false) const payee = await manager.findOne(Payee, transaction.payeeId) if (payee.transferAccountId === null) { @@ -115,7 +101,7 @@ export class TransactionSubscriber implements EntitySubscriberInterface { await createConnection({ @@ -25,32 +27,32 @@ beforeAll(async () => { emitDecoratorMetadata: true, }) - await User.create({ + const user = getRepository(User).create({ email: 'test@example.com', password: 'password', - }).save() + }) + await getRepository(User).save(user) - const user = await User.findOne({ email: 'test@example.com' }) - - const budget = Budget.create({ + const budget = getRepository(Budget).create({ id: 'test-budget', userId: user.id, name: 'My Budget', }) - await budget.save() + await getRepository(Budget).save(budget) - const categoryGroup = CategoryGroup.create({ + const categoryGroup = getRepository(CategoryGroup).create({ budgetId: budget.id, name: 'Bills', }) - await categoryGroup.save() + await getRepository(CategoryGroup).save(categoryGroup) await Promise.all(['Power', 'Water'].map(async catName => { - return Category.create({ + const newCategory = getRepository(Category).create({ id: `test-${catName.toLowerCase()}`, budgetId: budget.id, categoryGroupId: categoryGroup.id, name: catName, - }).save() + }) + return getRepository(Category).insert(newCategory) })) }) @@ -61,40 +63,49 @@ afterAll(() => { describe("Budget Tests", () => { it("Should budget a positive category and cascade to the next month", async () => { - const budget = await Budget.findOne({ id: 'test-budget' }) - const category = await Category.findOne({ id: 'test-power' }) + const budget = await getRepository(Budget).findOne('test-budget') + const category = await getRepository(Category).findOne('test-power') const thisMonth = formatMonthFromDateString(new Date()) const nextMonth = getMonthStringFromNow(1) - const categoryMonth = await CategoryMonth.findOrCreate(budget.id, category.id, thisMonth) + const categoryMonth = await getCustomRepository(CategoryMonths).findOrCreate(budget.id, category.id, thisMonth) - await categoryMonth.update({ budgeted: 25}) + categoryMonth.update({ budgeted: dinero({ amount: 25, currency: USD })}) + await getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth.getUpdatePayload()) - const nextCategoryMonth = await CategoryMonth.findOne({ categoryId: category.id, month: nextMonth }) + let nextCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: category.id, month: nextMonth }) - expect(nextCategoryMonth.balance).toBe(25) + expect(nextCategoryMonth.balance.toJSON().amount).toBe(25) - await categoryMonth.update({ budgeted: -25 }) - await nextCategoryMonth.reload() + categoryMonth.update({ budgeted: dinero({ amount: -25, currency: USD }) }) + await getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth.getUpdatePayload()) + nextCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: category.id, month: nextMonth }) - expect(categoryMonth.budgeted).toBe(-25) - expect(nextCategoryMonth.balance).toBe(0) + expect(categoryMonth.budgeted.toJSON().amount).toBe(-25) + expect(nextCategoryMonth.balance.toJSON().amount).toBe(0) + + await categoryMonth.update({ budgeted: dinero({ amount: 0, currency: USD }) }) + await getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth.getUpdatePayload()) + nextCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: category.id, month: nextMonth }) + + expect(categoryMonth.budgeted.toJSON().amount).toBe(0) + expect(nextCategoryMonth.balance.toJSON().amount).toBe(0) }) it("Income should add to TBB and remove on a deleted transaction", async () => { - const budget = await Budget.findOne({ id: 'test-budget' }) - let account = Account.create({ + let budget = await getRepository(Budget).findOne({ id: 'test-budget' }) + let account = getRepository(Account).create({ budgetId: budget.id, type: AccountTypes.Bank, name: "Checking", }) - await account.save() + await getRepository(Account).insert(account) - const category = await Category.findOne({ budgetId: budget.id, inflow: true }) + const category = await getRepository(Category).findOne({ budgetId: budget.id, inflow: true }) - const payee = await Payee.findOne({ name: "Starting Balance", internal: true}) - const transaction = Transaction.create({ + const payee = await getRepository(Payee).findOne({ name: "Starting Balance", internal: true}) + const transaction = getRepository(Transaction).create({ budgetId: budget.id, accountId: account.id, categoryId: category.id, @@ -102,58 +113,62 @@ describe("Budget Tests", () => { amount: 100, date: new Date(), }) - await transaction.save() - await account.reload() - await budget.reload() + await getRepository(Transaction).insert(transaction) - expect(account.balance).toBe(100) - expect(budget.toBeBudgeted).toBe(100) + account = await getRepository(Account).findOne(account.id) + budget = await getRepository(Budget).findOne(budget.id) - await transaction.remove() - await budget.reload() - await account.reload() + expect(account.balance.toJSON().amount).toBe(100) + expect(budget.toBeBudgeted.toJSON().amount).toBe(100) - expect(budget.toBeBudgeted).toBe(0) - expect(account.balance).toBe(0) + await getRepository(Transaction).remove(transaction) + account = await getRepository(Account).findOne(account.id) + budget = await getRepository(Budget).findOne(budget.id) + + expect(budget.toBeBudgeted.toJSON().amount).toBe(0) + expect(account.balance.toJSON().amount).toBe(0) }) it("Transfer transaction should not affect TBB", async () => { - const budget = await Budget.findOne({ id: 'test-budget' }) - const account = Account.create({ + let budget = await getRepository(Budget).findOne({ id: 'test-budget' }) + const account = getRepository(Account).create({ budgetId: budget.id, type: AccountTypes.Bank, name: "Savings", }) - await account.save() + await getRepository(Account).insert(account) - // Inflow category - const category = await Category.findOne({ budgetId: budget.id, inflow: true }) - const payee = await Payee.findOne({ name: "Starting Balance", internal: true}) + // Inflow category + const category = await getRepository(Category).findOne({ budgetId: budget.id, inflow: true }) + const payee = await getRepository(Payee).findOne({ name: "Starting Balance", internal: true}) // checking account - const checkingAccount = await Account.findOne({ budgetId: budget.id, name: "Checking" }) - await Transaction.create({ + let checkingAccount = await getRepository(Account).findOne({ budgetId: budget.id, name: "Checking" }) + // starting balance of 100 + const transaction = getRepository(Transaction).create({ budgetId: budget.id, accountId: checkingAccount.id, categoryId: category.id, payeeId: payee.id, amount: 100, date: new Date(), - }).save() + }) + await getRepository(Transaction).insert(transaction) // create savings account - const savingsAccount = Account.create({ + let savingsAccount = getRepository(Account).create({ budgetId: budget.id, type: AccountTypes.Bank, name: "Savings", }) - await savingsAccount.save() + await getRepository(Account).insert(savingsAccount) - await checkingAccount.reload() + checkingAccount = await getRepository(Account).findOne({ budgetId: budget.id, name: "Checking" }) - const transferTransaction = Transaction.create({ + // transfer 50 checking -> savings + let transferTransaction = getRepository(Transaction).create({ budgetId: budget.id, accountId: checkingAccount.id, categoryId: null, @@ -161,46 +176,47 @@ describe("Budget Tests", () => { amount: -50, date: new Date(), }) - transferTransaction.handleTransfers = true - await transferTransaction.save() + await getRepository(Transaction).insert(transferTransaction) - await checkingAccount.reload() - await savingsAccount.reload() - await budget.reload() + checkingAccount = await getRepository(Account).findOne(checkingAccount.id) + savingsAccount = await getRepository(Account).findOne(savingsAccount.id) + budget = await getRepository(Budget).findOne(budget.id) - expect(budget.toBeBudgeted).toBe(100) - expect(checkingAccount.balance).toBe(50) - expect(savingsAccount.balance).toBe(50) + expect(budget.toBeBudgeted.toJSON().amount).toBe(100) + expect(checkingAccount.balance.toJSON().amount).toBe(50) + expect(savingsAccount.balance.toJSON().amount).toBe(50) - await (await Transaction.findOne(transferTransaction.id)).remove() - await checkingAccount.reload() - await savingsAccount.reload() - await budget.reload() + // Remove transfer transaction (100 savings back to checking) + await getRepository(Transaction).remove(transferTransaction) + checkingAccount = await getRepository(Account).findOne(checkingAccount.id) + savingsAccount = await getRepository(Account).findOne(savingsAccount.id) + budget = await getRepository(Budget).findOne(budget.id) - expect(budget.toBeBudgeted).toBe(100) - expect(checkingAccount.balance).toBe(100) - expect(savingsAccount.balance).toBe(0) + expect(budget.toBeBudgeted.toJSON().amount).toBe(100) + expect(checkingAccount.balance.toJSON().amount).toBe(100) + expect(savingsAccount.balance.toJSON().amount).toBe(0) }) it("Credit card transactions affect their category", async () => { - const budget = await Budget.findOne({ id: 'test-budget' }) - const account = Account.create({ + let budget = await getRepository(Budget).findOne({ id: 'test-budget' }) + let account = getRepository(Account).create({ budgetId: budget.id, type: AccountTypes.CreditCard, name: "Visa", }) - await account.save() + await getRepository(Account).insert(account) - const paymentCategory = await Category.findOne({ id: 'test-power' }) + const paymentCategory = await getRepository(Category).findOne({ id: 'test-power' }) - const payee = Payee.create({ + const payee = getRepository(Payee).create({ budgetId: budget.id, name: 'Power company', }) - await payee.save() + await getRepository(Payee).insert(payee) - const ccTransaction = Transaction.create({ + // pay 50 to test-power from credit card + const ccTransaction = getRepository(Transaction).create({ budgetId: budget.id, accountId: account.id, categoryId: paymentCategory.id, @@ -208,25 +224,28 @@ describe("Budget Tests", () => { amount: -50, date: new Date(), }) - await ccTransaction.save() + await getRepository(Transaction).insert(ccTransaction) - await account.reload() + const ccCategory = await getRepository(Category).findOne({ trackingAccountId: account.id }) + const ccCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: ccCategory.id, month: Transaction.getMonth(ccTransaction.date) }) - const ccCategory = await Category.findOne({ trackingAccountId: account.id }) - const ccCategoryMonth = await CategoryMonth.findOne({ categoryId: ccCategory.id, month: ccTransaction.getMonth() }) + account = await getRepository(Account).findOne(account.id) + const paymentCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: 'test-power', month: Transaction.getMonth(ccTransaction.date) }) - expect(ccCategoryMonth.balance).toBe(50) - expect((await (await Account.findOne({ name: 'Visa' })).balance)).toBe(-50) + expect(ccCategoryMonth.balance.toJSON().amount).toBe(50) + expect(account.balance.toJSON().amount).toBe(-50) + expect(paymentCategoryMonth.balance.toJSON().amount).toBe(-50) }) it("Credit card transfer reduces CC category", async () => { - const budget = await Budget.findOne({ id: 'test-budget' }) + const budget = await getRepository(Budget).findOne({ id: 'test-budget' }) // Initial amount for Checking transfer - const checkingAccount = await Account.findOne({ budgetId: budget.id, name: "Checking" }) - const ccAccount = await Account.findOne({ budgetId: budget.id, name: 'Visa' }) + let checkingAccount = await getRepository(Account).findOne({ budgetId: budget.id, name: "Checking" }) + let ccAccount = await getRepository(Account).findOne({ budgetId: budget.id, name: 'Visa' }) - const transaction = await Transaction.createNew({ + // Transfer 50 checking -> credit card for payment + const transaction = getRepository(Transaction).create({ budgetId: budget.id, accountId: checkingAccount.id, categoryId: null, @@ -235,14 +254,46 @@ describe("Budget Tests", () => { date: new Date(), handleTransfers: true, }) + await getRepository(Transaction).insert(transaction) - await checkingAccount.reload() - await ccAccount.reload() + checkingAccount = await getRepository(Account).findOne(checkingAccount.id) + ccAccount = await getRepository(Account).findOne(ccAccount.id) - const ccCategory = await Category.findOne({ trackingAccountId: ccAccount.id }) - const ccCategoryMonth = await CategoryMonth.findOne({ categoryId: ccCategory.id, month: transaction.getMonth() }) + const ccCategory = await getRepository(Category).findOne({ trackingAccountId: ccAccount.id }) + const ccCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: ccCategory.id, month: Transaction.getMonth(transaction.date) }) - expect(ccCategoryMonth.balance).toBe(0) - expect(ccAccount.balance).toBe(0) + expect(ccCategoryMonth.balance.toJSON().amount).toBe(0) + expect(ccAccount.balance.toJSON().amount).toBe(0) + }) + + it("Credit card inflow should account for CC category and target category", async () => { + const budget = await getRepository(Budget).findOne({ id: 'test-budget' }) + + // Initial amount for Checking transfer + let checkingAccount = await getRepository(Account).findOne({ budgetId: budget.id, name: "Checking" }) + let ccAccount = await getRepository(Account).findOne({ budgetId: budget.id, name: 'Visa' }) + let paymentCategory = await getRepository(Category).findOne({ id: 'test-power' }) + let payee = await getRepository(Payee).findOne({ budgetId: budget.id, name: 'Power company' }) + + // 'reimbursement' of 10 to credit card from test-power + const transaction = getRepository(Transaction).create({ + budgetId: budget.id, + accountId: ccAccount.id, + categoryId: paymentCategory.id, + payeeId: payee.id, + amount: 10, + date: new Date(), + }) + await getRepository(Transaction).insert(transaction) + + ccAccount = await getRepository(Account).findOne(ccAccount.id) + + const ccCategory = await getRepository(Category).findOne({ trackingAccountId: ccAccount.id }) + const ccCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: ccCategory.id, month: Transaction.getMonth(transaction.date) }) + const paymentCategoryMonth = await getRepository(CategoryMonth).findOne({ categoryId: paymentCategory.id, month: Transaction.getMonth(transaction.date) }) + + expect(ccCategoryMonth.balance.toJSON().amount).toBe(-10) + expect(ccAccount.balance.toJSON().amount).toBe(10) + expect(paymentCategoryMonth.balance.toJSON().amount).toBe(-40) }) }) diff --git a/frontend/src/api.js b/frontend/src/api.js index 98f5f44..f5484de 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -122,13 +122,13 @@ export default class API { } static async createCategoryGroup(name, budgetId) { - const response = await axios.post(`/api/budgets/${budgetId}/categories/groups`, { name }) + const response = await axios.post(`/api/budgets/${budgetId}/categories/groups`, { name, order: 0 }) return response.data.data } - static async updateCategoryGroup(categoryGroupId, name, budgetId) { - const response = await axios.put(`/api/budgets/${budgetId}/categories/groups/${categoryGroupId}`, { name }) + static async updateCategoryGroup(categoryGroupId, name, order, budgetId) { + const response = await axios.put(`/api/budgets/${budgetId}/categories/groups/${categoryGroupId}`, { name, order }) return response.data.data } @@ -136,16 +136,18 @@ export default class API { static async createCategory(name, categoryGroupId, budgetId) { const response = await axios.post(`/api/budgets/${budgetId}/categories`, { name, - categoryGroupId + order: 0, + categoryGroupId, }) return response.data.data } - static async updateCategory(categoryId, name, categoryGroupId, budgetId) { + static async updateCategory(categoryId, name, order, categoryGroupId, budgetId) { const response = await axios.put(`/api/budgets/${budgetId}/categories/${categoryId}`, { name, - categoryGroupId + order, + categoryGroupId, }) return response.data.data diff --git a/frontend/src/components/BudgetTable/BudgetTable.js b/frontend/src/components/BudgetTable/BudgetTable.js index 1aa7140..bce0298 100644 --- a/frontend/src/components/BudgetTable/BudgetTable.js +++ b/frontend/src/components/BudgetTable/BudgetTable.js @@ -1,8 +1,9 @@ import React, { useState, useEffect } from "react"; import { useSelector, useDispatch } from "react-redux" -import MaterialTable, { MTableCell, MTableEditCell } from "@material-table/core"; +import MaterialTable, { MTableCell, MTableEditCell, MTableBodyRow } from "@material-table/core"; import { TableIcons } from '../../utils/Table' import { fetchBudgetMonth, updateCategoryMonth, fetchCategoryMonths, refreshBudget } from "../../redux/slices/Budgets"; +import { updateCategoryGroup, updateCategory, fetchCategories } from "../../redux/slices/Categories" import TextField from '@mui/material/TextField'; import IconButton from '@mui/material/IconButton'; import AddCircleIcon from "@mui/icons-material/AddCircle"; @@ -29,20 +30,26 @@ export default function BudgetTable(props) { const budgetId = budget.id const month = useSelector(state => state.budgets.currentMonth) const availableMonths = useSelector(state => state.budgets.availableMonths) - const categoriesMap = useSelector( - state => state.categories.categoryGroups.reduce( - (acc, group) => { - if (group.internal) { - return acc - } - acc[group.id] = group.name - for (const category of group.categories) { - acc[category.id] = category.name - } + const [categoryGroupsMap, categoriesMap] = useSelector( + state => { + const groupMap = {} + const map = state.categories.categoryGroups.reduce( + (acc, group) => { + if (group.internal) { + return acc + } + groupMap[group.id] = group + acc[group.id] = group.name + for (const category of group.categories) { + acc[category.id] = category.name + } - return acc - }, {} - ) + return acc + }, {} + ) + + return [groupMap, map] + } ) const budgetMonth = useSelector(state => { if (!state.budgets.budgetMonths[month]) { @@ -61,6 +68,8 @@ export default function BudgetTable(props) { let groupRow = { id: group.id, + name: group.name, + order: group.order, categoryId: group.id, month, budgeted: dinero({ amount: 0, currency: USD }), @@ -71,6 +80,8 @@ export default function BudgetTable(props) { for (let category of group.categories) { const defaultRow = { id: category.id, + name: category.name, + order: category.order, groupId: group.id, categoryId: category.id, month, @@ -98,6 +109,7 @@ export default function BudgetTable(props) { retval.push({ ...categoryMonth, + order: category.order, groupId: group.id, trackingAccountId: category.trackingAccountId, }) @@ -115,11 +127,23 @@ export default function BudgetTable(props) { let cellEditing = false const openCategoryDialog = props.openCategoryDialog const openCategoryGroupDialog = props.openCategoryGroupDialog + const DragState = { + row: -1, + dropRow: -1, // drag target + }; const columns = [ + { + title: "order", + field: "order", + hidden: true, + editable: "never", + defaultSort: "asc", + }, { title: "Category", field: "categoryId", + sorting: false, lookup: categoriesMap, editable: "never", align: "left", @@ -149,6 +173,7 @@ export default function BudgetTable(props) { popupState={popupState} mode={'edit'} name={categoriesMap[rowData.categoryId]} + order={categoriesMap[rowData.order]} categoryId={rowData.categoryId} /> ) @@ -159,6 +184,7 @@ export default function BudgetTable(props) { popupState={popupState} mode={'edit'} name={categoriesMap[rowData.categoryId]} + order={rowData.order} categoryId={rowData.categoryId} categoryGroupId={rowData.groupId} /> @@ -179,9 +205,11 @@ export default function BudgetTable(props) { {...bindTrigger(popupState)} style={{padding: 0}} aria-label="add" - size="small" + // size="small" > - + intlFormat(rowData.budgeted), }, { title: "Activity", field: "activity", + sorting: false, type: "currency", editable: "never", render: rowData => intlFormat(rowData.activity), }, { - title: "Balance", - field: "balance", - type: "currency", - align: "right", - editable: "never", - render: (rowData) => { - const value = intlFormat(rowData.balance) + title: "Balance", + field: "balance", + sorting: false, + type: "currency", + align: "right", + editable: "never", + render: (rowData) => { + const value = intlFormat(rowData.balance) - if (!rowData.groupId) { - return value - } - - let color = "default" - if (rowData.trackingAccountId) { - if (isPositive(budgetMonth.underfunded) && !isZero(budgetMonth.underfunded)) { - color = "warning" - } else if (isZero(rowData.balance) || isNegative(rowData.balance)) { - color = "default" - } else { - color = "success" - } - } else { - if (isZero(rowData.balance)) { - color = "default" - } else if (isNegative(rowData.balance)) { - color = "error" - } else { - color = "success" - } - } - - // Tooltip for CC warning - if (rowData.trackingAccountId && color === 'warning') { - return ( - - - - ) - } - - return + if (!rowData.groupId) { + return value } - }, + + let color = "default" + if (rowData.trackingAccountId) { + if (isPositive(budgetMonth.underfunded) && !isZero(budgetMonth.underfunded)) { + color = "warning" + } else if (isZero(rowData.balance) || isNegative(rowData.balance)) { + color = "default" + } else { + color = "success" + } + } else { + if (isZero(rowData.balance)) { + color = "default" + } else if (isNegative(rowData.balance)) { + color = "error" + } else { + color = "success" + } + } + + // Tooltip for CC warning + if (rowData.trackingAccountId && color === 'warning') { + return ( + + + + ) + } + + return + } + }, ] + const reorderRows = async (from, to) => { + if (from.groupId) { + /// updating a category, not a group + if (!to.groupId) { + // placing into a new group, at the very top + from.groupId = to.id + } else { + // placing into same or new group, at the position dropped + from.groupId = to.groupId + from.order = to.order + 0.5 + } + + await dispatch(updateCategory({ id: from.id, name: from.name, order: from.order, categoryGroupId: from.groupId })) + } else { + if (to.groupId) { + // This is category, find the group it belongs in + to = categoryGroupsMap[to.groupId] + } + + from.order = to.order + 0.5 + await dispatch(updateCategoryGroup({ id: from.id, name: from.name, order: from.order })) + } + + dispatch(fetchCategories()) + }; + const onBudgetEdit = async (newRow, oldRow) => { if (equal(newRow.budgeted, oldRow.budgeted)) { // Only update if the amount budgeted was changed @@ -320,6 +377,28 @@ export default function BudgetTable(props) { /> ) }, + Row: (props) => ( + { + DragState.row = props.data + }} + onDragEnter={(e) => { + e.preventDefault(); + if (props.data.id !== DragState.row.id) { + DragState.dropRow = props.data + } + }} + onDragEnd={(e) => { + if (DragState.dropRow !== -1) { + reorderRows(DragState.row, DragState.dropRow) + } + DragState.row = -1; + DragState.dropRow = -1; + }} + /> + ), Cell: budgetTableCell, EditCell: props => { return ( @@ -372,7 +451,7 @@ export default function BudgetTable(props) { showTitle: false, // toolbar: false, draggable: false, - sorting: false, + // sorting: false, headerStyle: { position: 'sticky', top: 0 }, rowStyle: rowData => ({ ...!rowData.groupId && { diff --git a/frontend/src/components/BudgetTable/BudgetTableHeader.js b/frontend/src/components/BudgetTable/BudgetTableHeader.js index 5301711..204bd0f 100644 --- a/frontend/src/components/BudgetTable/BudgetTableHeader.js +++ b/frontend/src/components/BudgetTable/BudgetTableHeader.js @@ -111,6 +111,7 @@ export default function BudgetTableHeader(props) {