Skip to content

Commit

Permalink
feat: Implement transactions unlinking
Browse files Browse the repository at this point in the history
  • Loading branch information
letehaha committed Jan 19, 2024
1 parent 17d5868 commit 61359e6
Show file tree
Hide file tree
Showing 9 changed files with 278 additions and 6 deletions.
13 changes: 11 additions & 2 deletions shared-types/routes/transactions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'];
Expand All @@ -44,3 +49,7 @@ export interface UpdateTransactionBody {
categoryId?: TransactionModel['categoryId'];
transferNature?: TransactionModel['transferNature'];
}

export interface UnlinkTransferTransactionsBody {
transferIds: string[];
}
1 change: 1 addition & 0 deletions src/controllers/transactions.controller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './create-transaction';
export * from './delete-transaction';
export * from './update-transaction';
export * from './unlink-transfer-transactions';
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
2 changes: 2 additions & 0 deletions src/routes/transactions.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getTransactionsByTransferId,
createTransaction,
updateTransaction,
unlinkTransferTransactions,
deleteTransaction,
} from '@controllers/transactions.controller';
import { authenticateJwt } from '@middlewares/passport';
Expand All @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/services/transactions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
62 changes: 62 additions & 0 deletions src/services/transactions/unlink-transfer-transactions.ts
Original file line number Diff line number Diff line change
@@ -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<Transactions.default[]> => {
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;
}
};
28 changes: 28 additions & 0 deletions src/tests/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,34 @@ export function getTransactions({ raw = false } = {}) {
});
}

export function unlinkTransferTransactions({
transferIds,
raw,
}: {
transferIds: string[];
raw?: false;
}): Promise<Response>;
export function unlinkTransferTransactions({
raw,
transferIds,
}: {
transferIds: string[];
raw?: true;
}): Promise<Transactions[]>;
export function unlinkTransferTransactions({
raw = false,
transferIds = [],
} = {}) {
return makeRequest({
method: 'put',
url: '/transactions/unlink',
payload: {
transferIds,
},
raw,
});
}

export async function getCurrenciesRates({
codes,
}: { codes?: string[] } = {}): Promise<ExchangeRates[]> {
Expand Down

0 comments on commit 61359e6

Please sign in to comment.