diff --git a/shared-types/routes/transactions.ts b/shared-types/routes/transactions.ts index 7a0fb4f2..af5557dd 100644 --- a/shared-types/routes/transactions.ts +++ b/shared-types/routes/transactions.ts @@ -53,3 +53,7 @@ export interface UpdateTransactionBody { export interface UnlinkTransferTransactionsBody { transferIds: string[]; } +// Array of income/expense pairs to link between each other +export interface LinkTransactionsBody { + ids: [number, number][]; +} diff --git a/src/controllers/transactions.controller.ts b/src/controllers/transactions.controller.ts index 2fb08f37..c3702208 100644 --- a/src/controllers/transactions.controller.ts +++ b/src/controllers/transactions.controller.ts @@ -20,7 +20,7 @@ export const getTransactions = async (req, res: CustomResponse) => { includeAll, nestedInclude, isRaw, - } = req.query + } = req.query; const data = await transactionsService.getTransactions({ userId, @@ -41,7 +41,7 @@ export const getTransactions = async (req, res: CustomResponse) => { return res.status(200).json({ status: API_RESPONSE_STATUS.success, response: data, - }) + }); } catch (err) { errorHandler(res, err); } diff --git a/src/controllers/transactions.controller/index.ts b/src/controllers/transactions.controller/index.ts index c200411e..2ec7c6cd 100644 --- a/src/controllers/transactions.controller/index.ts +++ b/src/controllers/transactions.controller/index.ts @@ -2,4 +2,4 @@ export * from './create-transaction'; export * from './delete-transaction'; export * from './update-transaction'; -export * from './transfer-linking/unlink-transfer-transactions'; +export * from './transfer-linking'; diff --git a/src/controllers/transactions.controller/transfer-linking/index.ts b/src/controllers/transactions.controller/transfer-linking/index.ts new file mode 100644 index 00000000..cbe01707 --- /dev/null +++ b/src/controllers/transactions.controller/transfer-linking/index.ts @@ -0,0 +1,2 @@ +export * from './link-transactions'; +export * from './unlink-transfer-transactions'; diff --git a/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts b/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts new file mode 100644 index 00000000..036c8b3b --- /dev/null +++ b/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts @@ -0,0 +1,185 @@ +import { TRANSACTION_TRANSFER_NATURE, TRANSACTION_TYPES } from 'shared-types'; +import * as helpers from '@tests/helpers'; +import { ERROR_CODES } from '@js/errors'; + +describe('link transactions between each other', () => { + it('link two valid transactions', async () => { + // Create 2 income and 2 expense to check that multiple updation is possible + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const [incomeA] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + transactionType: TRANSACTION_TYPES.income, + }), + raw: true, + }); + const [incomeB] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountB.id, + transactionType: TRANSACTION_TYPES.income, + }), + raw: true, + }); + const [expenseA] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + transactionType: TRANSACTION_TYPES.expense, + }), + raw: true, + }); + const [expenseB] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountB.id, + transactionType: TRANSACTION_TYPES.expense, + }), + raw: true, + }); + + const linkingResult = await helpers.linkTransactions({ + payload: { + ids: [ + [incomeA.id, expenseB.id], + [incomeB.id, expenseA.id], + ], + }, + raw: true, + }); + + // Check that linkind response is coorect + [incomeA, incomeB, expenseA, expenseB].forEach((tx) => { + const txAfter = linkingResult.flat().find((t) => t.id === tx.id); + // Expect that only transferNature and transferId were changed + expect({ ...tx }).toEqual({ + ...txAfter, + transferNature: expect.toBeAnythingOrNull(), + transferId: expect.toBeAnythingOrNull(), + }); + + expect(txAfter.transferNature).toBe( + TRANSACTION_TRANSFER_NATURE.common_transfer, + ); + expect(txAfter.transferId).toEqual(expect.any(String)); + }); + + // Check that transactions fetching also returns correct result + const txsAfterUpdation = await helpers.getTransactions({ raw: true }); + [incomeA, incomeB, expenseA, expenseB].forEach((tx) => { + const txAfter = txsAfterUpdation.find((t) => t.id === tx.id); + // Expect that only transferNature and transferId were changed + expect({ ...tx }).toEqual({ + ...txAfter, + transferNature: expect.toBeAnythingOrNull(), + transferId: expect.toBeAnythingOrNull(), + }); + + expect(txAfter.transferNature).toBe( + TRANSACTION_TRANSFER_NATURE.common_transfer, + ); + expect(txAfter.transferId).toEqual(expect.any(String)); + }); + + expect(incomeA.transferId).toBe(expenseB.transferId); + expect(incomeB.transferId).toBe(expenseA.transferId); + }); + + it('throws an error when trying to link tx from the same account', async () => { + await helpers.monobank.pair(); + const { transactions } = await helpers.monobank.mockTransactions(); + + const tx1 = transactions.find( + (item) => item.transactionType === TRANSACTION_TYPES.expense, + ); + const tx2 = transactions.find( + (item) => item.transactionType === TRANSACTION_TYPES.income, + ); + + const result = await helpers.linkTransactions({ + payload: { + ids: [[tx1.id, tx2.id]], + }, + }); + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }); + + it.each([[TRANSACTION_TYPES.expense], [TRANSACTION_TYPES.income]])( + 'throws an error when trying to link tx with same transactionType. test %s type', + async (txType) => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const [tx1] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + transactionType: txType, + }), + raw: true, + }); + const [tx2] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountB.id, + transactionType: txType, + }), + raw: true, + }); + + const result = await helpers.linkTransactions({ + payload: { + ids: [[tx1.id, tx2.id]], + }, + }); + + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }, + ); + + it.each([[TRANSACTION_TYPES.expense], [TRANSACTION_TYPES.income]])( + 'throws an error when trying to link to the transaction that is already a transfer. test %s type', + async (txType) => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + const accountC = await helpers.createAccount({ raw: true }); + + const [tx1] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + transactionType: txType, + }), + raw: true, + }); + const transactions = await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ + accountId: accountB.id, + amount: 10, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationAmount: 20, + destinationAccountId: accountC.id, + }), + }, + raw: true, + }); + + const expenseTx = transactions.find( + (t) => t.transactionType === TRANSACTION_TYPES.expense, + ); + const incomeTx = transactions.find( + (t) => t.transactionType === TRANSACTION_TYPES.income, + ); + + const result = await helpers.linkTransactions({ + payload: { + ids: [ + [ + tx1.id, + txType === TRANSACTION_TYPES.income ? expenseTx.id : incomeTx.id, + ], + ], + }, + }); + + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }, + ); +}); diff --git a/src/controllers/transactions.controller/transfer-linking/link-transactions.ts b/src/controllers/transactions.controller/transfer-linking/link-transactions.ts new file mode 100644 index 00000000..3b590c39 --- /dev/null +++ b/src/controllers/transactions.controller/transfer-linking/link-transactions.ts @@ -0,0 +1,29 @@ +import { API_RESPONSE_STATUS, endpointsTypes } from 'shared-types'; +import { CustomResponse } from '@common/types'; +import { errorHandler } from '@controllers/helpers'; +import * as transactionsService from '@services/transactions'; +import { ValidationError } from '@js/errors'; + +export const linkTransactions = async (req, res: CustomResponse) => { + try { + const { ids }: endpointsTypes.LinkTransactionsBody = req.body; + const { id: userId } = req.user; + + if (!ids || !Array.isArray(ids)) { + throw new ValidationError({ + message: '"ids" field is missing or invalid.', + }); + } + + const data = await transactionsService.linkTransactions({ + userId, + ids, + }); + + return res + .status(200) + .json({ status: API_RESPONSE_STATUS.success, response: data }); + } catch (err) { + errorHandler(res, err); + } +}; diff --git a/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts index 696c36b2..2d04716e 100644 --- a/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts +++ b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts @@ -80,19 +80,12 @@ describe('Unlink transfer transactions', () => { }); // Now link 1 external with 1 system for each type - const updatedA = await helpers.updateTransaction({ - id: expenseExternalTx.id, + const [updatedA, updatedB] = await helpers.linkTransactions({ payload: { - transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - destinationTransactionId: incomeSystemTx.id, - }, - raw: true, - }); - const updatedB = await helpers.updateTransaction({ - id: incomeExternalTx.id, - payload: { - transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - destinationTransactionId: expenseSystemTx.id, + ids: [ + [expenseExternalTx.id, incomeSystemTx.id], + [incomeExternalTx.id, expenseSystemTx.id], + ], }, raw: true, }); diff --git a/src/models/Transactions.model.ts b/src/models/Transactions.model.ts index c952e8d0..d594192b 100644 --- a/src/models/Transactions.model.ts +++ b/src/models/Transactions.model.ts @@ -4,6 +4,7 @@ import { TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE, SORT_DIRECTIONS, + TransactionModel, } from 'shared-types'; import { Op } from 'sequelize'; import { Transaction } from 'sequelize/types'; @@ -508,7 +509,9 @@ export const getTransactionsByTransferId = ( }); }; -export const getTransactionsByArrayOfField = async ( +export const getTransactionsByArrayOfField = async < + T extends keyof TransactionModel, +>( { fieldValues, fieldName, @@ -519,8 +522,8 @@ export const getTransactionsByArrayOfField = async ( includeAll, nestedInclude, }: { - fieldValues: unknown[]; - fieldName: string; + fieldValues: TransactionModel[T][]; + fieldName: T; userId: number; includeUser?: boolean; includeAccount?: boolean; diff --git a/src/routes/transactions.route.ts b/src/routes/transactions.route.ts index 978a7396..abc4e64b 100644 --- a/src/routes/transactions.route.ts +++ b/src/routes/transactions.route.ts @@ -6,6 +6,7 @@ import { createTransaction, updateTransaction, unlinkTransferTransactions, + linkTransactions, deleteTransaction, } from '@controllers/transactions.controller'; import { authenticateJwt } from '@middlewares/passport'; @@ -21,6 +22,7 @@ router.get( ); router.post('/', authenticateJwt, createTransaction); router.put('/unlink', authenticateJwt, unlinkTransferTransactions); +router.put('/link', authenticateJwt, linkTransactions); router.put('/:id', authenticateJwt, updateTransaction); router.delete('/:id', authenticateJwt, deleteTransaction); diff --git a/src/services/transactions/create-transaction.ts b/src/services/transactions/create-transaction.ts index 06be67f3..ede85572 100644 --- a/src/services/transactions/create-transaction.ts +++ b/src/services/transactions/create-transaction.ts @@ -16,7 +16,7 @@ import * as Accounts from '@models/Accounts.model'; import * as UsersCurrencies from '@models/UsersCurrencies.model'; import { calculateRefAmount } from '@services/calculate-ref-amount.service'; -import { linkTransactions } from './link-transaction'; +import { linkTransactions } from './transactions-linking'; import type { CreateTransactionParams, UpdateTransactionParams } from './types'; type CreateOppositeTransactionParams = [ @@ -273,11 +273,11 @@ export const createTransaction = async ( * doesn't exist */ if (destinationTransactionId) { - const { baseTx, oppositeTx } = await linkTransactions( + const [[baseTx, oppositeTx]] = await linkTransactions( { userId, - baseTx: baseTransaction, - destinationTransactionId, + ids: [[baseTransaction.id, destinationTransactionId]], + ignoreBaseTxTypeValidation: true, }, { transaction }, ); diff --git a/src/services/transactions/get-transactions.ts b/src/services/transactions/get-transactions.ts index c1eedd82..02ad9c43 100644 --- a/src/services/transactions/get-transactions.ts +++ b/src/services/transactions/get-transactions.ts @@ -1,16 +1,19 @@ import * as Transactions from '@models/Transactions.model'; import { GenericSequelizeModelAttributes } from '@common/types'; -import { type GetTransactionsParams } from '@models/transactions'; +import type { GetTransactionsParams } from '@models/transactions'; export const getTransactions = async ( - params: GetTransactionsParams, - attributes: GenericSequelizeModelAttributes = {}, + params: GetTransactionsParams, + attributes: GenericSequelizeModelAttributes = {}, ) => { + // eslint-disable-next-line no-useless-catch try { - const data = await Transactions.getTransactions(params, { transaction: attributes.transaction }); - + const data = await Transactions.getTransactions(params, { + transaction: attributes.transaction, + }); + return data; - } catch(err) { - throw new err; + } catch (err) { + throw err; } -}; \ No newline at end of file +}; diff --git a/src/services/transactions/index.ts b/src/services/transactions/index.ts index cdf1b216..7cbd74f1 100644 --- a/src/services/transactions/index.ts +++ b/src/services/transactions/index.ts @@ -5,4 +5,4 @@ export * from './get-transactions'; export * from './update-transaction'; export * from './get-by-transfer-id'; export * from './get-transaction-by-some-id'; -export * from './transactions-linking/unlink-transfer-transactions'; +export * from './transactions-linking'; diff --git a/src/services/transactions/link-transaction.ts b/src/services/transactions/link-transaction.ts deleted file mode 100644 index c7663131..00000000 --- a/src/services/transactions/link-transaction.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { TRANSACTION_TRANSFER_NATURE } from 'shared-types'; -import { logger } from '@js/utils/logger'; -import { GenericSequelizeModelAttributes } from '@common/types'; -import * as Transactions from '@models/Transactions.model'; -import { v4 as uuidv4 } from 'uuid'; -import { getTransactionById } from './get-by-id'; -import { ValidationError } from '@js/errors'; - -const validateTransactionLinking = ( - baseTx: Transactions.default, - oppositeTx: Transactions.default, -) => { - if (oppositeTx.transactionType === baseTx.transactionType) { - throw new ValidationError({ - message: - 'Trying to link with the transaction that has the same "transactionType".', - }); - } - if (oppositeTx.accountId === baseTx.accountId) { - throw new ValidationError({ - message: - "Trying to link with the transaction within the same account. It's allowed to link only between different accounts", - }); - } - if (oppositeTx.transferNature !== TRANSACTION_TRANSFER_NATURE.not_transfer) { - // TODO: disabled when multiple links are available - throw new ValidationError({ - message: - 'Trying to link with the transaction that is already a transfer.', - }); - } -}; - -export const linkTransactions = async ( - { - userId, - baseTx, - destinationTransactionId, - }: { - destinationTransactionId: number; - userId: number; - baseTx: Transactions.default; - }, - attributes: GenericSequelizeModelAttributes = {}, -) => { - try { - const transferId = uuidv4(); - - const sharedPayload = { - userId, - transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - transferId, - }; - - const oppositeTx = await getTransactionById( - { userId, id: destinationTransactionId }, - { transaction: attributes.transaction }, - ); - - validateTransactionLinking(baseTx, oppositeTx); - - const [base, opposite] = await Promise.all([ - Transactions.updateTransactionById( - { id: baseTx.id, ...sharedPayload }, - { transaction: attributes.transaction }, - ), - Transactions.updateTransactionById( - { id: destinationTransactionId, ...sharedPayload }, - { transaction: attributes.transaction }, - ), - ]); - - return { baseTx: base, oppositeTx: opposite }; - } catch (e) { - logger.error(e); - throw e; - } -}; diff --git a/src/services/transactions/transactions-linking/index.ts b/src/services/transactions/transactions-linking/index.ts new file mode 100644 index 00000000..0b8fb965 --- /dev/null +++ b/src/services/transactions/transactions-linking/index.ts @@ -0,0 +1,2 @@ +export * from './unlink-transfer-transactions'; +export * from './link-transactions'; diff --git a/src/services/transactions/transactions-linking/link-transactions.ts b/src/services/transactions/transactions-linking/link-transactions.ts new file mode 100644 index 00000000..cab422da --- /dev/null +++ b/src/services/transactions/transactions-linking/link-transactions.ts @@ -0,0 +1,135 @@ +import { TRANSACTION_TRANSFER_NATURE } from 'shared-types'; +import { logger } from '@js/utils/logger'; +import { GenericSequelizeModelAttributes } from '@common/types'; +import * as Transactions from '@models/Transactions.model'; +import { v4 as uuidv4 } from 'uuid'; +import { connection } from '@models/index'; +import { ValidationError } from '@js/errors'; +import { Op, Transaction } from 'sequelize'; + +const validateTransactionLinking = ({ + base, + opposite, + ignoreBaseTxTypeValidation, +}: { + base: Transactions.default; + opposite: Transactions.default; + ignoreBaseTxTypeValidation?: boolean; +}) => { + if (base.id === opposite.id) { + throw new ValidationError({ + message: 'Trying to link the transaction to itself.', + }); + } + if (opposite.transactionType === base.transactionType) { + throw new ValidationError({ + message: + 'Trying to link with the transaction that has the same "transactionType".', + }); + } + if (opposite.accountId === base.accountId) { + throw new ValidationError({ + message: + "Trying to link with the transaction within the same account. It's allowed to link only between different accounts", + }); + } + if ( + opposite.transferNature !== TRANSACTION_TRANSFER_NATURE.not_transfer || + (!ignoreBaseTxTypeValidation && + base.transferNature !== TRANSACTION_TRANSFER_NATURE.not_transfer) + ) { + // TODO: disabled when multiple links are available + throw new ValidationError({ + message: 'Trying to link transaction that is already a transfer.', + }); + } +}; + +export const linkTransactions = async ( + { + userId, + ids, + ignoreBaseTxTypeValidation, + }: { + userId: number; + ids: [baseTxId: number, oppositeTxId: number][]; + ignoreBaseTxTypeValidation?: boolean; + }, + attributes: GenericSequelizeModelAttributes = {}, +): Promise< + [baseTx: Transactions.default, oppositeTx: Transactions.default][] +> => { + const isTxPassedFromAbove = attributes.transaction !== undefined; + const transaction: Transaction = + attributes.transaction ?? (await connection.sequelize.transaction()); + + try { + const result: [ + baseTx: Transactions.default, + oppositeTx: Transactions.default, + ][] = []; + + for (const [baseTxId, oppositeTxId] of ids) { + let transactions = await Transactions.getTransactionsByArrayOfField( + { + userId, + fieldName: 'id', + fieldValues: [baseTxId, oppositeTxId], + }, + { transaction }, + ); + + if (transactions.length !== 2) { + throw new ValidationError({ + message: 'Unexpected error. Cannot link asked transactions.', + }); + } + + validateTransactionLinking({ + base: transactions.find((tx) => tx.id === baseTxId), + opposite: transactions.find((tx) => tx.id === oppositeTxId), + ignoreBaseTxTypeValidation, + }); + + await Transactions.default.update( + { + transferId: uuidv4(), + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + }, + { + where: { + userId, + id: { [Op.in]: [baseTxId, oppositeTxId] }, + }, + transaction, + }, + ); + + transactions = await Transactions.getTransactionsByArrayOfField( + { + userId, + fieldName: 'id', + fieldValues: [baseTxId, oppositeTxId], + }, + { transaction }, + ); + + result.push([ + transactions.find((tx) => tx.id === baseTxId), + transactions.find((tx) => tx.id === oppositeTxId), + ]); + } + + if (!isTxPassedFromAbove) { + await transaction.commit(); + } + + return result; + } catch (err) { + logger.error(err); + if (!isTxPassedFromAbove) { + await transaction.rollback(); + } + throw err; + } +}; diff --git a/src/services/transactions/update-transaction.ts b/src/services/transactions/update-transaction.ts index 84bb8643..b4bd25aa 100644 --- a/src/services/transactions/update-transaction.ts +++ b/src/services/transactions/update-transaction.ts @@ -16,7 +16,7 @@ import { createOppositeTransaction, calcTransferTransactionRefAmount, } from './create-transaction'; -import { linkTransactions } from './link-transaction'; +import { linkTransactions } from './transactions-linking'; import { type UpdateTransactionParams } from './types'; import { removeUndefinedKeys } from '@js/helpers'; import { GenericSequelizeModelAttributes } from '@common/types'; @@ -365,11 +365,13 @@ export const updateTransaction = async ( updatedTransactions = [baseTx, oppositeTx]; } else if (isCreatingTransfer(payload, prevData)) { if (payload.destinationTransactionId) { - const { baseTx, oppositeTx } = await linkTransactions( + const [[baseTx, oppositeTx]] = await linkTransactions( { userId: payload.userId, - baseTx: updatedTransactions[0], - destinationTransactionId: payload.destinationTransactionId, + ids: [ + [updatedTransactions[0].id, payload.destinationTransactionId], + ], + ignoreBaseTxTypeValidation: true, }, { transaction }, ); diff --git a/src/tests/helpers/index.ts b/src/tests/helpers/index.ts index cf305819..5c306b21 100644 --- a/src/tests/helpers/index.ts +++ b/src/tests/helpers/index.ts @@ -17,6 +17,8 @@ import ExchangeRates from '@models/ExchangeRates.model'; import UsersCurrencies from '@models/UsersCurrencies.model'; import monobank from './monobank'; +import * as transactionsService from '@services/transactions'; + export { monobank }; export * from './account'; @@ -303,6 +305,29 @@ export function unlinkTransferTransactions({ }); } +export function linkTransactions({ + payload, + raw, +}: { + payload: endpointsTypes.LinkTransactionsBody; + raw?: false; +}): Promise; +export function linkTransactions({ + payload, + raw, +}: { + payload: endpointsTypes.LinkTransactionsBody; + raw?: true; +}): ReturnType; +export function linkTransactions({ raw = false, payload }) { + return makeRequest({ + method: 'put', + url: '/transactions/link', + payload, + raw, + }); +} + export async function getCurrenciesRates({ codes, }: { codes?: string[] } = {}): Promise {