Merge pull request #3 from alex-phillips/entity-refactor

Entity refactor
This commit is contained in:
Alex Phillips 2021-12-29 13:59:59 -05:00 committed by GitHub
commit 6f953b481c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1747 additions and 1229 deletions

View File

@ -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<AccountResponse | ErrorResponse> {
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<AccountResponse | ErrorResponse> {
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<AccountsResponse | ErrorResponse> {
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<AccountResponse | ErrorResponse> {
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',

View File

@ -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<BudgetsResponse | ErrorResponse> {
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<BudgetResponse | ErrorResponse> {
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<BudgetResponse | ErrorResponse> {
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<BudgetResponse | ErrorResponse> {
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<BudgetMonthsResponse | ErrorResponse> {
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<BudgetMonthWithCategoriesResponse | ErrorResponse> {
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,
})

View File

@ -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<CategoryGroupsResponse | ErrorResponse> {
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<CategoryGroupResponse | ErrorResponse> {
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<CategoryGroupResponse | ErrorResponse> {
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<CategoryResponse | ErrorResponse> {
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<CategoryResponse | ErrorResponse> {
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<CategoryMonthResponse | ErrorResponse> {
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<CategoryMonthsResponse | ErrorResponse> {
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',

View File

@ -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<PayeeResponse | ErrorResponse> {
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<PayeesResponse | ErrorResponse> {
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<PayeeResponse | ErrorResponse> {
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',

View File

@ -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<LoginResponse|ErrorResponse> {
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<UserResponse | ErrorResponse> {
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(),

View File

@ -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<TransactionResponse | ErrorResponse> {
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<TransactionResponse | ErrorResponse> {
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<TransactionResponse | ErrorResponse> {
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<TransactionsResponse | ErrorResponse> {
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',

View File

@ -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<UserResponse | ErrorResponse> {
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<UserResponse | ErrorResponse> {
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',

View File

@ -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

View File

@ -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<Transaction[]>
/**
@ -91,52 +86,17 @@ export class Account extends BaseEntity {
@JoinColumn()
transferPayee: Promise<Payee>
@AfterInsert()
private async createCreditCardCategory(): Promise<void> {
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<AccountModel> {

View File

@ -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()
// }
// }
}

View File

@ -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<Account[]>
/**
* Has many categories
*/
@OneToMany(() => Category, category => category.budget, { cascade: true })
@OneToMany(() => Category, category => category.budget)
categories: Promise<Category[]>
/**
* Has many category groups
*/
@OneToMany(() => CategoryGroup, categoryGroup => categoryGroup.budget, { cascade: true })
@OneToMany(() => CategoryGroup, categoryGroup => categoryGroup.budget)
categoryGroups: Promise<CategoryGroup[]>
/**
* Has many budget months
*/
@OneToMany(() => BudgetMonth, budgetMonth => budgetMonth.budget, { cascade: true })
@OneToMany(() => BudgetMonth, budgetMonth => budgetMonth.budget)
months: Promise<BudgetMonth[]>
/**
* Has many budget transactions
*/
@OneToMany(() => Transaction, transaction => transaction.budget, { cascade: true })
@OneToMany(() => Transaction, transaction => transaction.budget)
transactions: Promise<Transaction[]>
@AfterInsert()
private async initialBudgetSetup(): Promise<void> {
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<BudgetModel> {

View File

@ -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<CategoryMonth[]>
originalIncome: Dinero<number> = dinero({ amount: 0, currency: USD })
originalBudgeted: Dinero<number> = dinero({ amount: 0, currency: USD })
originalActivity: Dinero<number> = dinero({ amount: 0, currency: USD })
@AfterLoad()
private async loadInitialValues(): Promise<void> {
this.originalIncome = this.income
this.originalBudgeted = this.budgeted
this.originalActivity = this.activity
}
@BeforeUpdate()
private async test(): Promise<void> {
// 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<BudgetMonthModel> {
@ -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<BudgetMonth> {
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
}
}

View File

@ -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<CategoryModel> {
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(),
}

View File

@ -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<Category[]>
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<CategoryGroupModel> {
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(),

View File

@ -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<number>
activity: Dinero<number>
balance: Dinero<number>
}
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<BudgetMonth>
originalBudgeted: Dinero<number> = dinero({ amount: 0, currency: USD })
originalActivity: Dinero<number> = dinero({ amount: 0, currency: USD })
originalBalance: Dinero<number> = 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<CategoryMonth> {
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<void> {
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<void> {
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<number> }): Promise<CategoryMonth> {
public update({ activity, budgeted }: { [key: string]: Dinero<number> }) {
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<CategoryMonthModel> {

View File

@ -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<Transaction[]>
public async toResponseModel(): Promise<PayeeModel> {

View File

@ -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<number>
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<Category>
handleTransfers: boolean = false
originalPayeeId: string | null = null
originalTransferTransactionId: string | null = null
categoryMonth: CategoryMonth
originalCategoryId: string = ''
originalAmount: Dinero<number> = 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<Transaction>): Promise<Transaction> {
// Create transaction
const transaction = Transaction.create(partial)
if (partial.handleTransfers === true) {
transaction.handleTransfers = true
}
await transaction.save()
return transaction
}
public async update(partial: DeepPartial<Transaction>): Promise<Transaction> {
public update(partial: DeepPartial<Transaction>) {
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<void> {
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<void> {
await this.updateAccountBalanceOnAdd()
await this.bookkeepingOnAdd()
await this.createTransferTransaction()
}
private async bookkeepingOnAdd(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<Transaction> {
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<Account> {
return await Account.findOne(this.accountId)
}
public async getPayee(): Promise<Payee> {
return await Payee.findOne(this.payeeId)
memo: this.memo,
status: this.status,
}
}
public async toResponseModel(): Promise<TransactionModel> {
@ -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)
}
}

View File

@ -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()

View File

@ -1,5 +0,0 @@
import { User } from './User'
import { Budget } from './Budget'
import { Account } from './Account'
export { User, Budget, Account }

View File

@ -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) {}
}

View File

@ -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<CategoryModel>

View File

@ -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<CategoryGroupModel>

View File

@ -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<BudgetMonth> {
async findOrCreate(budgetId: string, month: string): Promise<BudgetMonth> {
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
}
}

View File

@ -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<CategoryMonth> {
async createNew(budgetId: string, categoryId: string, month: string): Promise<CategoryMonth> {
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<CategoryMonth> {
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
}
}

View File

@ -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<Transaction> {
public static async foobar(budgetId: string, partial: DeepPartial<Transaction>): Promise<Transaction> {
// 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
}
}

View File

@ -1,12 +0,0 @@
import { EntityRepository, Repository } from 'typeorm'
import { User } from '../entities/User'
@EntityRepository()
export class UserRepository extends Repository<User> {
// findByName(firstName: string, lastName: string) {
// return this.createQueryBuilder("user")
// .where("user.firstName = :firstName", { firstName })
// .andWhere("user.lastName = :lastName", { lastName })
// .getMany();
// }
}

View File

@ -1,6 +0,0 @@
import { getConnectionManager } from 'typeorm'
import { UserRepository } from './UserRepository'
const userRepository = getConnectionManager().get('default').getRepository(UserRepository)
export { userRepository }

View File

@ -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(<number>config.port, '0.0.0.0', () => {

View File

@ -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<Account> {
listenTo() {
return Account;
}
async afterInsert(event: InsertEvent<Account>) {
await Promise.all([
this.createCreditCardCategory(event),
this.createAccountPayee(event),
])
}
private async createAccountPayee(event: InsertEvent<Account>) {
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<Account>) {
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<Account>) {
const account = event.entity
account.balance = add(account.cleared, account.uncleared)
}
}

View File

@ -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<BudgetMonth> {
listenTo() {
return BudgetMonth;
}
async afterInsert(event: InsertEvent<BudgetMonth>) {
// 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,
// })
// }
// }
}
}

View File

@ -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<Budget> {
listenTo() {
return Budget;
}
async afterInsert(event: InsertEvent<Budget>) {
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)
}
}

View File

@ -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<CategoryMonth> {
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<CategoryMonth>) {
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<CategoryMonth>) {
if (isZero(event.entity.balance)) {
return
}
await this.bookkeeping(event.entity as CategoryMonth, event.manager)
}
async afterUpdate(event: UpdateEvent<CategoryMonth>) {
await this.bookkeeping(event.entity as CategoryMonth, event.manager)
}
/**
* == RECURSIVE ==
*
* Cascade the new assigned and activity amounts up into the parent budget month for new totals.
* Also, cascade the new balance of this month into the next month to update the carry-over amount.
*/
private async bookkeeping(categoryMonth: CategoryMonth, manager: EntityManager) {
const category = await manager.findOne(Category, categoryMonth.categoryId)
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())
}
}

View File

@ -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<Category> {
listenTo() {
return Category;
}
async afterInsert(event: InsertEvent<Category>) {
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)
}
}

View File

@ -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<Transaction> {
listenTo() {
return Transaction;
}
async beforeInsert(event: InsertEvent<Transaction>) {
await Promise.all([
this.checkCreateTransferTransaction(event.entity as Transaction, event.manager),
this.createCategoryMonth(event.entity as Transaction, event.manager),
])
}
async beforeUpdate(event: UpdateEvent<Transaction>) {
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<Transaction>) {
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<Transaction>) {
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<Transaction>) {
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())
}
}
}

View File

@ -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)
})
})

View File

@ -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

View File

@ -44,7 +44,7 @@ export default function BudgetDetails(props) {
return (
<Box sx={{p: 2}}>
<h3>
{(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
</h3>
<TableContainer>
<Table aria-label="simple table">

View File

@ -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"
>
<AddCircleIcon fontSize="small" />
<AddCircleIcon style={{
fontSize: theme.typography.subtitle2.fontSize,
}} />
</IconButton>
<CategoryForm
popupState={popupState}
@ -199,62 +228,101 @@ export default function BudgetTable(props) {
{
title: "Assigned",
field: "budgeted",
sorting: false,
type: "currency",
render: rowData => 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 (
<Tooltip title="Month is underfunded, this amount may not be accurate">
<Chip size="small" label={value} color={color}></Chip>
</Tooltip>
)
}
return <Chip size="small" label={value} color={color}></Chip>
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 (
<Tooltip title="Month is underfunded, this amount may not be accurate">
<Chip size="small" label={value} color={color}></Chip>
</Tooltip>
)
}
return (
<Chip
size="small"
label={value}
color={color}
style={{
height: 'auto',
padding: "1px 0",
}}
/>
)
}
},
]
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) => (
<MTableBodyRow
{...props}
draggable="true"
onDragStart={(e) => {
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}

View File

@ -111,6 +111,7 @@ export default function BudgetTableHeader(props) {
<CategoryGroupForm
popupState={categoryGroupPopupState}
mode={'create'}
order={0}
/>
<Button
aria-describedby="category-group-add"

View File

@ -37,6 +37,7 @@ export default function NewCategoryDialog(props) {
await dispatch(updateCategory({
id: props.categoryId,
name: name,
order: props.order,
categoryGroupId: categoryGroup,
}))
break

View File

@ -32,6 +32,7 @@ export default function NewCategoryDialog(props) {
await dispatch(updateCategoryGroup({
id: props.categoryGroupId,
name: name,
order: props.order,
}))
break
}

View File

@ -225,7 +225,9 @@ export default function AppDrawer(props) {
<List dense={true}>
<ListItemButton>
<ListItemIcon size="small" style={{minWidth: '25px'}}>
<AddCircleIcon fontSize="small" />
<AddCircleIcon style={{
fontSize: theme.typography.subtitle2.fontSize,
}} />
</ListItemIcon>
<ListItemText primary="Add Account" onClick={() => props.onAddAccountClick()} />
</ListItemButton>

View File

@ -359,10 +359,12 @@ export default function Account(props) {
await dispatch(fetchBudgetMonth({ month: formatMonthFromDateString(newRow.date) }))
dispatch(refreshBudget())
dispatch(fetchCategoryMonths({ categoryId: newRow.categoryId }))
dispatch(fetchBudgetMonths())
dispatch(fetchPayees())
dispatch(fetchAccounts())
if (newRow.categoryId && newRow.categoryId !== '0') {
dispatch(fetchCategoryMonths({ categoryId: newRow.categoryId }))
}
}
const setTransactionStatus = (rowData) => {
@ -588,6 +590,12 @@ export default function Account(props) {
rowStyle: rowData => ({
fontSize: theme.typography.subtitle2.fontSize,
}),
headerStyle: {
position: 'sticky',
top: 0,
textTransform: 'uppercase',
fontSize: theme.typography.caption.fontSize,
},
}}
components={{
Row: props => (

View File

@ -11,9 +11,9 @@ export const createCategoryGroup = createAsyncThunk('categories/createCategoryGr
return await api.createCategoryGroup(name, store.budgets.activeBudget.id);
})
export const updateCategoryGroup = createAsyncThunk('categories/updateCategoryGroup', async ({ id, name }, { getState }) => {
export const updateCategoryGroup = createAsyncThunk('categories/updateCategoryGroup', async ({ id, name, order }, { getState }) => {
const store = getState()
return await api.updateCategoryGroup(id, name, store.budgets.activeBudget.id);
return await api.updateCategoryGroup(id, name, order, store.budgets.activeBudget.id);
})
export const createCategory = createAsyncThunk('categories/createCategory', async ({ name, categoryGroupId }, { getState }) => {
@ -21,9 +21,9 @@ export const createCategory = createAsyncThunk('categories/createCategory', asyn
return await api.createCategory(name, categoryGroupId, store.budgets.activeBudget.id);
})
export const updateCategory = createAsyncThunk('categories/updateCategory', async ({ id, name, categoryGroupId }, { getState }) => {
export const updateCategory = createAsyncThunk('categories/updateCategory', async ({ id, name, order, categoryGroupId }, { getState }) => {
const store = getState()
return await api.updateCategory(id, name, categoryGroupId, store.budgets.activeBudget.id);
return await api.updateCategory(id, name, order, categoryGroupId, store.budgets.activeBudget.id);
})
const categoriesSlice = createSlice({

View File

@ -88,7 +88,7 @@ class BudgE {
}
try {
return (await this.makeRequest(`budgets/${this.budgetId}/categories/groups`, 'post', { json: { name } })).data
return (await this.makeRequest(`budgets/${this.budgetId}/categories/groups`, 'post', { json: { name, order: 0 } })).data
} catch (err) {
console.log(err.response.body)
process.exit()
@ -108,7 +108,7 @@ class BudgE {
try {
console.log(`Creating category ${name}`)
return (await this.makeRequest(`budgets/${this.budgetId}/categories`, 'post', { json: { name, categoryGroupId } })).data
return (await this.makeRequest(`budgets/${this.budgetId}/categories`, 'post', { json: { name, categoryGroupId, order: 0 } })).data
} catch (err) {
console.log(err.response.body)
process.exit()
@ -389,12 +389,12 @@ class YNAB {
description: 'Budget CSV Location',
name: 'budget',
required: true,
default: "/config/budget.csv",
default: "budget.csv",
}, {
description: 'Register CSV Location',
name: 'register',
required: true,
default: "/config/register.csv",
default: "register.csv",
}]
))
const ynab = new YNAB('', '', fileInfo.register, fileInfo.budget)
@ -404,19 +404,7 @@ class YNAB {
process.exit(1)
}
let category_groups = await ynab.getCategories()
for (let category_group of category_groups) {
// Skip auto-generated CC group / categories
if (category_group.name === 'Credit Card Payments') {
continue
}
const categoryGroup = await budge.findOrCreateCategoryGroup(category_group.name)
for (let ynab_category of category_group['categories']) {
const category = await budge.findOrCreateCategory(ynab_category.name, categoryGroup.id)
}
}
const importMap = []
let ynab_accounts = await ynab.getAccounts()
for (let ynab_account of ynab_accounts) {
const run = (await prompt.get({
@ -444,8 +432,8 @@ class YNAB {
newAccountType = (await prompt.get({
name: 'accounttype',
message: 'Account type is unknown, specify the account type: 0 = Bank, 1 = Credit Card, 2 = Tracking',
validator: /0|1|2|skip/,
warning: 'Must respond 0, 1, 2 or skip',
validator: /0|1|2/,
warning: 'Must respond 0, 1, or 2',
default: 0,
})).accounttype
break
@ -454,12 +442,40 @@ class YNAB {
continue
}
if (!newAccountType || newAccountType === 'skip') {
const transfer = (await prompt.get({
name: 'transfer',
message: `Run transfers for account ${ynab_account.name}?`,
validator: /y[es]*|n[o]?/,
warning: 'Must respond yes or no',
default: 'no'
})).transfer
importMap.push({
...ynab_account,
run_transfers: transfer,
new_account_type: newAccountType,
})
}
let category_groups = await ynab.getCategories()
for (let category_group of category_groups) {
// Skip auto-generated CC group / categories
if (category_group.name === 'Credit Card Payments') {
continue
}
const transactions = await ynab.getTransactions()
const categoryGroup = await budge.findOrCreateCategoryGroup(category_group.name)
for (let ynab_category of category_group['categories']) {
const category = await budge.findOrCreateCategory(ynab_category.name, categoryGroup.id)
}
}
const transactions = (await ynab.getTransactions()).sort(function(a,b){
return a.date - b.date
})
// Have to create all accounts before importing transactions because we handle transfers too
for (const ynab_account of importMap) {
let account = null
// Create account with starting balance
@ -467,13 +483,17 @@ class YNAB {
if (transaction.payee_name == "Starting Balance" && transaction.account_name === ynab_account.name) {
console.log(`Setting starting balance of account ${ynab_account.name} to ${toUnit(transaction.amount)}`)
if (newAccountType == 1) {
account = await budge.findOrCreateAccount(ynab_account.name, newAccountType, multiply(transaction.amount, -1), transaction.date)
if (ynab_account.new_account_type == 1) {
account = await budge.findOrCreateAccount(ynab_account.name, ynab_account.new_account_type, multiply(transaction.amount, -1), transaction.date)
} else {
account = await budge.findOrCreateAccount(ynab_account.name, newAccountType, transaction.amount, transaction.date)
account = await budge.findOrCreateAccount(ynab_account.name, ynab_account.new_account_type, transaction.amount, transaction.date)
}
}
}
}
for (const ynab_account of importMap) {
const account = await budge.findOrCreateAccount(ynab_account.name)
// Pull in transactions, but skip transfers until all accounts are in
for (let transaction of transactions) {
@ -505,61 +525,47 @@ class YNAB {
status: transaction.status,
})
}
}
for (let ynab_account of ynab_accounts) {
const run = (await prompt.get({
name: 'yesno',
message: `Run transfers for account ${ynab_account.name}?`,
validator: /y[es]*|n[o]?/,
warning: 'Must respond yes or no',
default: 'no'
})).yesno
if (run === 'no') {
continue
}
const transactions = await ynab.getTransactions()
const account = await budge.findOrCreateAccount(ynab_account.name)
// Pull in transfer transactions
for (let transaction of transactions) {
if (transaction.account_name !== ynab_account.name) {
continue
}
if (ynab_account.run_transfers === 'yes') {
for (let transaction of transactions) {
if (transaction.account_name !== ynab_account.name) {
continue
}
if (!transaction.payee_name.match(/Transfer : /)) {
continue
}
if (!transaction.payee_name.match(/Transfer : /)) {
continue
}
console.log(`Importing transaction: ${transaction.payee_name}, ${toUnit(transaction.amount)}, ${transaction.date.toISOString().split('T')[0]}`)
let category = null
if (transaction.category_name === 'Ready to Assign') {
transaction.category_name = 'To be Budgeted'
}
if (transaction.category_name) {
category = await budge.findOrCreateCategory(transaction.category_name)
}
console.log(`Importing transaction: ${transaction.payee_name}, ${toUnit(transaction.amount)}, ${transaction.date.toISOString().split('T')[0]}`)
let category = null
if (transaction.category_name === 'Ready to Assign') {
transaction.category_name = 'To be Budgeted'
}
if (transaction.category_name) {
category = await budge.findOrCreateCategory(transaction.category_name)
}
const payee = await budge.findOrCreatePayee(transaction.payee_name)
await budge.postTransaciton({
accountId: account.id,
payeeId: payee.id,
amount: transaction.amount.toJSON().amount,
date: transaction.date.toISOString().split('T')[0],
memo: transaction.memo,
categoryId: category ? category.id : null,
status: transaction.status,
})
const payee = await budge.findOrCreatePayee(transaction.payee_name)
await budge.postTransaciton({
accountId: account.id,
payeeId: payee.id,
amount: transaction.amount.toJSON().amount,
date: transaction.date.toISOString().split('T')[0],
memo: transaction.memo,
categoryId: category ? category.id : null,
status: transaction.status,
})
}
}
}
const categoryMonths = await ynab.getCategoryMonths()
for (const categoryMonth of categoryMonths) {
const category = await budge.findOrCreateCategory(categoryMonth.name)
console.log(`Updating category month ${category.name} - ${categoryMonth.month}: ${categoryMonth.budgeted}`)
await budge.updateCategoryMonth(category.id, categoryMonth.month, categoryMonth.budgeted)
if (parseInt(categoryMonth.budgeted) !== 0) {
const category = await budge.findOrCreateCategory(categoryMonth.name)
console.log(`Updating category month ${category.name} - ${categoryMonth.month}: ${categoryMonth.budgeted}`)
await budge.updateCategoryMonth(category.id, categoryMonth.month, categoryMonth.budgeted)
}
}
})()