From 8d2e0cfdfc888f8adf9e881c59c190f9f499acd6 Mon Sep 17 00:00:00 2001 From: Dmytro Svyrydenko Date: Sun, 22 Sep 2024 13:16:09 +0200 Subject: [PATCH] feat: Add transactions fetching with filters --- src/controllers/transactions.controller.ts | 49 +--- .../get-transaction.ts | 85 ++++++ src/models/Transactions.model.ts | 75 +++-- src/models/transactions.ts | 19 -- src/routes/transactions.route.ts | 7 +- .../transactions/get-transactions.e2e.ts | 258 ++++++++++++++++++ src/services/transactions/get-transactions.ts | 11 +- src/tests/helpers/transactions.ts | 12 +- 8 files changed, 420 insertions(+), 96 deletions(-) create mode 100644 src/controllers/transactions.controller/get-transaction.ts delete mode 100644 src/models/transactions.ts create mode 100644 src/services/transactions/get-transactions.e2e.ts diff --git a/src/controllers/transactions.controller.ts b/src/controllers/transactions.controller.ts index 9af5dc2b..f527350a 100644 --- a/src/controllers/transactions.controller.ts +++ b/src/controllers/transactions.controller.ts @@ -1,56 +1,9 @@ -import { API_RESPONSE_STATUS, SORT_DIRECTIONS, endpointsTypes } from 'shared-types'; +import { API_RESPONSE_STATUS } from 'shared-types'; import { CustomResponse } from '@common/types'; import { ValidationError } from '@js/errors'; import * as transactionsService from '@services/transactions'; import { errorHandler } from './helpers'; -export const getTransactions = async (req, res: CustomResponse) => { - try { - const { id: userId } = req.user; - const { - sort = SORT_DIRECTIONS.desc, - limit, - from = 0, - type: transactionType, - accountType, - accountId, - includeUser, - includeAccount, - includeCategory, - includeAll, - nestedInclude, - // isRaw, - excludeTransfer, - excludeRefunds, - }: endpointsTypes.GetTransactionsQuery = req.query; - - const data = await transactionsService.getTransactions({ - userId, - transactionType, - sortDirection: sort, - limit, - from, - accountType, - accountId, - includeUser, - includeAccount, - includeCategory, - includeAll, - nestedInclude, - excludeTransfer, - excludeRefunds, - isRaw: false, - }); - - return res.status(200).json({ - status: API_RESPONSE_STATUS.success, - response: data, - }); - } catch (err) { - errorHandler(res, err); - } -}; - export const getTransactionById = async (req, res: CustomResponse) => { try { const { id } = req.params; diff --git a/src/controllers/transactions.controller/get-transaction.ts b/src/controllers/transactions.controller/get-transaction.ts new file mode 100644 index 00000000..a6a2251a --- /dev/null +++ b/src/controllers/transactions.controller/get-transaction.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { + API_RESPONSE_STATUS, + SORT_DIRECTIONS, + TRANSACTION_TYPES, + ACCOUNT_TYPES, +} from 'shared-types'; +import { CustomRequest, CustomResponse } from '@common/types'; +import * as transactionsService from '@services/transactions'; +import { errorHandler } from '../helpers'; + +export const getTransactions = async ( + req: CustomRequest, + res: CustomResponse, +) => { + try { + const { id: userId } = req.user; + const { ...restParams } = req.validated.query; + + const data = await transactionsService.getTransactions({ + ...restParams, + userId, + }); + + return res.status(200).json({ + status: API_RESPONSE_STATUS.success, + response: data, + }); + } catch (err) { + console.log('err', err); + errorHandler(res, err); + } +}; + +const parseCommaSeparatedNumbers = (value: string) => + value + .split(',') + .map(Number) + .filter((n) => !isNaN(n)); + +export const getTransactionsSchema = z.object({ + query: z + .object({ + order: z.nativeEnum(SORT_DIRECTIONS).optional().default(SORT_DIRECTIONS.desc), + limit: z.preprocess((val) => Number(val), z.number().int().positive()).optional(), + from: z + .preprocess((val) => Number(val), z.number().int().nonnegative()) + .optional() + .default(0), + transactionType: z.nativeEnum(TRANSACTION_TYPES).optional(), + accountType: z.nativeEnum(ACCOUNT_TYPES).optional(), + accountIds: z + .preprocess( + (val) => (typeof val === 'string' ? parseCommaSeparatedNumbers(val) : val), + z.array(z.number().int().positive()), + ) + .optional(), + includeUser: z.preprocess((val) => val === 'true', z.boolean()).optional(), + includeAccount: z.preprocess((val) => val === 'true', z.boolean()).optional(), + includeCategory: z.preprocess((val) => val === 'true', z.boolean()).optional(), + includeAll: z.preprocess((val) => val === 'true', z.boolean()).optional(), + nestedInclude: z.preprocess((val) => val === 'true', z.boolean()).optional(), + excludeTransfer: z.preprocess((val) => val === 'true', z.boolean()).optional(), + excludeRefunds: z.preprocess((val) => val === 'true', z.boolean()).optional(), + startDate: z + .string() + .datetime({ message: 'Invalid ISO date string for startDate' }) + .optional(), + endDate: z.string().datetime({ message: 'Invalid ISO date string for endDate' }).optional(), + amountLte: z.preprocess((val) => Number(val), z.number().positive()).optional(), + amountGte: z.preprocess((val) => Number(val), z.number().positive()).optional(), + }) + .refine( + (data) => { + if (data.amountGte && data.amountLte) { + return data.amountGte <= data.amountLte; + } + return true; + }, + { + message: 'amountGte must be less than or equal to amountLte', + path: ['amountGte'], + }, + ), +}); diff --git a/src/models/Transactions.model.ts b/src/models/Transactions.model.ts index 236f3ee9..cd83f26b 100644 --- a/src/models/Transactions.model.ts +++ b/src/models/Transactions.model.ts @@ -6,7 +6,7 @@ import { SORT_DIRECTIONS, TransactionModel, } from 'shared-types'; -import { Op, Includeable } from 'sequelize'; +import { Op, Includeable, WhereOptions } from 'sequelize'; import { Table, BeforeCreate, @@ -329,13 +329,13 @@ export default class Transactions extends Model { } } -export const getTransactions = async ({ +export const findWithFilters = async ({ from = 0, limit = 20, accountType, - accountId, + accountIds, userId, - sortDirection = SORT_DIRECTIONS.desc, + order = SORT_DIRECTIONS.desc, includeUser, includeAccount, transactionType, @@ -345,14 +345,18 @@ export const getTransactions = async ({ isRaw = false, excludeTransfer, excludeRefunds, + startDate, + endDate, + amountGte, + amountLte, }: { from: number; limit?: number; accountType?: ACCOUNT_TYPES; transactionType?: TRANSACTION_TYPES; - accountId?: number; + accountIds?: number[]; userId: number; - sortDirection: SORT_DIRECTIONS; + order?: SORT_DIRECTIONS; includeUser?: boolean; includeAccount?: boolean; includeCategory?: boolean; @@ -361,6 +365,10 @@ export const getTransactions = async ({ isRaw: boolean; excludeTransfer?: boolean; excludeRefunds?: boolean; + startDate?: string; + endDate?: string; + amountGte?: number; + amountLte?: number; }) => { const include = prepareTXInclude({ includeUser, @@ -370,21 +378,54 @@ export const getTransactions = async ({ nestedInclude, }); + const whereClause: WhereOptions = { + userId, + ...removeUndefinedKeys({ + accountType, + transactionType, + transferNature: excludeTransfer ? TRANSACTION_TRANSFER_NATURE.not_transfer : undefined, + refundLinked: excludeRefunds ? false : undefined, + }), + }; + + if (accountIds && accountIds.length > 0) { + whereClause.accountId = { + [Op.in]: accountIds, + }; + } + + if (startDate || endDate) { + whereClause.time = {}; + if (startDate && endDate) { + whereClause.time = { + [Op.between]: [new Date(startDate), new Date(endDate)], + }; + } else if (startDate) { + whereClause.time[Op.gte] = new Date(startDate); + } else if (endDate) { + whereClause.time[Op.lte] = new Date(endDate); + } + } + + if (amountGte || amountLte) { + whereClause.amount = {}; + if (amountGte && amountLte) { + whereClause.amount = { + [Op.between]: [amountGte, amountLte], + }; + } else if (amountGte) { + whereClause.amount[Op.gte] = amountGte; + } else if (amountLte) { + whereClause.amount[Op.lte] = amountLte; + } + } + const transactions = await Transactions.findAll({ include, - where: { - userId, - ...removeUndefinedKeys({ - accountType, - accountId, - transactionType, - transferNature: excludeTransfer ? TRANSACTION_TRANSFER_NATURE.not_transfer : undefined, - refundLinked: excludeRefunds ? false : undefined, - }), - }, + where: whereClause, offset: from, limit: limit, - order: [['time', sortDirection]], + order: [['time', order]], raw: isRaw, }); diff --git a/src/models/transactions.ts b/src/models/transactions.ts deleted file mode 100644 index afb2b546..00000000 --- a/src/models/transactions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ACCOUNT_TYPES, SORT_DIRECTIONS, TRANSACTION_TYPES } from 'shared-types'; - -export interface GetTransactionsParams { - userId: number; - sortDirection: SORT_DIRECTIONS; - includeUser?: boolean; - includeAccount?: boolean; - includeCategory?: boolean; - includeAll?: boolean; - nestedInclude?: boolean; - transactionType?: TRANSACTION_TYPES; - limit?: number; - from: number; - accountType?: ACCOUNT_TYPES; - accountId?: number; - isRaw: boolean; - excludeTransfer?: boolean; - excludeRefunds?: boolean; -} diff --git a/src/routes/transactions.route.ts b/src/routes/transactions.route.ts index 1022710a..11eb0b2d 100644 --- a/src/routes/transactions.route.ts +++ b/src/routes/transactions.route.ts @@ -1,6 +1,5 @@ import { Router } from 'express'; import { - getTransactions, getTransactionById, getTransactionsByTransferId, createTransaction, @@ -13,6 +12,10 @@ import { } from '@controllers/transactions.controller'; import { createRefund } from '@controllers/transactions.controller/refunds/create-refund'; import { deleteRefund } from '@controllers/transactions.controller/refunds/delete-refund'; +import { + getTransactions, + getTransactionsSchema, +} from '@controllers/transactions.controller/get-transaction'; import { getRefund } from '@controllers/transactions.controller/refunds/get-refund'; import { getRefunds } from '@controllers/transactions.controller/refunds/get-refunds'; import { getRefundsForTransactionById } from '@controllers/transactions.controller/refunds/get-refunds-for-transaction-by-id'; @@ -27,7 +30,7 @@ router.get('/refunds', authenticateJwt, getRefunds); router.post('/refund', authenticateJwt, createRefund); router.delete('/refund', authenticateJwt, deleteRefund); -router.get('/', authenticateJwt, getTransactions); +router.get('/', authenticateJwt, validateEndpoint(getTransactionsSchema), getTransactions); router.get('/:id', authenticateJwt, getTransactionById); router.get('/:id/refunds', authenticateJwt, getRefundsForTransactionById); router.get('/transfer/:transferId', authenticateJwt, getTransactionsByTransferId); diff --git a/src/services/transactions/get-transactions.e2e.ts b/src/services/transactions/get-transactions.e2e.ts new file mode 100644 index 00000000..4eed5fa9 --- /dev/null +++ b/src/services/transactions/get-transactions.e2e.ts @@ -0,0 +1,258 @@ +import { TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE, SORT_DIRECTIONS } from 'shared-types'; +import { subDays, compareAsc, compareDesc } from 'date-fns'; +import * as helpers from '@tests/helpers'; +import { ERROR_CODES } from '@js/errors'; + +const dates = { + income: '2024-08-02T00:00:00Z', + expense: '2024-08-03T00:00:00Z', + transfer: '2024-09-03T00:00:00Z', + refunds: '2024-07-03T00:00:00Z', +}; + +describe('Retrieve transactions with filters', () => { + const createMockTransactions = async () => { + const accountA = await helpers.createAccount({ raw: true }); + const { + currencies: [currencyB], + } = await helpers.addUserCurrencies({ currencyCodes: ['UAH'], raw: true }); + const accountB = await helpers.createAccount({ + payload: { + ...helpers.buildAccountPayload(), + currencyId: currencyB!.id, + }, + raw: true, + }); + + const [income] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + amount: 2000, + transactionType: TRANSACTION_TYPES.income, + time: dates.income, + }), + raw: true, + }); + const [expense] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountB.id, + amount: 2000, + transactionType: TRANSACTION_TYPES.expense, + time: dates.expense, + }), + raw: true, + }); + const [transferIncome, transferExpense] = await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ accountId: accountA.id, amount: 5000 }), + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationAmount: 10000, + destinationAccountId: accountB.id, + time: dates.transfer, + }, + raw: true, + }); + + const [refundOriginal] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + amount: 1000, + transactionType: TRANSACTION_TYPES.income, + time: dates.refunds, + }), + raw: true, + }); + const refundTxPayload = { + ...helpers.buildTransactionPayload({ + accountId: accountA.id, + amount: 1000, + transactionType: TRANSACTION_TYPES.expense, + time: dates.refunds, + }), + refundsTxId: refundOriginal.id, + }; + const [refundTx] = await helpers.createTransaction({ + payload: refundTxPayload, + raw: true, + }); + + return { income, expense, transferIncome, transferExpense, refundOriginal, refundTx }; + }; + + describe('filtered by dates', () => { + it('[success] for `startDate`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: dates.income, + raw: true, + }); + + expect(res.length).toBe(4); // income, expense, two transfers + }); + it('[success] for `endDate`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + endDate: dates.income, + raw: true, + }); + + expect(res.length).toBe(3); // income, two refunds + }); + it('[success] for date range', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: dates.income, + endDate: dates.expense, + raw: true, + }); + + expect(res.length).toBe(2); // income, expense + }); + it('[success] for date range with the same value', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: dates.income, + endDate: dates.income, + raw: true, + }); + + expect(res.length).toBe(1); // income + }); + it('[success] when `startDate` bigger than `endDate`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: new Date().toISOString(), + endDate: subDays(new Date(), 1).toISOString(), + raw: true, + }); + + expect(res.length).toBe(0); + }); + }); + + it('should retrieve transactions filtered by transactionType', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + transactionType: TRANSACTION_TYPES.expense, + raw: true, + }); + + expect(res.length).toBe(3); // expense, 1 of transfers, 1 of refunds + expect(res.every((t) => t.transactionType === TRANSACTION_TYPES.expense)).toBe(true); + }); + + it('should retrieve transactions excluding transfers', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + excludeTransfer: true, + raw: true, + }); + + expect(res.length).toBe(4); // income, expense, refunds + expect(res.every((t) => t.transferNature === TRANSACTION_TRANSFER_NATURE.not_transfer)).toBe( + true, + ); + }); + + it('should retrieve transactions excluding refunds', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + excludeRefunds: true, + raw: true, + }); + + expect(res.length).toBe(4); + expect(res.every((t) => t.refundLinked === false)).toBe(true); + }); + + it.each([ + [SORT_DIRECTIONS.desc, compareDesc], + [SORT_DIRECTIONS.asc, compareAsc], + ])('should retrieve transactions sorted by time `%s`', async (direction, comparer) => { + const transactions = Object.values(await createMockTransactions()); + + const res = await helpers.getTransactions({ + order: direction, + raw: true, + }); + + expect(res.length).toBe(6); + expect( + transactions.map((t) => t!.time).sort((a, b) => comparer(new Date(a), new Date(b))), + ).toEqual(res.map((t) => t.time)); + }); + + it('should retrieve transactions filtered by accountIds', async () => { + const { expense } = await createMockTransactions(); + + const res = await helpers.getTransactions({ + accountIds: [expense.accountId], + raw: true, + }); + + expect(res.length).toBe(2); // expense, 1 of transfers + expect(res.every((t) => t.accountId === expense.accountId)).toBe(true); + }); + + describe('filter by amount', () => { + it('`amountLte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountLte: 1000, + raw: true, + }); + + expect(res.length).toBe(2); // refunds + res.forEach((tx) => { + expect(tx.amount).toBeGreaterThanOrEqual(1000); + }); + }); + it('`amountGte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountGte: 5000, + raw: true, + }); + + expect(res.length).toBe(2); // transfers + res.forEach((tx) => { + expect(tx.amount).toBeGreaterThanOrEqual(5000); + }); + }); + it('both `amountLte` and `amountGte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountGte: 2000, + amountLte: 5000, + raw: true, + }); + + expect(res.length).toBe(3); // income, expense, 1 of transfers + res.forEach((tx) => { + expect(tx.amount >= 2000 && tx.amount <= 5000).toBe(true); + }); + }); + + it('fails when `amountLte` bigger than `amountGte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountLte: 2000, + amountGte: 5000, + }); + + expect(res.statusCode).toBe(ERROR_CODES.ValidationError); + }); + }); +}); diff --git a/src/services/transactions/get-transactions.ts b/src/services/transactions/get-transactions.ts index 09894aeb..ff69d899 100644 --- a/src/services/transactions/get-transactions.ts +++ b/src/services/transactions/get-transactions.ts @@ -1,9 +1,10 @@ import * as Transactions from '@models/Transactions.model'; -import type { GetTransactionsParams } from '@models/transactions'; import { withTransaction } from '../common'; -export const getTransactions = withTransaction(async (params: GetTransactionsParams) => { - const data = await Transactions.getTransactions(params); +export const getTransactions = withTransaction( + async (params: Omit[0], 'isRaw'>) => { + const data = await Transactions.findWithFilters({ ...params, isRaw: true }); - return data; -}); + return data; + }, +); diff --git a/src/tests/helpers/transactions.ts b/src/tests/helpers/transactions.ts index b9549f63..5f6a1110 100644 --- a/src/tests/helpers/transactions.ts +++ b/src/tests/helpers/transactions.ts @@ -9,6 +9,7 @@ import { import { CreateTransactionBody } from '../../../shared-types/routes'; import Transactions from '@models/Transactions.model'; import * as transactionsService from '@services/transactions'; +import { getTransactions as apiGetTransactions } from '@services/transactions/get-transactions'; import { makeRequest } from './common'; import { createAccount } from './account'; @@ -98,13 +99,14 @@ export function deleteTransaction({ id }: { id?: number } = {}): Promise; -export function getTransactions({ raw }: { raw?: false }): Promise; -export function getTransactions({ raw }: { raw?: true }): Promise; -export function getTransactions({ raw = false } = {}) { - return makeRequest({ +export function getTransactions({ + raw, + ...rest +}: { raw?: R } & Partial[0], 'userId'>> = {}) { + return makeRequest>, R>({ method: 'get', url: '/transactions', + payload: rest, raw, }); }