Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add spending analytics by categories #91

Merged
merged 4 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion shared-types/routes/stats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AccountModel } from '../models';
import { AccountModel, TransactionModel } from '../models';
import { QueryPayload } from './index'

export interface GetBalanceHistoryPayload extends QueryPayload {
Expand All @@ -12,3 +12,25 @@ export interface GetBalanceHistoryPayload extends QueryPayload {
export interface GetTotalBalancePayload extends QueryPayload {
date: string;
}

export interface GetSpendingCategoriesPayload extends QueryPayload {
accountId?: AccountModel['id'];
// yyyy-mm-dd
from?: string;
// yyyy-mm-dd
to?: string;
raw?: boolean;
}

// TODO: Improve that logic and expose type from the source-code.
// Currently frontend (vite) complains about it and trying to import source code
type TransactionEntity = Pick<
TransactionModel,
'accountId' | 'time' | 'amount' | 'refAmount' | 'currencyId' | 'currencyCode' | 'categoryId'
>[];

interface TransactionGroup {
transactions: TransactionEntity;
nestedCategories: { [categoryId: number]: TransactionGroup };
}
export type GetSpendingsByCategoriesReturnType = { [categoryId: number]: TransactionGroup }
3 changes: 3 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ export interface GenericSequelizeModelAttributes {
transaction?: Transaction;
raw?: boolean;
}

export type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
export type UnwrapArray<T> = T extends (infer U)[] ? U : T;
2 changes: 1 addition & 1 deletion src/controllers/categories.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const getCategories = async (req, res: CustomResponse) => {
const { rawCategories } = req.query;

try {
const data = await Categories.getCategories({ id });
const data = await Categories.getCategories({ userId: id });

if (rawCategories !== undefined) {
return res.status(200).json({
Expand Down
82 changes: 82 additions & 0 deletions src/controllers/stats.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { API_RESPONSE_STATUS, BalanceModel, endpointsTypes } from 'shared-types'
import { isValid, isBefore } from 'date-fns';
import { CustomResponse } from '@common/types';
import * as statsService from '@services/stats';
import { removeUndefinedKeys } from '@js/helpers';
import { errorHandler } from './helpers';
import { ValidationError } from '@js/errors';

Expand Down Expand Up @@ -62,3 +63,84 @@ export const getTotalBalance = async (req, res: CustomResponse) => {
errorHandler(res, err);
}
}

export const getExpensesHistory = async (req, res: CustomResponse) => {
const { id: userId } = req.user;
const { from, to, accountId }: endpointsTypes.GetSpendingCategoriesPayload = req.query;

try {
if (from && !isValid(new Date(from))) throw new ValidationError({ message: '"from" is invalid date.' })
if (to && !isValid(new Date(to))) throw new ValidationError({ message: '"to" is invalid date.' })
if (from && to && !isBefore(new Date(from), new Date(to))) {
throw new ValidationError({ message: '"from" cannot be greater than "to" date.' })
}

const result = await statsService.getExpensesHistory(removeUndefinedKeys({
userId,
from,
to,
accountId,
}));

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: result,
});
} catch (err) {
errorHandler(res, err);
}
};

export const getSpendingsByCategories = async (req, res: CustomResponse) => {
const { id: userId } = req.user;
const { from, to, accountId }: endpointsTypes.GetSpendingCategoriesPayload = req.query;

try {
if (from && !isValid(new Date(from))) throw new ValidationError({ message: '"from" is invalid date.' })
if (to && !isValid(new Date(to))) throw new ValidationError({ message: '"to" is invalid date.' })
if (from && to && !isBefore(new Date(from), new Date(to))) {
throw new ValidationError({ message: '"from" cannot be greater than "to" date.' })
}

const result = await statsService.getSpendingsByCategories(removeUndefinedKeys({
userId,
from,
to,
accountId,
}));

return res.status(200).json<endpointsTypes.GetSpendingsByCategoriesReturnType>({
status: API_RESPONSE_STATUS.success,
response: result,
});
} catch (err) {
errorHandler(res, err);
}
};

export const getExpensesAmountForPeriod = async (req, res: CustomResponse) => {
const { id: userId } = req.user;
const { from, to, accountId }: endpointsTypes.GetSpendingCategoriesPayload = req.query;

try {
if (from && !isValid(new Date(from))) throw new ValidationError({ message: '"from" is invalid date.' })
if (to && !isValid(new Date(to))) throw new ValidationError({ message: '"to" is invalid date.' })
if (from && to && !isBefore(new Date(from), new Date(to))) {
throw new ValidationError({ message: '"from" cannot be greater than "to" date.' })
}

const result = await statsService.getExpensesAmountForPeriod(removeUndefinedKeys({
userId,
from,
to,
accountId,
}));

return res.status(200).json<number>({
status: API_RESPONSE_STATUS.success,
response: result,
});
} catch (err) {
errorHandler(res, err);
}
};
16 changes: 9 additions & 7 deletions src/models/Accounts.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DataType,
AfterCreate,
BeforeUpdate,
HasMany,
} from 'sequelize-typescript';
import { Op } from 'sequelize';
import { ACCOUNT_TYPES } from 'shared-types';
Expand All @@ -15,6 +16,7 @@ import Users from '@models/Users.model';
import Currencies from '@models/Currencies.model';
import AccountTypes from '@models/AccountTypes.model';
import Balances from '@models/Balances.model';
import Transactions from '@models/Transactions.model';

export interface AccountsAttributes {
id: number;
Expand Down Expand Up @@ -43,13 +45,13 @@ export interface AccountsAttributes {
timestamps: false,
})
export default class Accounts extends Model<AccountsAttributes> {
@BelongsTo(
() => Currencies,
{
as: 'currency',
foreignKey: 'currencyId',
}
)
@BelongsTo(() => Currencies, {
as: 'currency',
foreignKey: 'currencyId',
})

@HasMany(() => Transactions)
transactions: Transactions[];;

@Column({
unique: true,
Expand Down
12 changes: 10 additions & 2 deletions src/models/Categories.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DataType,
BelongsToMany,
} from 'sequelize-typescript';
import { GenericSequelizeModelAttributes } from '@common/types';
import Users from './Users.model';
import UserMerchantCategoryCodes from './UserMerchantCategoryCodes.model';
import MerchantCategoryCodes from './MerchantCategoryCodes.model';
Expand Down Expand Up @@ -57,8 +58,15 @@ export default class Categories extends Model {
categoryId: number;
}

export const getCategories = async ({ id }) => {
const categories = await Categories.findAll({ where: { userId: id }, raw: true });
export const getCategories = async (
{ userId }: { userId: number; },
attributes: GenericSequelizeModelAttributes = {},
) => {
const categories = await Categories.findAll({
where: { userId },
raw: attributes.raw ?? true,
transaction: attributes.transaction,
});

return categories;
};
Expand Down
4 changes: 4 additions & 0 deletions src/models/Transactions.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Length,
ForeignKey,
DataType,
BelongsTo,
} from 'sequelize-typescript';
import { isExist, removeUndefinedKeys } from '@js/helpers';
import { ValidationError } from '@js/errors'
Expand Down Expand Up @@ -133,6 +134,9 @@ export default class Transactions extends Model<TransactionsAttributes> {
@Column({ allowNull: false })
accountId: number;

@BelongsTo(() => Accounts)
account: Accounts;

@ForeignKey(() => Categories)
@Column
categoryId: number;
Expand Down
4 changes: 4 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const DBConfig: Record<string, unknown> = config.get('db');
const sequelize = new Sequelize({
...DBConfig,
models: [__dirname + '/**/*.model.ts'],
pool: {
max: 50,
evict: 10000,
},
});

if (['development'].includes(process.env.NODE_ENV)) {
Expand Down
3 changes: 3 additions & 0 deletions src/routes/stats.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ const router = Router({});

router.get('/balance-history', authenticateJwt, statsController.getBalanceHistory);
router.get('/total-balance', authenticateJwt, statsController.getTotalBalance);
router.get('/expenses-history', authenticateJwt, statsController.getExpensesHistory);
router.get('/expenses-amount-for-period', authenticateJwt, statsController.getExpensesAmountForPeriod);
router.get('/spendings-by-categories', authenticateJwt, statsController.getSpendingsByCategories);

export default router;
27 changes: 27 additions & 0 deletions src/services/stats/get-expenses-amount-for-period.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { GenericSequelizeModelAttributes } from '@common/types';

import { connection } from '@models/index';
import { getExpensesHistory } from './get-expenses-history';

export const getExpensesAmountForPeriod = async (
params: Parameters<typeof getExpensesHistory>[0],
attributes: GenericSequelizeModelAttributes = {},
) => {
const isTxPassedFromAbove = attributes.transaction !== undefined;
const transaction = attributes.transaction ?? await connection.sequelize.transaction();

try {
const transactions = await getExpensesHistory(params);

if (!isTxPassedFromAbove) {
await transaction.commit();
}

return transactions.reduce((acc, curr) => acc + curr.refAmount, 0);
} catch (err) {
if (!isTxPassedFromAbove) {
await transaction.rollback();
}
throw err;
}
};
97 changes: 97 additions & 0 deletions src/services/stats/get-expenses-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Op } from 'sequelize';
import { TransactionModel, TRANSACTION_TYPES } from 'shared-types';
import { removeUndefinedKeys } from '@js/helpers';
import { GenericSequelizeModelAttributes } from '@common/types';

import { connection } from '@models/index';
import * as Transactions from '@models/Transactions.model';

interface DateQuery {
// yyyy-mm-dd
from?: string;
// yyyy-mm-dd
to?: string;
}

const getWhereConditionForTime = ({ from, to }: DateQuery) => {
const where: { time?: Record<symbol, Date[] | Date> } = {}

if (from && to) {
where.time = {
[Op.between]: [new Date(from), new Date(to)],
};
} else if (from) {
where.time = {
[Op.gte]: new Date(from),
};
} else if (to) {
where.time = {
[Op.lte]: new Date(to),
};
}

return where;
};

export type GetExpensesHistoryResponseSchema = Pick<
TransactionModel,
'accountId' | 'time' | 'amount' | 'refAmount' | 'currencyId'
| 'currencyCode' | 'categoryId'
>

/**
* Fetches the expense history for a specified user within an optional date range and account.
*
* @param {Object} params - The parameters for fetching balances.
* @param {number} params.userId - The ID of the user for whom balances are to be fetched.
* @param {string} [params.from] - The start date (inclusive) of the date range in 'yyyy-mm-dd' format.
* @param {string} [params.to] - The end date (inclusive) of the date range in 'yyyy-mm-dd' format.
* @param {string} [params.accountId] - Load history for asked account.
* @param {GenericSequelizeModelAttributes} [attributes={}] - Additional Sequelize model attributes for the query.
* @returns {Promise<BalanceModel[]>} - A promise that resolves to an array of expenses records.
* @throws {Error} - Throws an error if the database query fails.
*
* @example
* const balances = await getExpensesHistory({ userId: 1, from: '2023-01-01', to: '2023-12-31' });
*/
export const getExpensesHistory = async (
{ userId, from, to, accountId }: {
userId: number;
accountId?: number;
from?: string;
to?: string;
},
attributes: GenericSequelizeModelAttributes = {},
): Promise<GetExpensesHistoryResponseSchema[]> => {
const isTxPassedFromAbove = attributes.transaction !== undefined;
const transaction = attributes.transaction ?? await connection.sequelize.transaction();

try {
const dataAttributes: (keyof Transactions.default)[] = ['accountId', 'time', 'amount', 'refAmount', 'currencyId', 'currencyCode', 'categoryId'];

const transactions = await Transactions.default.findAll({
where: removeUndefinedKeys({
accountId,
userId,
isTransfer: false,
transactionType: TRANSACTION_TYPES.expense,
...getWhereConditionForTime({ from, to }),
}),
order: [['time', 'ASC']],
raw: attributes.raw || true,
attributes: dataAttributes,
transaction,
});

if (!isTxPassedFromAbove) {
await transaction.commit();
}

return transactions;
} catch (err) {
if (!isTxPassedFromAbove) {
await transaction.rollback();
}
throw err;
}
};
Loading
Loading