diff --git a/shared-types/index.ts b/shared-types/index.ts index 55da5c1b..39893e61 100644 --- a/shared-types/index.ts +++ b/shared-types/index.ts @@ -13,6 +13,11 @@ export enum PAYMENT_TYPES { debitCard = 'debitCard', } +export enum SORT_DIRECTIONS { + asc = 'ASC', + desc = 'DESC', +} + export enum TRANSACTION_TYPES { income = 'income', expense = 'expense', diff --git a/shared-types/routes/transactions.ts b/shared-types/routes/transactions.ts index b8d3cac8..7e21a3cd 100644 --- a/shared-types/routes/transactions.ts +++ b/shared-types/routes/transactions.ts @@ -1,7 +1,13 @@ -import { TransactionModel, ACCOUNT_TYPES } from 'shared-types'; +import { + TransactionModel, + ACCOUNT_TYPES, + SORT_DIRECTIONS, + TRANSACTION_TYPES, +} from 'shared-types'; +import { QueryPayload } from './index'; -export interface GetTransactionsQuery { - sort?: 'ASC' | 'DESC'; +export interface GetTransactionsQuery extends QueryPayload { + sort?: SORT_DIRECTIONS; includeUser?: boolean; includeAccount?: boolean; includeCategory?: boolean; @@ -9,8 +15,10 @@ export interface GetTransactionsQuery { nestedInclude?: boolean; limit?: number; from?: number; + type?: TRANSACTION_TYPES; accountType?: ACCOUNT_TYPES; accountId?: number; + excludeTransfer?: boolean; } export type GetTransactionsResponse = TransactionModel[]; @@ -25,12 +33,14 @@ export interface CreateTransactionBody { categoryId?: TransactionModel['categoryId']; destinationAccountId?: TransactionModel['accountId']; destinationAmount?: TransactionModel['amount']; + destinationTransactionId?: number; transferNature?: TransactionModel['transferNature']; } export interface UpdateTransactionBody { amount?: TransactionModel['amount']; destinationAmount?: TransactionModel['amount']; + destinationTransactionId?: TransactionModel['id']; note?: TransactionModel['note']; time?: string; transactionType?: TransactionModel['transactionType']; @@ -40,3 +50,12 @@ export interface UpdateTransactionBody { categoryId?: TransactionModel['categoryId']; transferNature?: TransactionModel['transferNature']; } + +export interface UnlinkTransferTransactionsBody { + transferIds: string[]; +} +// Array of income/expense pairs to link between each other. It's better to pass +// exactly exactly as described in the type, but in fact doesn't really matter +export interface LinkTransactionsBody { + ids: [baseTxId: number, destinationTxId: number][]; +} diff --git a/src/controllers/transactions.controller.ts b/src/controllers/transactions.controller.ts index 10665f14..9c337bf2 100644 --- a/src/controllers/transactions.controller.ts +++ b/src/controllers/transactions.controller.ts @@ -1,50 +1,52 @@ -import { API_RESPONSE_STATUS, endpointsTypes } from 'shared-types'; +import { + API_RESPONSE_STATUS, + SORT_DIRECTIONS, + endpointsTypes, +} from 'shared-types'; import { CustomResponse } from '@common/types'; import { ValidationError } from '@js/errors'; -import * as Transactions from '@models/Transactions.model'; import * as transactionsService from '@services/transactions'; import { errorHandler } from './helpers'; -const SORT_DIRECTIONS = Object.freeze({ - asc: 'ASC', - desc: 'DESC', -}); - 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, - limit, - from = 0, - accountType, - accountId, + // isRaw, + excludeTransfer, }: endpointsTypes.GetTransactionsQuery = req.query; - const transactions = await Transactions.getTransactions({ + const data = await transactionsService.getTransactions({ userId, + transactionType, + sortDirection: sort, + limit, from, accountType, accountId, - limit, - sortDirection: sort, includeUser, includeAccount, includeCategory, includeAll, nestedInclude, - isRaw: true, + excludeTransfer, + isRaw: false, }); return res.status(200).json({ status: API_RESPONSE_STATUS.success, - response: transactions, + response: data, }); } catch (err) { errorHandler(res, err); diff --git a/src/controllers/transactions.controller/create-transaction.e2e.ts b/src/controllers/transactions.controller/create-transaction.e2e.ts index 41701892..049d4e0e 100644 --- a/src/controllers/transactions.controller/create-transaction.e2e.ts +++ b/src/controllers/transactions.controller/create-transaction.e2e.ts @@ -278,4 +278,167 @@ describe('Create transaction controller', () => { expect(tx).toStrictEqual(transactions[i]); }); }); + describe('create transfer via linking', () => { + it('link with system transaction', async () => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + const expectedValues = { + destinationTransaction: { + transactionType: TRANSACTION_TYPES.income, + accountId: accountA.id, + }, + baseTransaction: { + amount: 100, + accountId: accountB.id, + }, + }; + const txPayload = helpers.buildTransactionPayload({ + ...expectedValues.destinationTransaction, + }); + const [destinationTx] = await helpers.createTransaction({ + payload: txPayload, + raw: true, + }); + + const transferTxPayload = helpers.buildTransactionPayload({ + accountId: expectedValues.baseTransaction.accountId, + amount: expectedValues.baseTransaction.amount, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: destinationTx.id, + }); + + const [baseTx, oppositeTx] = await helpers.createTransaction({ + payload: transferTxPayload, + raw: true, + }); + + const transactions = await helpers.getTransactions({ raw: true }); + + expect(transactions.length).toBe(2); + expect(baseTx.transferId).toBe(oppositeTx.transferId); + expect(oppositeTx.amount).toBe(destinationTx.amount); + expect(baseTx.amount).toBe(expectedValues.baseTransaction.amount); + expect(baseTx.transactionType).toBe(TRANSACTION_TYPES.expense); + expect(oppositeTx.transactionType).toBe( + expectedValues.destinationTransaction.transactionType, + ); + }); + it.each([[TRANSACTION_TYPES.expense], [TRANSACTION_TYPES.income]])( + 'link with external %s transaction', + async (txType) => { + await helpers.monobank.pair(); + const { transactions } = await helpers.monobank.mockTransactions(); + const externalTransaction = transactions.find( + (item) => item.transactionType === txType, + ); + const accountA = await helpers.createAccount({ raw: true }); + const expectedValues = { + accountId: accountA.id, + amount: 50, + transactionType: + txType === TRANSACTION_TYPES.expense + ? TRANSACTION_TYPES.income + : TRANSACTION_TYPES.expense, + }; + const transferTxPayload = helpers.buildTransactionPayload({ + ...expectedValues, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: externalTransaction.id, + }); + + const [baseTx, oppositeTx] = await helpers.createTransaction({ + payload: transferTxPayload, + raw: true, + }); + + expect(baseTx.transferId).toBe(oppositeTx.transferId); + expect(oppositeTx.amount).toBe(externalTransaction.amount); + expect(baseTx.amount).toBe(expectedValues.amount); + }, + ); + it('throws an error when trying to link tx with same transactionType', async () => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const transactionType = TRANSACTION_TYPES.income; + + const [destinationTx] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + transactionType, + accountId: accountA.id, + }), + raw: true, + }); + + const transferTxPayload = helpers.buildTransactionPayload({ + accountId: accountB.id, + transactionType, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: destinationTx.id, + }); + + const result = await helpers.createTransaction({ + payload: transferTxPayload, + }); + + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }); + it('throws an error when trying to link tx from the same account', async () => { + const accountA = await helpers.createAccount({ raw: true }); + + const [destinationTx] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + transactionType: TRANSACTION_TYPES.income, + accountId: accountA.id, + }), + raw: true, + }); + + const transferTxPayload = helpers.buildTransactionPayload({ + accountId: accountA.id, + transactionType: TRANSACTION_TYPES.expense, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: destinationTx.id, + }); + + const result = await helpers.createTransaction({ + payload: transferTxPayload, + }); + + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }); + it('throws an error when trying to link to the transaction that is already a transfer', async () => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const defaultTxPayload = helpers.buildTransactionPayload({ + accountId: accountA.id, + }); + const txPayload = { + ...defaultTxPayload, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationAmount: defaultTxPayload.amount, + destinationAccountId: accountB.id, + }; + const [, oppositeTx] = await helpers.createTransaction({ + payload: txPayload, + raw: true, + }); + + const accountC = await helpers.createAccount({ raw: true }); + + const transferTxPayload = helpers.buildTransactionPayload({ + accountId: accountC.id, + transactionType: TRANSACTION_TYPES.expense, + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: oppositeTx.id, + }); + + const result = await helpers.createTransaction({ + payload: transferTxPayload, + }); + + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }); + }); }); diff --git a/src/controllers/transactions.controller/create-transaction.ts b/src/controllers/transactions.controller/create-transaction.ts index f5a71aee..d9047f9b 100644 --- a/src/controllers/transactions.controller/create-transaction.ts +++ b/src/controllers/transactions.controller/create-transaction.ts @@ -16,6 +16,7 @@ export const createTransaction = async (req, res: CustomResponse) => { const { amount, destinationAmount, + destinationTransactionId, note, time, transactionType, @@ -32,6 +33,7 @@ export const createTransaction = async (req, res: CustomResponse) => { const params = { amount, + destinationTransactionId, destinationAmount, note, time: new Date(time), diff --git a/src/controllers/transactions.controller/helpers/index.ts b/src/controllers/transactions.controller/helpers/index.ts index 9f6d85ac..487bc6bf 100644 --- a/src/controllers/transactions.controller/helpers/index.ts +++ b/src/controllers/transactions.controller/helpers/index.ts @@ -20,6 +20,7 @@ export const validateTransactionCreation = ( accountId, destinationAccountId, destinationAmount, + destinationTransactionId, } = params; if (transferNature === TRANSACTION_TRANSFER_NATURE.transfer_out_wallet) { @@ -34,6 +35,8 @@ export const validateTransactionCreation = ( validateTransactionAmount(amount); if (transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer) { + if (destinationTransactionId) return; + if (!(accountId && destinationAccountId)) throw new ValidationError({ message: `Both "accountId" and "destinationAccountId" should be provided when "${TRANSACTION_TRANSFER_NATURE.common_transfer}" is provided`, diff --git a/src/controllers/transactions.controller/index.ts b/src/controllers/transactions.controller/index.ts index ad45748a..2ec7c6cd 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 './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 new file mode 100644 index 00000000..2d04716e --- /dev/null +++ b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts @@ -0,0 +1,134 @@ +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, updatedB] = await helpers.linkTransactions({ + payload: { + ids: [ + [expenseExternalTx.id, incomeSystemTx.id], + [incomeExternalTx.id, 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/transfer-linking/unlink-transfer-transactions.ts b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.ts new file mode 100644 index 00000000..e9877110 --- /dev/null +++ b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.ts @@ -0,0 +1,31 @@ +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) { + errorHandler(res, err); + } +}; diff --git a/src/controllers/transactions.controller/update-transaction.e2e.ts b/src/controllers/transactions.controller/update-transaction.e2e.ts index 525228dd..7995e93d 100644 --- a/src/controllers/transactions.controller/update-transaction.e2e.ts +++ b/src/controllers/transactions.controller/update-transaction.e2e.ts @@ -6,14 +6,12 @@ import { EXTERNAL_ACCOUNT_RESTRICTED_UPDATION_FIELDS } from '@services/transacti describe('Update transaction controller', () => { it('should make basic updation', async () => { - const createdTransaction = ( - await helpers.createTransaction({ raw: true }) - )[0]; - const txAmount = createdTransaction.amount; + const [baseTx] = await helpers.createTransaction({ raw: true }); + const txAmount = baseTx.amount; const expectedNewAmount = txAmount + 1000; const res = await helpers.updateTransaction({ - id: createdTransaction.id, + id: baseTx.id, payload: { amount: expectedNewAmount, transactionType: TRANSACTION_TYPES.income, @@ -91,77 +89,23 @@ describe('Update transaction controller', () => { currencyCode: currencyEUR.code, }); }); - describe('should change expense to transfer and vice versa', () => { - let createdTransactions = []; - let accountA = null; - let accountB = null; - - beforeEach(async () => { - // Create account with a new currency - const OLD_DESTINATION_CURRENCY = 'UAH'; - const currencyA = global.MODELS_CURRENCIES.find( - (item) => item.code === OLD_DESTINATION_CURRENCY, - ); - await helpers.addUserCurrencies({ currencyCodes: [currencyA.code] }); - accountA = await helpers.createAccount({ - payload: { - ...helpers.buildAccountPayload(), - currencyId: currencyA.id, - }, - raw: true, - }); - - const New_DESTINATION_CURRENCY = 'EUR'; - const currencyB = global.MODELS_CURRENCIES.find( - (item) => item.code === New_DESTINATION_CURRENCY, - ); - await helpers.addUserCurrencies({ currencyCodes: [currencyA.code] }); - accountB = await helpers.createAccount({ - payload: { - ...helpers.buildAccountPayload(), - currencyId: currencyB.id, - }, - raw: true, - }); - - const txPayload = { - ...helpers.buildTransactionPayload({ accountId: accountA.id }), - transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - destinationAmount: 30, - destinationAccountId: accountB.id, - }; - createdTransactions = await helpers.createTransaction({ - payload: txPayload, - raw: true, - }); - }); - - it('works with source transaction (`from`)', async () => { - const sourceTransaction = createdTransactions[0]; - - await helpers.updateTransaction({ - id: sourceTransaction.id, - payload: { - transactionType: TRANSACTION_TYPES.income, - }, + it.each([[TRANSACTION_TYPES.income], [TRANSACTION_TYPES.expense]])( + 'should change %s to transfer and vice versa', + async (txType) => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const [tx] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + transactionType: txType, + }), raw: true, }); - const txsAfterUpdation = await helpers.getTransactions({ raw: true }); - - expect(txsAfterUpdation.length).toBe(1); - expect(txsAfterUpdation[0].transactionType).toBe( - TRANSACTION_TYPES.income, - ); - expect(txsAfterUpdation[0].transferId).toBe(null); - expect(txsAfterUpdation[0].transferNature).toBe( - TRANSACTION_TRANSFER_NATURE.not_transfer, - ); - await helpers.updateTransaction({ - id: sourceTransaction.id, + id: tx.id, payload: { - ...helpers.buildTransactionPayload({ accountId: accountA.id }), transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, destinationAmount: 30, destinationAccountId: accountB.id, @@ -169,35 +113,20 @@ describe('Update transaction controller', () => { raw: true, }); - const txsAfterUpdation2 = await helpers.getTransactions({ raw: true }); - - expect(txsAfterUpdation2[0].id).toBe(sourceTransaction.id); - // Check that after making tx transfer type, source changes from `income` to `expense` - expect(txsAfterUpdation2[0].transactionType).toBe( - TRANSACTION_TYPES.expense, - ); - expect(txsAfterUpdation2[0].transferId).not.toBe(null); - expect(txsAfterUpdation2[0].transferNature).toBe( - TRANSACTION_TRANSFER_NATURE.common_transfer, - ); - }); - it('disallowd to change non-source transaction', async () => { - const destinationTransaction = createdTransactions[1]; - - const response = await helpers.updateTransaction({ - id: destinationTransaction.id, + await helpers.updateTransaction({ + id: tx.id, payload: { - transactionType: TRANSACTION_TYPES.expense, + transferNature: TRANSACTION_TRANSFER_NATURE.not_transfer, }, + raw: true, }); - const txsAfterUpdation = await helpers.getTransactions({ raw: true }); + const transactions = await helpers.getTransactions({ raw: true }); - expect(response.statusCode).toBe(ERROR_CODES.ValidationError); - // Check that after updation try nothing changed - expect(createdTransactions).toStrictEqual(txsAfterUpdation); - }); - }); + expect(transactions.length).toBe(1); + expect(transactions[0]).toEqual(tx); + }, + ); describe('test refAmount is correct when changing transfer transaction accounts to ref account', () => { it('EUR->UAH to EUR->USD, refAmount should be same as amount of USD. Because USD is a ref-currency', async () => { const { account: accountEUR } = @@ -408,4 +337,226 @@ describe('Update transaction controller', () => { } }); }); + describe('link transactions between each other', () => { + it.each([[TRANSACTION_TYPES.expense], [TRANSACTION_TYPES.income]])( + 'update %s to transfer when linking', + async (txType) => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const oppositeTxType = + txType === TRANSACTION_TYPES.income + ? TRANSACTION_TYPES.expense + : TRANSACTION_TYPES.income; + + 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: oppositeTxType, + }), + raw: true, + }); + + await helpers.updateTransaction({ + id: tx1.id, + payload: { + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: tx2.id, + }, + }); + + const txsAfterUpdation = await helpers.getTransactions({ raw: true }); + + const tx1AfterUpdation = txsAfterUpdation.find( + (item) => item.id === tx1.id, + ); + const tx2AfterUpdation = txsAfterUpdation.find( + (item) => item.id === tx2.id, + ); + + [ + [tx1, tx1AfterUpdation], + [tx2, tx2AfterUpdation], + ].forEach(([tx, txAfter]) => { + // 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(tx1AfterUpdation.transferId).toBe(tx2AfterUpdation.transferId); + }, + ); + + it.each([[TRANSACTION_TYPES.expense], [TRANSACTION_TYPES.income]])( + 'throws an error when trying to link tx from the same account', + async (txType) => { + const oppositeTxType = + txType === TRANSACTION_TYPES.income + ? TRANSACTION_TYPES.expense + : TRANSACTION_TYPES.income; + + await helpers.monobank.pair(); + const { transactions } = await helpers.monobank.mockTransactions(); + + const tx1 = transactions.find( + (item) => item.transactionType === txType, + ); + const tx2 = transactions.find( + (item) => item.transactionType === oppositeTxType, + ); + + const result = await helpers.updateTransaction({ + id: tx1.id, + payload: { + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: 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.updateTransaction({ + id: tx1.id, + payload: { + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: 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.updateTransaction({ + id: tx1.id, + payload: { + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: + txType === TRANSACTION_TYPES.income ? expenseTx.id : incomeTx.id, + }, + }); + + expect(result.statusCode).toBe(ERROR_CODES.ValidationError); + }, + ); + + it.each([[TRANSACTION_TYPES.expense], [TRANSACTION_TYPES.income]])( + 'update transfer of two linked transactions back to their initial state will just remove the opposite tx. testing %s', + async (txType) => { + const accountA = await helpers.createAccount({ raw: true }); + const accountB = await helpers.createAccount({ raw: true }); + + const oppositeTxType = + txType === TRANSACTION_TYPES.income + ? TRANSACTION_TYPES.expense + : TRANSACTION_TYPES.income; + + 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: oppositeTxType, + }), + raw: true, + }); + + await helpers.updateTransaction({ + id: tx1.id, + payload: { + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationTransactionId: tx2.id, + }, + }); + + await helpers.updateTransaction({ + id: tx1.id, + payload: { + transferNature: TRANSACTION_TRANSFER_NATURE.not_transfer, + }, + }); + + const txsAfterUpdation = await helpers.getTransactions({ raw: true }); + + expect(txsAfterUpdation.length).toBe(1); + expect(txsAfterUpdation[0]).toEqual(tx1); + }, + ); + }); }); diff --git a/src/controllers/transactions.controller/update-transaction.ts b/src/controllers/transactions.controller/update-transaction.ts index 4c9f085d..85bf292d 100644 --- a/src/controllers/transactions.controller/update-transaction.ts +++ b/src/controllers/transactions.controller/update-transaction.ts @@ -17,6 +17,7 @@ export const updateTransaction = async (req, res: CustomResponse) => { paymentType, accountId, destinationAccountId, + destinationTransactionId, categoryId, transferNature, }: endpointsTypes.UpdateTransactionBody = req.body; @@ -29,6 +30,7 @@ export const updateTransaction = async (req, res: CustomResponse) => { ...removeUndefinedKeys({ amount, destinationAmount, + destinationTransactionId, note, time: new Date(time), userId, diff --git a/src/models/Transactions.model.ts b/src/models/Transactions.model.ts index 40f780ad..8c1a5834 100644 --- a/src/models/Transactions.model.ts +++ b/src/models/Transactions.model.ts @@ -3,6 +3,8 @@ import { PAYMENT_TYPES, TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE, + SORT_DIRECTIONS, + TransactionModel, } from 'shared-types'; import { Op } from 'sequelize'; import { Transaction } from 'sequelize/types'; @@ -362,20 +364,40 @@ export default class Transactions extends Model { } } -export const getTransactions = async ({ - from = 0, - limit = 20, - accountType, - accountId, - userId, - sortDirection = 'DESC', - includeUser, - includeAccount, - includeCategory, - includeAll, - nestedInclude, - isRaw = false, -}) => { +export const getTransactions = async ( + { + from = 0, + limit = 20, + accountType, + accountId, + userId, + sortDirection = SORT_DIRECTIONS.desc, + includeUser, + includeAccount, + transactionType, + includeCategory, + includeAll, + nestedInclude, + isRaw = false, + excludeTransfer, + }: { + from: number; + limit: number; + accountType: ACCOUNT_TYPES; + transactionType: string; + accountId: number; + userId: number; + sortDirection: SORT_DIRECTIONS; + includeUser: boolean; + includeAccount: boolean; + includeCategory: boolean; + includeAll: boolean; + nestedInclude: boolean; + isRaw: boolean; + excludeTransfer?: boolean; + }, + { transaction }: { transaction?: Transaction } = {}, +) => { const include = prepareTXInclude({ includeUser, includeAccount, @@ -388,11 +410,19 @@ export const getTransactions = async ({ include, where: { userId, - ...removeUndefinedKeys({ accountType, accountId }), + ...removeUndefinedKeys({ + accountType, + accountId, + transactionType, + transferNature: excludeTransfer + ? TRANSACTION_TRANSFER_NATURE.not_transfer + : undefined, + }), }, + transaction, offset: from, limit: limit, - order: [['time', sortDirection.toUpperCase()]], + order: [['time', sortDirection]], raw: isRaw, }); @@ -488,7 +518,9 @@ export const getTransactionsByTransferId = ( }); }; -export const getTransactionsByArrayOfField = async ( +export const getTransactionsByArrayOfField = async < + T extends keyof TransactionModel, +>( { fieldValues, fieldName, @@ -499,8 +531,8 @@ export const getTransactionsByArrayOfField = async ( includeAll, nestedInclude, }: { - fieldValues: unknown[]; - fieldName: string; + fieldValues: TransactionModel[T][]; + fieldName: T; userId: number; includeUser?: boolean; includeAccount?: boolean; @@ -646,7 +678,7 @@ export const deleteTransactionById = async ( if (tx.accountType !== ACCOUNT_TYPES.system) { throw new ValidationError({ - message: "It's not possible to manually delete external transactions", + message: "It's not allowed to manually delete external transactions", }); } diff --git a/src/models/transactions.ts b/src/models/transactions.ts new file mode 100644 index 00000000..c99969d5 --- /dev/null +++ b/src/models/transactions.ts @@ -0,0 +1,22 @@ +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; +} diff --git a/src/routes/transactions.route.ts b/src/routes/transactions.route.ts index a1923f74..abc4e64b 100644 --- a/src/routes/transactions.route.ts +++ b/src/routes/transactions.route.ts @@ -5,6 +5,8 @@ import { getTransactionsByTransferId, createTransaction, updateTransaction, + unlinkTransferTransactions, + linkTransactions, deleteTransaction, } from '@controllers/transactions.controller'; import { authenticateJwt } from '@middlewares/passport'; @@ -19,6 +21,8 @@ router.get( getTransactionsByTransferId, ); 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 11bd0b49..ede85572 100644 --- a/src/services/transactions/create-transaction.ts +++ b/src/services/transactions/create-transaction.ts @@ -16,6 +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 './transactions-linking'; import type { CreateTransactionParams, UpdateTransactionParams } from './types'; type CreateOppositeTransactionParams = [ @@ -195,6 +196,7 @@ export const createTransaction = async ( userId, accountId, transferNature, + destinationTransactionId, ...payload }: CreateTransactionParams, attributes: GenericSequelizeModelAttributes = {}, @@ -259,23 +261,42 @@ export const createTransaction = async ( ] = [baseTransaction]; /** - * If transactions is transfer between two accounts, add transferId to both + * If transaction is transfer between two accounts, add transferId to both * transactions to connect them, and use destinationAmount and destinationAccountId * for the second transaction. */ if (transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer) { - const res = await createOppositeTransaction([ - { - amount, - userId, - accountId, - transferNature, - ...payload, - }, - baseTransaction, - transaction, - ]); - transactions = [res.baseTx, res.oppositeTx]; + /** + * When "destinationTransactionId" is provided, we don't need to create an + * opposite transaction, since it's expected to use the existing one. + * We need to update the existing one, or fail the whole creation if it + * doesn't exist + */ + if (destinationTransactionId) { + const [[baseTx, oppositeTx]] = await linkTransactions( + { + userId, + ids: [[baseTransaction.id, destinationTransactionId]], + ignoreBaseTxTypeValidation: true, + }, + { transaction }, + ); + + transactions = [baseTx, oppositeTx]; + } else { + const res = await createOppositeTransaction([ + { + amount, + userId, + accountId, + transferNature, + ...payload, + }, + baseTransaction, + transaction, + ]); + transactions = [res.baseTx, res.oppositeTx]; + } } if (!isTxPassedFromAbove) { diff --git a/src/services/transactions/delete-transaction.ts b/src/services/transactions/delete-transaction.ts index 098a8e01..2409f6a0 100644 --- a/src/services/transactions/delete-transaction.ts +++ b/src/services/transactions/delete-transaction.ts @@ -24,7 +24,7 @@ export const deleteTransaction = async ({ if (accountType !== ACCOUNT_TYPES.system) { throw new ValidationError({ - message: "It's not possible to manually delete external transactions", + message: "It's not allowed to manually delete external transactions", }); } diff --git a/src/services/transactions/get-transactions.ts b/src/services/transactions/get-transactions.ts new file mode 100644 index 00000000..02ad9c43 --- /dev/null +++ b/src/services/transactions/get-transactions.ts @@ -0,0 +1,19 @@ +import * as Transactions from '@models/Transactions.model'; +import { GenericSequelizeModelAttributes } from '@common/types'; +import type { GetTransactionsParams } from '@models/transactions'; + +export const getTransactions = async ( + params: GetTransactionsParams, + attributes: GenericSequelizeModelAttributes = {}, +) => { + // eslint-disable-next-line no-useless-catch + try { + const data = await Transactions.getTransactions(params, { + transaction: attributes.transaction, + }); + + return data; + } catch (err) { + throw err; + } +}; diff --git a/src/services/transactions/index.ts b/src/services/transactions/index.ts index 3503db84..7cbd74f1 100644 --- a/src/services/transactions/index.ts +++ b/src/services/transactions/index.ts @@ -1,6 +1,8 @@ export * from './create-transaction'; export * from './delete-transaction'; export * from './get-by-id'; +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'; 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/transactions-linking/unlink-transfer-transactions.ts b/src/services/transactions/transactions-linking/unlink-transfer-transactions.ts new file mode 100644 index 00000000..e86787f6 --- /dev/null +++ b/src/services/transactions/transactions-linking/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/services/transactions/types.ts b/src/services/transactions/types.ts index cde31ea5..fb066f04 100644 --- a/src/services/transactions/types.ts +++ b/src/services/transactions/types.ts @@ -11,6 +11,7 @@ export type CreateTransactionParams = Omit< > & { destinationAmount?: number; destinationAccountId?: number; + destinationTransactionId?: number; }; interface UpdateParams { @@ -28,8 +29,10 @@ interface UpdateParams { interface UpdateTransferParams { destinationAmount?: number; + destinationTransactionId?: number; destinationAccountId?: number; transferNature?: TRANSACTION_TRANSFER_NATURE; + transferId?: string; } export type UpdateTransactionParams = UpdateParams & UpdateTransferParams; diff --git a/src/services/transactions/update-transaction.ts b/src/services/transactions/update-transaction.ts index 41d91e6b..b4bd25aa 100644 --- a/src/services/transactions/update-transaction.ts +++ b/src/services/transactions/update-transaction.ts @@ -16,8 +16,10 @@ import { createOppositeTransaction, calcTransferTransactionRefAmount, } from './create-transaction'; +import { linkTransactions } from './transactions-linking'; import { type UpdateTransactionParams } from './types'; import { removeUndefinedKeys } from '@js/helpers'; +import { GenericSequelizeModelAttributes } from '@common/types'; export const EXTERNAL_ACCOUNT_RESTRICTED_UPDATION_FIELDS = [ 'amount', @@ -67,15 +69,15 @@ const validateTransaction = ( // 2. To keep `refAmount` calculation correct abd be tied exactly to source tx. // Otherwise we will need to code additional logic to handle that // For now keep that logic only for system transactions - if ( - prevData.accountType === ACCOUNT_TYPES.system && - prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer && - prevData.transactionType !== TRANSACTION_TYPES.expense - ) { - throw new ValidationError({ - message: 'You cannot edit non-primary transfer transaction', - }); - } + // if ( + // prevData.accountType === ACCOUNT_TYPES.system && + // prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer && + // prevData.transactionType !== TRANSACTION_TYPES.expense + // ) { + // throw new ValidationError({ + // message: 'You cannot edit non-primary transfer transaction', + // }); + // } }; const makeBasicBaseTxUpdation = async ( @@ -88,12 +90,10 @@ const makeBasicBaseTxUpdation = async ( { transaction }, ); + // Never update "transactionType" of non-system transactions. Just an additional guard const transactionType = prevData.accountType === ACCOUNT_TYPES.system - ? // For system - newData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer - ? TRANSACTION_TYPES.expense - : newData.transactionType + ? newData.transactionType : prevData.transactionType; const baseTransactionUpdateParams: Transactions.UpdateTransactionByIdParams = @@ -271,11 +271,55 @@ const deleteOppositeTransaction = async (params: HelperFunctionsArgs) => { ); }; +const isUpdatingTransferTx = ( + payload: UpdateTransactionParams, + prevData: Transactions.default, +) => { + // Previously was transfer, now NOT a transfer + const nowNotTransfer = + payload.transferNature === undefined && + prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer; + + // Previously was transfer, now also transfer + const updatingTransfer = + payload.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer && + prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer; + + return nowNotTransfer || updatingTransfer; +}; + +const isCreatingTransfer = ( + payload: UpdateTransactionParams, + prevData: Transactions.default, +) => { + return ( + payload.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer && + prevData.transferNature === TRANSACTION_TRANSFER_NATURE.not_transfer + ); +}; + +const isDiscardingTransfer = ( + payload: UpdateTransactionParams, + prevData: Transactions.default, +) => { + return ( + payload.transferNature !== TRANSACTION_TRANSFER_NATURE.common_transfer && + prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer + ); +}; + /** * Updates transaction and updates account balance. */ -export const updateTransaction = async (payload: UpdateTransactionParams) => { - const transaction: Transaction = await connection.sequelize.transaction(); +export const updateTransaction = async ( + payload: UpdateTransactionParams, + 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 prevData = await getTransactionById( @@ -305,13 +349,7 @@ export const updateTransaction = async (payload: UpdateTransactionParams) => { transaction, ]; - if ( - (payload.transferNature === undefined && - prevData.transferNature === - TRANSACTION_TRANSFER_NATURE.common_transfer) || - (payload.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer && - prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer) - ) { + if (isUpdatingTransferTx(payload, prevData)) { // Handle the case when initially tx was "expense", became "transfer", // but now user wants to unmark it from transfer and make "income" if ( @@ -323,35 +361,50 @@ export const updateTransaction = async (payload: UpdateTransactionParams) => { const { baseTx, oppositeTx } = await updateTransferTransaction(helperFunctionsArgs); + updatedTransactions = [baseTx, oppositeTx]; - } else if ( - payload.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer && - prevData.transferNature === TRANSACTION_TRANSFER_NATURE.not_transfer - ) { - const { baseTx, oppositeTx } = await createOppositeTransaction([ - // When updating existing tx we usually don't pass transactionType, so - // it will be `undefined`, that's why we derive it from prevData - { - ...payload, - transactionType: payload.transactionType ?? prevData.transactionType, - }, - baseTransaction, - transaction, - ]); - updatedTransactions = [baseTx, oppositeTx]; - } else if ( - payload.transferNature !== TRANSACTION_TRANSFER_NATURE.common_transfer && - prevData.transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer - ) { + } else if (isCreatingTransfer(payload, prevData)) { + if (payload.destinationTransactionId) { + const [[baseTx, oppositeTx]] = await linkTransactions( + { + userId: payload.userId, + ids: [ + [updatedTransactions[0].id, payload.destinationTransactionId], + ], + ignoreBaseTxTypeValidation: true, + }, + { transaction }, + ); + + updatedTransactions = [baseTx, oppositeTx]; + } else { + const { baseTx, oppositeTx } = await createOppositeTransaction([ + // When updating existing tx we usually don't pass transactionType, so + // it will be `undefined`, that's why we derive it from prevData + { + ...payload, + transactionType: + payload.transactionType ?? prevData.transactionType, + }, + baseTransaction, + transaction, + ]); + updatedTransactions = [baseTx, oppositeTx]; + } + } else if (isDiscardingTransfer(payload, prevData)) { await deleteOppositeTransaction(helperFunctionsArgs); } - await transaction.commit(); + if (!isTxPassedFromAbove) { + await transaction.commit(); + } return updatedTransactions; } catch (e) { logger.error(e); - await transaction.rollback(); + if (!isTxPassedFromAbove) { + await transaction.rollback(); + } throw e; } }; diff --git a/src/tests/custom-matchers.d.ts b/src/tests/custom-matchers.d.ts new file mode 100644 index 00000000..27ce8593 --- /dev/null +++ b/src/tests/custom-matchers.d.ts @@ -0,0 +1,9 @@ +declare namespace jest { + interface Matchers { + toBeAnythingOrNull(): R; + } + + interface Expect { + toBeAnythingOrNull(): R; + } +} diff --git a/src/tests/helpers/index.ts b/src/tests/helpers/index.ts index 6b713002..5c306b21 100644 --- a/src/tests/helpers/index.ts +++ b/src/tests/helpers/index.ts @@ -7,6 +7,8 @@ import { TRANSACTION_TYPES, endpointsTypes, TRANSACTION_TRANSFER_NATURE, + TransactionModel, + PAYMENT_TYPES, } from 'shared-types'; import { app } from '@root/app'; import Accounts from '@models/Accounts.model'; @@ -15,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'; @@ -85,12 +89,14 @@ type BuildAccountPayload = ReturnType; export const buildTransactionPayload = ({ accountId, ...overrides -}: { accountId: number } & ReturnType) => ({ +}: { accountId: number } & ReturnType< + typeof buildTransactionPayload +>): TransactionModel => ({ accountId, amount: 1000, categoryId: 1, transferNature: TRANSACTION_TRANSFER_NATURE.not_transfer, - paymentType: 'creditCard', + paymentType: PAYMENT_TYPES.creditCard, time: startOfDay(new Date()), transactionType: TRANSACTION_TYPES.expense, accountType: ACCOUNT_TYPES.system, @@ -127,6 +133,9 @@ export function getAccounts(): Promise { }); } +/** + * Creates an account. By default for base currency, but any payload can be passed + */ export function createAccount(): Promise; export function createAccount({ payload, @@ -219,6 +228,7 @@ interface UpdateTransactionBasePayload { payload?: Partial> & { destinationAmount?: number; destinationAccountId?: number; + destinationTransactionId?: number; }; } @@ -267,6 +277,57 @@ 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 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 { diff --git a/src/tests/setupIntegrationTests.ts b/src/tests/setupIntegrationTests.ts index 8dbfaa93..a646e33c 100644 --- a/src/tests/setupIntegrationTests.ts +++ b/src/tests/setupIntegrationTests.ts @@ -43,6 +43,21 @@ async function dropAllEnums(sequelize) { } } +expect.extend({ + toBeAnythingOrNull(received) { + if (received !== undefined) { + return { + message: () => `expected ${received} to be anything or null`, + pass: true, + }; + } + return { + message: () => `expected ${received} not to be undefined`, + pass: false, + }; + }, +}); + beforeEach(async () => { try { await connection.sequelize.drop({ cascade: true }); diff --git a/tsconfig.json b/tsconfig.json index 78f05078..8d2ec81e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,5 +28,5 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": ["src/**/*"] + "include": ["src/**/*", "src/tests/custom-matchers.d.ts"] }