diff --git a/shared-types/routes/transactions.ts b/shared-types/routes/transactions.ts index 476f56d2..7a0fb4f2 100644 --- a/shared-types/routes/transactions.ts +++ b/shared-types/routes/transactions.ts @@ -1,4 +1,9 @@ -import { TransactionModel, ACCOUNT_TYPES, SORT_DIRECTIONS, TRANSACTION_TYPES } from 'shared-types'; +import { + TransactionModel, + ACCOUNT_TYPES, + SORT_DIRECTIONS, + TRANSACTION_TYPES, +} from 'shared-types'; import { QueryPayload } from './index'; export interface GetTransactionsQuery extends QueryPayload { @@ -34,7 +39,7 @@ export interface CreateTransactionBody { export interface UpdateTransactionBody { amount?: TransactionModel['amount']; destinationAmount?: TransactionModel['amount']; - destinationTransactionId?: TransactionModel['id'] + destinationTransactionId?: TransactionModel['id']; note?: TransactionModel['note']; time?: string; transactionType?: TransactionModel['transactionType']; @@ -44,3 +49,7 @@ export interface UpdateTransactionBody { categoryId?: TransactionModel['categoryId']; transferNature?: TransactionModel['transferNature']; } + +export interface UnlinkTransferTransactionsBody { + transferIds: string[]; +} diff --git a/src/controllers/transactions.controller/index.ts b/src/controllers/transactions.controller/index.ts index ad45748a..e3e163a8 100644 --- a/src/controllers/transactions.controller/index.ts +++ b/src/controllers/transactions.controller/index.ts @@ -2,3 +2,4 @@ export * from './create-transaction'; export * from './delete-transaction'; export * from './update-transaction'; +export * from './unlink-transfer-transactions'; diff --git a/src/controllers/transactions.controller/unlink-transfer-transactions.e2e.ts b/src/controllers/transactions.controller/unlink-transfer-transactions.e2e.ts new file mode 100644 index 00000000..696c36b2 --- /dev/null +++ b/src/controllers/transactions.controller/unlink-transfer-transactions.e2e.ts @@ -0,0 +1,141 @@ +import { TRANSACTION_TRANSFER_NATURE, TRANSACTION_TYPES } from 'shared-types'; +import * as helpers from '@tests/helpers'; +import { faker } from '@faker-js/faker'; + +describe('Unlink transfer transactions', () => { + it('unlink system transactions', async () => { + // Firstly create two transfer transactions + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ accountId: accountA.id }), + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationAmount: faker.number.int({ max: 1000 }) * 1000, + destinationAccountId: accountB.id, + }, + raw: true, + }); + + await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ accountId: accountA.id }), + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationAmount: faker.number.int({ max: 1000 }) * 1000, + destinationAccountId: accountB.id, + }, + raw: true, + }); + + // Now unlink them + const transactions = await helpers.getTransactions({ raw: true }); + const transferIds = transactions.map((item) => item.transferId); + + const updatedTransactions = await helpers.unlinkTransferTransactions({ + transferIds, + raw: true, + }); + + // Test that now they're unlinked and not transfer anymore + updatedTransactions.forEach((tx) => { + const oppositeTx = transactions.find((item) => item.id === tx.id); + + expect(tx).toEqual({ + ...oppositeTx, + transferId: null, + transferNature: TRANSACTION_TRANSFER_NATURE.not_transfer, + }); + }); + }); + it('unlink external transactions', async () => { + // Firstly create external expense + income + await helpers.monobank.pair(); + const { transactions } = await helpers.monobank.mockTransactions(); + const expenseExternalTx = transactions.find( + (item) => item.transactionType === TRANSACTION_TYPES.expense, + ); + const incomeExternalTx = transactions.find( + (item) => item.transactionType === TRANSACTION_TYPES.income, + ); + + // Now create system expense + income + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const [expenseSystemTx] = await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ accountId: accountA.id }), + transactionType: TRANSACTION_TYPES.expense, + }, + raw: true, + }); + + const [incomeSystemTx] = await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ accountId: accountB.id }), + transactionType: TRANSACTION_TYPES.income, + }, + raw: true, + }); + + // Now link 1 external with 1 system for each type + const updatedA = await helpers.updateTransaction({ + id: expenseExternalTx.id, + 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, + }, + raw: true, + }); + + // Test that after updation only transfer-related fields were changed for each + // transaction + expect(expenseExternalTx).toEqual({ + ...updatedA[0], + transferNature: expect.toBeAnythingOrNull(), + transferId: expect.toBeAnythingOrNull(), + }); + expect(incomeSystemTx).toEqual({ + ...updatedA[1], + transferNature: expect.toBeAnythingOrNull(), + transferId: expect.toBeAnythingOrNull(), + }); + expect(incomeExternalTx).toEqual({ + ...updatedB[0], + transferNature: expect.toBeAnythingOrNull(), + transferId: expect.toBeAnythingOrNull(), + }); + expect(expenseSystemTx).toEqual({ + ...updatedB[1], + transferNature: expect.toBeAnythingOrNull(), + transferId: expect.toBeAnythingOrNull(), + }); + + // Now unlink all of them + const transferIds = [...updatedA, ...updatedB].map((t) => t.transferId); + + const result = await helpers.unlinkTransferTransactions({ + transferIds, + raw: true, + }); + + // After unlinking check that transactions now are COMPLETELY SAME + [ + expenseExternalTx, + incomeExternalTx, + expenseSystemTx, + incomeSystemTx, + ].forEach((tx) => { + expect(result.find((t) => t.id === tx.id)).toEqual(tx); + }); + }); +}); diff --git a/src/controllers/transactions.controller/unlink-transfer-transactions.ts b/src/controllers/transactions.controller/unlink-transfer-transactions.ts new file mode 100644 index 00000000..c2ef399b --- /dev/null +++ b/src/controllers/transactions.controller/unlink-transfer-transactions.ts @@ -0,0 +1,32 @@ +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 unlinkTransferTransactions = async (req, res: CustomResponse) => { + try { + const { transferIds }: endpointsTypes.UnlinkTransferTransactionsBody = + req.body; + const { id: userId } = req.user; + + if (!transferIds || !Array.isArray(transferIds)) { + throw new ValidationError({ + message: + '"transferIds" field is required and should be an array if transferIds.', + }); + } + + const data = await transactionsService.unlinkTransferTransactions({ + userId, + transferIds: [...new Set(transferIds)], + }); + + return res + .status(200) + .json({ status: API_RESPONSE_STATUS.success, response: data }); + } catch (err) { + console.log('ERR', err); + errorHandler(res, err); + } +}; diff --git a/src/controllers/transactions.controller/update-transaction.e2e.ts b/src/controllers/transactions.controller/update-transaction.e2e.ts index a1a782ca..7995e93d 100644 --- a/src/controllers/transactions.controller/update-transaction.e2e.ts +++ b/src/controllers/transactions.controller/update-transaction.e2e.ts @@ -558,9 +558,5 @@ describe('Update transaction controller', () => { expect(txsAfterUpdation[0]).toEqual(tx1); }, ); - it.todo('test unlinking system transactions'); - it.todo( - 'update transfer of two EXTERNAL linked transactions back to their initial state', - ); }); }); diff --git a/src/routes/transactions.route.ts b/src/routes/transactions.route.ts index a1923f74..978a7396 100644 --- a/src/routes/transactions.route.ts +++ b/src/routes/transactions.route.ts @@ -5,6 +5,7 @@ import { getTransactionsByTransferId, createTransaction, updateTransaction, + unlinkTransferTransactions, deleteTransaction, } from '@controllers/transactions.controller'; import { authenticateJwt } from '@middlewares/passport'; @@ -19,6 +20,7 @@ router.get( getTransactionsByTransferId, ); router.post('/', authenticateJwt, createTransaction); +router.put('/unlink', authenticateJwt, unlinkTransferTransactions); router.put('/:id', authenticateJwt, updateTransaction); router.delete('/:id', authenticateJwt, deleteTransaction); diff --git a/src/services/transactions/index.ts b/src/services/transactions/index.ts index ceb3ceb3..f548db12 100644 --- a/src/services/transactions/index.ts +++ b/src/services/transactions/index.ts @@ -5,3 +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 './unlink-transfer-transactions'; diff --git a/src/services/transactions/unlink-transfer-transactions.ts b/src/services/transactions/unlink-transfer-transactions.ts new file mode 100644 index 00000000..e86787f6 --- /dev/null +++ b/src/services/transactions/unlink-transfer-transactions.ts @@ -0,0 +1,62 @@ +import { TRANSACTION_TRANSFER_NATURE } from 'shared-types'; +import { Op, Transaction } from 'sequelize'; +import { connection } from '@models/index'; +import { GenericSequelizeModelAttributes } from '@common/types'; +import { logger } from '@js/utils/logger'; +import * as Transactions from '@models/Transactions.model'; + +export const unlinkTransferTransactions = async ( + payload: { userId: number; transferIds: string[] }, + attributes: GenericSequelizeModelAttributes = {}, +): Promise => { + const isTxPassedFromAbove = attributes.transaction !== undefined; + const transaction: Transaction = + attributes.transaction ?? (await connection.sequelize.transaction()); + + try { + const transactions = await Transactions.getTransactionsByArrayOfField( + { + userId: payload.userId, + fieldName: 'transferId', + fieldValues: payload.transferIds, + }, + { transaction }, + ); + + const txIds = transactions.map((t) => t.id); + await Transactions.default.update( + { + transferId: null, + transferNature: TRANSACTION_TRANSFER_NATURE.not_transfer, + }, + { + where: { + userId: payload.userId, + id: { [Op.in]: txIds }, + }, + transaction, + }, + ); + + const updatedTxs = await Transactions.getTransactionsByArrayOfField( + { + userId: payload.userId, + fieldName: 'id', + fieldValues: txIds, + }, + { transaction }, + ); + + if (!isTxPassedFromAbove) { + await transaction.commit(); + } + + return updatedTxs; + } catch (err) { + logger.error(err); + if (!isTxPassedFromAbove) { + await transaction.rollback(); + } + throw err; + } +}; diff --git a/src/tests/helpers/index.ts b/src/tests/helpers/index.ts index 3b77ccaf..cf305819 100644 --- a/src/tests/helpers/index.ts +++ b/src/tests/helpers/index.ts @@ -275,6 +275,34 @@ export function getTransactions({ raw = false } = {}) { }); } +export function unlinkTransferTransactions({ + transferIds, + raw, +}: { + transferIds: string[]; + raw?: false; +}): Promise; +export function unlinkTransferTransactions({ + raw, + transferIds, +}: { + transferIds: string[]; + raw?: true; +}): Promise; +export function unlinkTransferTransactions({ + raw = false, + transferIds = [], +} = {}) { + return makeRequest({ + method: 'put', + url: '/transactions/unlink', + payload: { + transferIds, + }, + raw, + }); +} + export async function getCurrenciesRates({ codes, }: { codes?: string[] } = {}): Promise {