diff --git a/backend/src/controllers/AccountsController.ts b/backend/src/controllers/AccountsController.ts index 3ff1d1a..f5a8bd2 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' @@ -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).insert(account) // Create a transaction for the starting balance of the account if (requestBody.balance !== 0) { @@ -64,13 +65,13 @@ 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 }) + const startingBalanceTransaction = getRepository(Transaction).create({ budgetId, accountId: account.id, payeeId: startingBalancePayee.id, @@ -80,15 +81,15 @@ export class AccountsController extends Controller { memo: 'Starting Balance', status: TransactionStatus.Reconciled, }) - await startingBalanceTransaction.save() + await getRepository(Transaction).insert(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 +125,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 +133,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 +143,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.getUpdatePayload()) } 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 +162,16 @@ export class AccountsController extends Controller { memo: 'Reconciliation Transaction', status: TransactionStatus.Reconciled, }) - await startingBalanceTransaction.save() + await getRepository(Transaction).insert(startingBalanceTransaction) } - const clearedTransactions = await Transaction.find({ accountId: account.id, status: TransactionStatus.Cleared }) - console.log(clearedTransactions) + const clearedTransactions = await getRepository(Transaction).find({ accountId: account.id, status: TransactionStatus.Cleared }) for (const transaction of clearedTransactions) { transaction.status = TransactionStatus.Reconciled - await transaction.save() + await getRepository(Transaction).update(transaction.id, transaction.getUpdatePayload()) } - await account.reload() + account = await getRepository(Account).findOne(account.id) } return { @@ -211,7 +211,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 +219,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 +256,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 +264,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..aff114e 100644 --- a/backend/src/controllers/BudgetsController.ts +++ b/backend/src/controllers/BudgetsController.ts @@ -1,11 +1,12 @@ 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' 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).insert(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..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' @@ -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') @@ -29,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', @@ -40,7 +43,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 +51,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', @@ -72,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', @@ -83,7 +87,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 +95,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).insert(categoryGroup) return { message: 'success', @@ -119,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', @@ -131,7 +136,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 +144,36 @@ 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() + + 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', @@ -166,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', }, @@ -176,7 +209,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 +217,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).insert(category) return { message: 'success', @@ -213,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', }, @@ -224,7 +258,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,21 +266,45 @@ export class CategoriesController extends Controller { } } - const category = await Category.findOne(id, { relations: ['categoryGroup'] }) + const category = await getRepository(Category).findOne(id, { relations: ['categoryGroup'] }) + + const originalCategoryGroupId = category.categoryGroupId + const updateOrder = category.categoryGroupId !== requestBody.categoryGroupId || category.order !== requestBody.order category.name = requestBody.name - if (category.categoryGroupId !== requestBody.categoryGroupId) { - delete category.categoryGroup - category.categoryGroupId = requestBody.categoryGroupId - } + category.order = requestBody.order + delete category.categoryGroup + category.categoryGroupId = requestBody.categoryGroupId - await category.save() + 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', data: await category.toResponseModel(), } } catch (err) { + console.log(err) return { message: err.message } } } @@ -277,7 +335,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 +343,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).update(categoryMonth.id, categoryMonth.getUpdatePayload()) return { message: 'success', @@ -324,7 +383,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 +391,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..15b09b0 100644 --- a/backend/src/controllers/PayeesController.ts +++ b/backend/src/controllers/PayeesController.ts @@ -1,9 +1,10 @@ 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' 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).insert(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..ed75fc8 100644 --- a/backend/src/controllers/RootController.ts +++ b/backend/src/controllers/RootController.ts @@ -1,8 +1,9 @@ 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' @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..ba45415 100644 --- a/backend/src/controllers/TransactionsController.ts +++ b/backend/src/controllers/TransactionsController.ts @@ -1,12 +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 { 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), + }) + TransactionCache.enableTransfers(transaction.id) + await transactionalEntityManager.getRepository(Transaction).insert(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,12 +107,17 @@ 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({ - ...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, + 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 }) return { @@ -133,7 +144,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 +152,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) + TransactionCache.enableTransfers(transactionId) + await getRepository(Transaction).remove(transaction) return { message: 'success', @@ -182,7 +193,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 +201,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..7e1596f 100644 --- a/backend/src/controllers/UsersController.ts +++ b/backend/src/controllers/UsersController.ts @@ -1,8 +1,9 @@ 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' @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).insert(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/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 8f418ad..b7ac14e 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 @@ -81,7 +76,7 @@ export class Account extends BaseEntity { /** * Has many transactions */ - @OneToMany(() => Transaction, transaction => transaction.account, { cascade: true }) + @OneToMany(() => Transaction, transaction => transaction.account) transactions: Promise /** @@ -91,52 +86,17 @@ 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({ + public getUpdatePayload() { + return { + id: this.id, 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) + transferPayeeId: this.transferPayeeId, + name: this.name, + type: this.type, + balance: {...this.balance}, + cleared: {...this.cleared}, + uncleared: {...this.uncleared}, + } } public async toResponseModel(): Promise { diff --git a/backend/src/entities/Base.ts b/backend/src/entities/Base.ts deleted file mode 100644 index f7b3798..0000000 --- a/backend/src/entities/Base.ts +++ /dev/null @@ -1,37 +0,0 @@ -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() - // } - // } -} diff --git a/backend/src/entities/Budget.ts b/backend/src/entities/Budget.ts index c3895b0..73b84e7 100644 --- a/backend/src/entities/Budget.ts +++ b/backend/src/entities/Budget.ts @@ -3,13 +3,9 @@ import { Entity, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, OneToMany, - AfterInsert, - PrimaryColumn, - BeforeInsert, } from 'typeorm' import { User } from './User' import { Account } from './Account' @@ -17,16 +13,13 @@ 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 extends BaseEntity { +export class Budget { @PrimaryGeneratedColumn('uuid') id: string @@ -58,82 +51,40 @@ export class Budget extends BaseEntity { /** * 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 - @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 getUpdatePayload() { + return { + id: this.id, + userId: this.userId, + name: this.name, + toBeBudgeted: {...this.toBeBudgeted}, + } } public async toResponseModel(): Promise { diff --git a/backend/src/entities/BudgetMonth.ts b/backend/src/entities/BudgetMonth.ts index f08987b..6aaae9e 100644 --- a/backend/src/entities/BudgetMonth.ts +++ b/backend/src/entities/BudgetMonth.ts @@ -1,29 +1,22 @@ import { BudgetMonthModel } from '../models/BudgetMonth' import { Entity, - 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/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 @@ -78,25 +71,19 @@ export class BudgetMonth extends BaseEntity { /** * Has man category months */ - @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.budgetMonth, { cascade: true }) + @OneToMany(() => CategoryMonth, categoryMonth => categoryMonth.budgetMonth) 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 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 { @@ -112,36 +99,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..804b284 100644 --- a/backend/src/entities/Category.ts +++ b/backend/src/entities/Category.ts @@ -3,22 +3,18 @@ import { Entity, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, OneToMany, Index, - PrimaryColumn, - BeforeInsert, } from 'typeorm' 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 BaseEntity { +export class Category { @PrimaryGeneratedColumn('uuid') id: string @@ -29,6 +25,7 @@ export class Category extends BaseEntity { @Column({ type: 'varchar', nullable: false }) categoryGroupId: string + @Index({ unique: true }) @Column({ type: 'varchar', nullable: true }) trackingAccountId: string @@ -41,6 +38,9 @@ export class Category extends BaseEntity { @Column({ type: 'boolean', default: false }) locked: boolean + @Column({ type: 'int', default: 0 }) + order: number = 0 + @CreateDateColumn() created: Date @@ -62,15 +62,28 @@ export class Category extends BaseEntity { /** * 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, @@ -79,6 +92,7 @@ export class Category extends BaseEntity { 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 d05b307..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 BaseEntity { +export class CategoryGroup { @PrimaryGeneratedColumn('uuid') id: string @@ -35,6 +31,9 @@ export class CategoryGroup extends BaseEntity { @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 BaseEntity { /** * 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 BaseEntity { 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 56e2413..7e2abf1 100644 --- a/backend/src/entities/CategoryMonth.ts +++ b/backend/src/entities/CategoryMonth.ts @@ -1,30 +1,52 @@ 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 +} + +export class CategoryMonthCache { + static cache: { [key: string]: CategoryMonthOriginalValues } = {} + + 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 BaseEntity { +export class CategoryMonth { @PrimaryGeneratedColumn('uuid') id: string @@ -79,128 +101,24 @@ 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 }) - @AfterLoad() private storeOriginalValues(): void { - this.originalBudgeted = this.budgeted - this.originalActivity = this.activity - this.originalBalance = this.balance + CategoryMonthCache.set(this) } - 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({ + public getUpdatePayload() { + return { + id: this.id, categoryId: this.categoryId, - month: formatMonthFromDateString(prevMonth), - }) - if (prevCategoryMonth && isPositive(prevCategoryMonth.balance)) { - this.balance = add(prevCategoryMonth.balance, add(this.budgeted, this.activity)) + budgetMonthId: this.budgetMonthId, + month: this.month, + budgeted: {...this.budgeted}, + activity: {...this.activity}, + balance: {...this.balance}, } } - /** - * == 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 +128,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..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 BaseEntity { +export class Payee { @PrimaryGeneratedColumn('uuid') id: string @@ -45,7 +41,7 @@ export class Payee extends BaseEntity { /** * 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 368cef0..5e229bc 100644 --- a/backend/src/entities/Transaction.ts +++ b/backend/src/entities/Transaction.ts @@ -2,31 +2,22 @@ import { TransactionModel } from '../models/Transaction' import { Entity, AfterLoad, - AfterRemove, PrimaryGeneratedColumn, Column, - BaseEntity, CreateDateColumn, ManyToOne, DeepPartial, - AfterInsert, - BeforeInsert, - AfterUpdate, - BeforeUpdate, - BeforeRemove, - PrimaryColumn, + 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, @@ -34,8 +25,63 @@ export enum TransactionStatus { Reconciled, } +export type TransactionOriginalValues = { + payeeId: string + categoryId: string + amount: Dinero + date: Date + 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 BaseEntity { +export class Transaction { @PrimaryGeneratedColumn('uuid') id: string @@ -51,6 +97,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 +150,29 @@ export class Transaction extends BaseEntity { @ManyToOne(() => Category, category => category.transactions) category: Promise - handleTransfers: boolean = false - - 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 - @AfterLoad() private storeOriginalValues() { - this.originalPayeeId = this.payeeId - this.originalCategoryId = this.categoryId - this.originalAmount = this.amount - this.originalDate = this.date - this.originalStatus = this.status + TransactionCache.set(this) } - 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 async update(partial: DeepPartial): Promise { + 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({ + public getUpdatePayload() { + return { + id: this.id, 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, { + accountId: this.accountId, 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), + categoryId: this.categoryId, + amount: this.amount, 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) + memo: this.memo, + status: this.status, + } } public async toResponseModel(): Promise { @@ -598,7 +190,7 @@ export class Transaction extends BaseEntity { } } - 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 42acda7..2fa48c3 100644 --- a/backend/src/entities/User.ts +++ b/backend/src/entities/User.ts @@ -2,24 +2,21 @@ 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 extends BaseEntity { +export class User { @PrimaryGeneratedColumn('uuid') id: string @@ -32,7 +29,7 @@ export class User extends BaseEntity { 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 957a0a6..7cb1bd7 100644 --- a/backend/src/middleware/authentication.ts +++ b/backend/src/middleware/authentication.ts @@ -1,8 +1,9 @@ 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' 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/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 new file mode 100644 index 0000000..90eef96 --- /dev/null +++ b/backend/src/repositories/BudgetMonths.ts @@ -0,0 +1,41 @@ +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 { + 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 direction = 1 + let monthFrom = new Date() + monthFrom.setDate(1) + + if (month < months[0]) { + monthFrom = new Date(`${months[0]}T12:00:00`) + direction = -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() + direction) + newBudgetMonth = this.create({ + budgetId, + month: formatMonthFromDateString(monthFrom), + }) + await this.insert(newBudgetMonth) + } 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..e0bc65a --- /dev/null +++ b/backend/src/repositories/CategoryMonths.ts @@ -0,0 +1,38 @@ +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 createNew(budgetId: string, categoryId: string, month: string): Promise { + const budgetMonth = await this.manager.getCustomRepository(BudgetMonths).findOrCreate(budgetId, month) + const 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 }), + }) + categoryMonth.budgetMonth = Promise.resolve(budgetMonth) + + return categoryMonth + } + + async findOrCreate(budgetId: string, categoryId: string, month: string): Promise { + let categoryMonth: CategoryMonth = await this.findOne( + { categoryId, month: month }, + { relations: ['budgetMonth'] }, + ) + + if (!categoryMonth) { + categoryMonth = await this.createNew(budgetId, categoryId, month) + await this.insert(categoryMonth) + } + + return categoryMonth + } +} 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..c20997e --- /dev/null +++ b/backend/src/subscribers/AccountSubscriber.ts @@ -0,0 +1,73 @@ +import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from "typeorm"; +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 Promise.all([ + this.createCreditCardCategory(event), + 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.insert(Payee, payee) + account.transferPayeeId = payee.id + await manager.update(Account, account.id, account.getUpdatePayload()) + } + + 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.insert(Category, paymentCategory) + } + } + + async beforeUpdate(event: UpdateEvent) { + const account = event.entity + + account.balance = add(account.cleared, account.uncleared) + } +} diff --git a/backend/src/subscribers/BudgetMonthSubscriber.ts b/backend/src/subscribers/BudgetMonthSubscriber.ts new file mode 100644 index 0000000..7feddeb --- /dev/null +++ b/backend/src/subscribers/BudgetMonthSubscriber.ts @@ -0,0 +1,64 @@ +import { Budget } from "../entities/Budget"; +import { EntityManager, EntitySubscriberInterface, EventSubscriber, InsertEvent, MoreThan, MoreThanOrEqual, Not, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString, getDateFromString } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { Category } from "../entities/Category"; +import { add, isZero, isNegative, isPositive, subtract, equal, dinero } from "dinero.js"; +import { CategoryMonth, CategoryMonthCache } from "../entities/CategoryMonth"; +import { USD } from "@dinero.js/currencies"; +import { CategoryMonths } from "../repositories/CategoryMonths"; + +@EventSubscriber() +export class BudgetMonthSubscriber implements EntitySubscriberInterface { + listenTo() { + return BudgetMonth; + } + + async afterInsert(event: InsertEvent) { + // console.log('created new budget month') + // const budgetMonth = event.entity + // const manager = event.manager + + // const prevMonth = getDateFromString(budgetMonth.month) + // prevMonth.setMonth(prevMonth.getMonth() - 1) + + // const prevBudgetMonth = await manager.findOne(BudgetMonth, { + // budgetId: budgetMonth.budgetId, + // month: formatMonthFromDateString(prevMonth), + // }) + + // if (!prevBudgetMonth) { + // return + // } + + // // Find all categories with previous balances to update the new month with + // const previousCategoryMonths = await manager.getRepository(CategoryMonth).find({ + // budgetMonthId: prevBudgetMonth.id, + // }) + + // for (const previousCategoryMonth of previousCategoryMonths) { + // if (isZero(previousCategoryMonth.balance)) { + // continue + // } + + // if (isPositive(previousCategoryMonth.balance)) { + // await manager.insert(CategoryMonth, { + // budgetMonthId: budgetMonth.id, + // month: budgetMonth.month, + // balance: previousCategoryMonth.balance, + // }) + // } + + // const category = await manager.findOne(Category, { + // id: previousCategoryMonth.categoryId, + // }) + // if (isNegative(previousCategoryMonth.balance) && category.trackingAccountId) { + // await manager.insert(CategoryMonth, { + // budgetMonthId: budgetMonth.id, + // month: budgetMonth.month, + // balance: previousCategoryMonth.balance, + // }) + // } + // } + } +} diff --git a/backend/src/subscribers/BudgetSubscriber.ts b/backend/src/subscribers/BudgetSubscriber.ts new file mode 100644 index 0000000..f28a846 --- /dev/null +++ b/backend/src/subscribers/BudgetSubscriber.ts @@ -0,0 +1,66 @@ +import { Budget } from "../entities/Budget"; +import { EntitySubscriberInterface, EventSubscriber, 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.insert(BudgetMonth, newBudgetMonth) + } + + // Create internal categories + const internalCategoryGroup = manager.create(CategoryGroup, { + budgetId: budget.id, + name: 'Internal Category', + internal: true, + locked: true, + }) + await manager.insert(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.insert(Category, internalCategory) + }), + ) + + // Create special 'Starting Balance' payee + const startingBalancePayee = manager.create(Payee, { + budgetId: budget.id, + name: 'Starting Balance', + internal: true, + }) + await manager.insert(Payee, startingBalancePayee) + + const reconciliationPayee = manager.create(Payee, { + budgetId: budget.id, + name: 'Reconciliation Balance Adjustment', + internal: true, + }) + await manager.insert(Payee, reconciliationPayee) + } +} diff --git a/backend/src/subscribers/CategoryMonthSubscriber.ts b/backend/src/subscribers/CategoryMonthSubscriber.ts new file mode 100644 index 0000000..f318afd --- /dev/null +++ b/backend/src/subscribers/CategoryMonthSubscriber.ts @@ -0,0 +1,130 @@ +import { Budget } from "../entities/Budget"; +import { EntityManager, EntitySubscriberInterface, EventSubscriber, InsertEvent, MoreThan, MoreThanOrEqual, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString, getDateFromString } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { Category } from "../entities/Category"; +import { add, isZero, isNegative, isPositive, subtract, equal, dinero } from "dinero.js"; +import { CategoryMonth, CategoryMonthCache } from "../entities/CategoryMonth"; +import { USD } from "@dinero.js/currencies"; +import { CategoryMonths } from "../repositories/CategoryMonths"; + +@EventSubscriber() +export class CategoryMonthSubscriber implements EntitySubscriberInterface { + listenTo() { + return CategoryMonth + } + + /** + * 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), + }) + + const category = await event.manager.findOne(Category, { + id: event.entity.categoryId + }) + + if (prevCategoryMonth && (category.trackingAccountId || isPositive(prevCategoryMonth.balance))) { + categoryMonth.balance = add(prevCategoryMonth.balance, add(categoryMonth.budgeted, categoryMonth.activity)) + } + } + + async afterInsert(event: InsertEvent) { + if (isZero(event.entity.balance)) { + return + } + + await this.bookkeeping(event.entity as CategoryMonth, event.manager) + } + + async afterUpdate(event: UpdateEvent) { + 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) + const originalCategoryMonth = CategoryMonthCache.get(categoryMonth.id) + + // Update budget month activity and and budgeted + const budgetMonth = await manager.findOne(BudgetMonth, categoryMonth.budgetMonthId) + + budgetMonth.budgeted = add(budgetMonth.budgeted, subtract(categoryMonth.budgeted, originalCategoryMonth.budgeted)) + budgetMonth.activity = add(budgetMonth.activity, subtract(categoryMonth.activity, originalCategoryMonth.activity)) + + const budgetedDifference = subtract(originalCategoryMonth.budgeted, categoryMonth.budgeted) + const activityDifference = subtract(categoryMonth.activity, originalCategoryMonth.activity) + if (!isZero(budgetedDifference) || !isZero(activityDifference)) { + const budget = await manager.findOne(Budget, budgetMonth.budgetId) + budget.toBeBudgeted = add(budget.toBeBudgeted, budgetedDifference) + + if (category.inflow) { + budget.toBeBudgeted = add(budget.toBeBudgeted, activityDifference) + } + + await manager.update(Budget, budget.id, budget.getUpdatePayload()) + } + + if (category.inflow) { + budgetMonth.income = add(budgetMonth.income, subtract(categoryMonth.activity, originalCategoryMonth.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(originalCategoryMonth.balance)) { + budgetMonth.underfunded = add(budgetMonth.underfunded, originalCategoryMonth.balance) + } + if (isNegative(categoryMonth.balance)) { + budgetMonth.underfunded = subtract(budgetMonth.underfunded, categoryMonth.balance) + } + } + + await manager.update(BudgetMonth, budgetMonth.id, budgetMonth.getUpdatePayload()) + + 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.update(CategoryMonth, nextCategoryMonth.id, nextCategoryMonth.getUpdatePayload()) + } +} diff --git a/backend/src/subscribers/CategorySubscriber.ts b/backend/src/subscribers/CategorySubscriber.ts new file mode 100644 index 0000000..f48bcb7 --- /dev/null +++ b/backend/src/subscribers/CategorySubscriber.ts @@ -0,0 +1,34 @@ +import { Budget } from "../entities/Budget"; +import { EntityManager, EntitySubscriberInterface, EventSubscriber, InsertEvent, MoreThan, MoreThanOrEqual, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString, getDateFromString } from "../utils"; +import { BudgetMonth } from "../entities/BudgetMonth"; +import { Category } from "../entities/Category"; +import { add, isZero, isNegative, isPositive, subtract, equal, dinero } from "dinero.js"; +import { CategoryMonth, CategoryMonthCache } from "../entities/CategoryMonth"; +import { USD } from "@dinero.js/currencies"; +import { CategoryMonths } from "../repositories/CategoryMonths"; + +@EventSubscriber() +export class CategorySubscriber implements EntitySubscriberInterface { + listenTo() { + return Category; + } + + async afterInsert(event: InsertEvent) { + console.log('new category was created') + const category = event.entity + const manager = event.manager + + // Create a category month for all existing months + const budgetMonths = await manager.find(BudgetMonth, { budgetId: category.budgetId }) + + const categoryMonths = budgetMonths.map(budgetMonth => manager.create(CategoryMonth, { + categoryId: category.id, + budgetMonthId: budgetMonth.id, + month: budgetMonth.month, + })) + + console.log(`inserting ${categoryMonths.length} new category months`) + await manager.insert(CategoryMonth, categoryMonths) + } +} diff --git a/backend/src/subscribers/TransactionSubscriber.ts b/backend/src/subscribers/TransactionSubscriber.ts new file mode 100644 index 0000000..1b2f315 --- /dev/null +++ b/backend/src/subscribers/TransactionSubscriber.ts @@ -0,0 +1,502 @@ +import { EntityManager, EntitySubscriberInterface, EventSubscriber, getRepository, InsertEvent, RemoveEvent, UpdateEvent } from "typeorm"; +import { formatMonthFromDateString } from "../utils"; +import { Category } from "../entities/Category"; +import { Payee } from "../entities/Payee"; +import { Account, AccountTypes } from "../entities/Account"; +import { add, equal, isZero, multiply, subtract } from "dinero.js"; +import { CategoryMonth } from "../entities/CategoryMonth"; +import { Transaction, TransactionCache, TransactionStatus } from "../entities/Transaction"; +import { CategoryMonths } from "../repositories/CategoryMonths"; +import { BudgetMonths } from "../repositories/BudgetMonths"; + +@EventSubscriber() +export class TransactionSubscriber implements EntitySubscriberInterface { + listenTo() { + return Transaction; + } + + async beforeInsert(event: InsertEvent) { + await Promise.all([ + this.checkCreateTransferTransaction(event.entity as Transaction, event.manager), + this.createCategoryMonth(event.entity as Transaction, event.manager), + ]) + } + + async beforeUpdate(event: UpdateEvent) { + await Promise.all([ + this.createCategoryMonth(event.entity as Transaction, event.manager), + 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) { + 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 beforeRemove(event: RemoveEvent) { + 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) { + 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) { + // 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 + } + + 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(transaction.date), + ) + } + } + + 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(transaction.date)) + ccCategoryMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(ccCategoryMonth.id, ccCategoryMonth.getUpdatePayload()) + } + 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(transaction.date) }) + transactionCategoryMonth.update({ activity: transaction.amount }) + await manager.getRepository(CategoryMonth).update(transactionCategoryMonth.id, transactionCategoryMonth.getUpdatePayload()) + } + + 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(transaction.date)) + ccCategoryMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(ccCategoryMonth.id, ccCategoryMonth.getUpdatePayload()) + } + } + + 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.insert(Transaction, transferTransaction) + + const transferAccount = await manager.getRepository(Account).findOne(transferTransaction.accountId) + + transaction.payeeId = transferAccount.transferPayeeId + transaction.transferAccountId = transferAccount.id + transaction.transferTransactionId = transferTransaction.id + + // Perform save here so that the listener hooks don't get called + await manager.getRepository(Transaction).save(transaction, { listeners: false }) + } + + 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.update(Account, account.id, account.getUpdatePayload()) + } + + private async updateAccountBalanceOnUpdate(transaction: Transaction, manager: EntityManager) { + const originalTransaction = TransactionCache.get(transaction.id) + if (transaction.amount === originalTransaction.amount && transaction.status === originalTransaction.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 (originalTransaction.status) { + case TransactionStatus.Pending: + account.uncleared = subtract(account.uncleared, originalTransaction.amount) + break + case TransactionStatus.Cleared: + case TransactionStatus.Reconciled: + default: + account.cleared = subtract(account.cleared, originalTransaction.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.update(Account, account.id, account.getUpdatePayload()) + } + + 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.update(Account, account.id, account.getUpdatePayload()) + } + + private async updateTransferTransaction(transaction: Transaction, manager: EntityManager) { + if (!TransactionCache.transfersEnabled(transaction.id)) { + return + } + TransactionCache.disableTransfers(transaction.id) + + const originalTransaction = TransactionCache.get(transaction.id) + + // If the payees, dates, and amounts haven't changed, bail + if ( + transaction.payeeId === originalTransaction.payeeId && + transaction.amount === originalTransaction.amount && + formatMonthFromDateString(transaction.date) === formatMonthFromDateString(originalTransaction.date) + ) { + return + } + + if (transaction.payeeId === originalTransaction.payeeId && transaction.transferTransactionId) { + if (equal(transaction.amount, originalTransaction.amount) && transaction.date === originalTransaction.date) { + // amount and dates are the same, everything else is not linked + return + } + + // 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).update(transferTransaction.id, transferTransaction.getUpdatePayload()) + return + } + + if (transaction.payeeId !== originalTransaction.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.update(Transaction, transferTransaction.id, transferTransaction.getUpdatePayload()) + transaction.transferTransactionId = transferTransaction.id + } + } + } + + private async bookkeepingOnUpdate(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 originalTransaction = TransactionCache.get(transaction.id) + + // @TODO: hanle update of transactions when going to / from a transfer to / from a non-transfer + + let activity = subtract(transaction.amount, originalTransaction.amount) + + 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 + if (transaction.date !== originalTransaction.date && isZero(activity)) { + // No change in time or amount, so category month data doesn't change + return + } + + const ccCategory = await manager.findOne(Category, { trackingAccountId: account.id }) + const originalCCMonth = await manager.findOne(CategoryMonth, { + categoryId: ccCategory.id, + month: formatMonthFromDateString(originalTransaction.date), + }) + await originalCCMonth.update({ activity: originalTransaction.amount }) + + const currentCCMonth = await manager.getCustomRepository(CategoryMonths).findOrCreate(transaction.budgetId, ccCategory.id, Transaction.getMonth(transaction.date)) + currentCCMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(currentCCMonth.id, currentCCMonth.getUpdatePayload()) + } + + return + } + + if ( + originalTransaction.categoryId !== transaction.categoryId || + formatMonthFromDateString(originalTransaction.date) !== formatMonthFromDateString(transaction.date) + ) { + const category = await manager.getRepository(Category).findOne(transaction.categoryId) + const originalCategory = await manager.findOne(Category, originalTransaction.categoryId) + + // Cat or month has changed so the activity is the entirety of the transaction + activity = transaction.amount + + // Revert original category, if set + if (originalTransaction.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: originalTransaction.categoryId, month: formatMonthFromDateString(originalTransaction.date) }, + { relations: ['budgetMonth'] }, + ) + + originalCategoryMonth.update({ activity: multiply(originalTransaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(originalCategoryMonth.id, originalCategoryMonth.getUpdatePayload()) + } + + 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(originalTransaction.date), + }) + originalCCMonth.update({ activity: originalTransaction.amount }) + await manager.getRepository(CategoryMonth).update(originalCCMonth.id, originalCCMonth.getUpdatePayload()) + } + } + } + + // 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(transaction.date) }) + transactionCategoryMonth.update({ activity }) + await manager.getRepository(CategoryMonth).update(transactionCategoryMonth.id, transactionCategoryMonth.getUpdatePayload()) + } + + 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(transaction.date)) + currentCCMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(currentCCMonth.id, currentCCMonth.getUpdatePayload()) + } + } + } + } else { + if (isZero(activity)) { + return + } + + if (!transaction.categoryId) { + return + } + + const category = await manager.getRepository(Category).findOne(transaction.categoryId) + if (category.inflow === true && account.type === AccountTypes.CreditCard) { + return + } + + const categoryMonth = await manager.getRepository(CategoryMonth).findOne({ categoryId: category.id, month: Transaction.getMonth(transaction.date) }) + categoryMonth.update({ activity }) + await manager.getRepository(CategoryMonth).update(categoryMonth.id, categoryMonth.getUpdatePayload()) + + 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(transaction.date)) + currentCCMonth.update({ activity: multiply(transaction.amount, -1) }) + await manager.getRepository(CategoryMonth).update(currentCCMonth.id, currentCCMonth.getUpdatePayload()) + } + } + } + + 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(transaction.date) }) + ccCategoryMonth.update({ activity: transaction.amount }) + await manager.getRepository(CategoryMonth).update(ccCategoryMonth.id, ccCategoryMonth.getUpdatePayload()) + } + + 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.getUpdatePayload()) + } + + // 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(transaction.date) }) + ccCategoryMonth.update({ activity: transaction.amount }) + await manager.getRepository(CategoryMonth).update(ccCategoryMonth.id, ccCategoryMonth.getUpdatePayload()) + } + } +} diff --git a/backend/test/BudgE.test.js b/backend/test/BudgE.test.js index f19d8bf..cddf931 100644 --- a/backend/test/BudgE.test.js +++ b/backend/test/BudgE.test.js @@ -1,15 +1,17 @@ -import { createConnection, getConnection } from 'typeorm' +import { createConnection, getConnection, getRepository, getCustomRepository } from 'typeorm' import { User } from '../src/entities/User' import { Budget } from '../src/entities/Budget' import { Category } from '../src/entities/Category' import { CategoryGroup } from '../src/entities/CategoryGroup' import { CategoryMonth } from '../src/entities/CategoryMonth' import { formatMonthFromDateString, getMonthStringFromNow } from '../src/utils' -import { Account } from '../src/entities' +import { Account } from '../src/entities/Account' import { AccountTypes } from '../src/entities/Account' import { Transaction } from '../src/entities/Transaction' import { Payee } from '../src/entities/Payee' - +import { dinero } from 'dinero.js' +import { USD } from '@dinero.js/currencies' +import { CategoryMonths } from '../src/repositories/CategoryMonths' beforeAll(async () => { 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/BudgetDetails.js b/frontend/src/components/BudgetDetails.js index 81b4f61..e615a34 100644 --- a/frontend/src/components/BudgetDetails.js +++ b/frontend/src/components/BudgetDetails.js @@ -44,7 +44,7 @@ export default function BudgetDetails(props) { return (

- {(new Date(Date.UTC(...month.split('-')))).toLocaleDateString(undefined, { year: 'numeric', month: 'long' })} Summary + {(new Date(Date.UTC(...month.split('-')))).toLocaleDateString(undefined, { year: 'numeric', month: 'short' }).toUpperCase()} SUMMARY

diff --git a/frontend/src/components/BudgetTable/BudgetTable.js b/frontend/src/components/BudgetTable/BudgetTable.js index 1aa7140..b0ac85d 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,8 @@ export default function BudgetTable(props) { retval.push({ ...categoryMonth, + name: category.name, + order: category.order, groupId: group.id, trackingAccountId: category.trackingAccountId, }) @@ -115,11 +128,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 +174,7 @@ export default function BudgetTable(props) { popupState={popupState} mode={'edit'} name={categoriesMap[rowData.categoryId]} + order={categoriesMap[rowData.order]} categoryId={rowData.categoryId} /> ) @@ -159,6 +185,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 +206,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.categoryId, 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 +388,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,8 +462,13 @@ export default function BudgetTable(props) { showTitle: false, // toolbar: false, draggable: false, - sorting: false, - headerStyle: { position: 'sticky', top: 0 }, + // sorting: false, + headerStyle: { + position: 'sticky', + top: 0, + textTransform: 'uppercase', + fontSize: theme.typography.caption.fontSize, + }, rowStyle: rowData => ({ ...!rowData.groupId && { backgroundColor: theme.palette.action.hover, @@ -381,7 +476,6 @@ export default function BudgetTable(props) { }, fontSize: theme.typography.subtitle2.fontSize, }), - // headerStyle: { position: 'sticky', top: 0 } }} icons={TableIcons} columns={columns} 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) {