mirror of
https://github.com/linuxserver/budge.git
synced 2026-03-09 00:08:38 +08:00
Merge pull request #3 from alex-phillips/entity-refactor
Entity refactor
This commit is contained in:
commit
6f953b481c
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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()
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import { User } from './User'
|
||||
import { Budget } from './Budget'
|
||||
import { Account } from './Account'
|
||||
|
||||
export { User, Budget, Account }
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
41
backend/src/repositories/BudgetMonths.ts
Normal file
41
backend/src/repositories/BudgetMonths.ts
Normal 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
|
||||
}
|
||||
}
|
||||
38
backend/src/repositories/CategoryMonths.ts
Normal file
38
backend/src/repositories/CategoryMonths.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
// }
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { getConnectionManager } from 'typeorm'
|
||||
import { UserRepository } from './UserRepository'
|
||||
|
||||
const userRepository = getConnectionManager().get('default').getRepository(UserRepository)
|
||||
|
||||
export { userRepository }
|
||||
@ -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', () => {
|
||||
|
||||
73
backend/src/subscribers/AccountSubscriber.ts
Normal file
73
backend/src/subscribers/AccountSubscriber.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
64
backend/src/subscribers/BudgetMonthSubscriber.ts
Normal file
64
backend/src/subscribers/BudgetMonthSubscriber.ts
Normal 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,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
66
backend/src/subscribers/BudgetSubscriber.ts
Normal file
66
backend/src/subscribers/BudgetSubscriber.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
130
backend/src/subscribers/CategoryMonthSubscriber.ts
Normal file
130
backend/src/subscribers/CategoryMonthSubscriber.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
34
backend/src/subscribers/CategorySubscriber.ts
Normal file
34
backend/src/subscribers/CategorySubscriber.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
502
backend/src/subscribers/TransactionSubscriber.ts
Normal file
502
backend/src/subscribers/TransactionSubscriber.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -111,6 +111,7 @@ export default function BudgetTableHeader(props) {
|
||||
<CategoryGroupForm
|
||||
popupState={categoryGroupPopupState}
|
||||
mode={'create'}
|
||||
order={0}
|
||||
/>
|
||||
<Button
|
||||
aria-describedby="category-group-add"
|
||||
|
||||
@ -37,6 +37,7 @@ export default function NewCategoryDialog(props) {
|
||||
await dispatch(updateCategory({
|
||||
id: props.categoryId,
|
||||
name: name,
|
||||
order: props.order,
|
||||
categoryGroupId: categoryGroup,
|
||||
}))
|
||||
break
|
||||
|
||||
@ -32,6 +32,7 @@ export default function NewCategoryDialog(props) {
|
||||
await dispatch(updateCategoryGroup({
|
||||
id: props.categoryGroupId,
|
||||
name: name,
|
||||
order: props.order,
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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({
|
||||
|
||||
146
ynab/import.js
146
ynab/import.js
@ -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)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user