Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to link non-transfer transactions between each other by making them common_transfer #101

Merged
merged 20 commits into from
Jan 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions shared-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 22 additions & 3 deletions shared-types/routes/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
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;
includeAll?: boolean;
nestedInclude?: boolean;
limit?: number;
from?: number;
type?: TRANSACTION_TYPES;
accountType?: ACCOUNT_TYPES;
accountId?: number;
excludeTransfer?: boolean;
}

export type GetTransactionsResponse = TransactionModel[];
Expand All @@ -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'];
Expand All @@ -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][];
}
36 changes: 19 additions & 17 deletions src/controllers/transactions.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
163 changes: 163 additions & 0 deletions src/controllers/transactions.controller/create-transaction.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
2 changes: 2 additions & 0 deletions src/controllers/transactions.controller/create-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const createTransaction = async (req, res: CustomResponse) => {
const {
amount,
destinationAmount,
destinationTransactionId,
note,
time,
transactionType,
Expand All @@ -32,6 +33,7 @@ export const createTransaction = async (req, res: CustomResponse) => {

const params = {
amount,
destinationTransactionId,
letehaha marked this conversation as resolved.
Show resolved Hide resolved
destinationAmount,
note,
time: new Date(time),
Expand Down
3 changes: 3 additions & 0 deletions src/controllers/transactions.controller/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const validateTransactionCreation = (
accountId,
destinationAccountId,
destinationAmount,
destinationTransactionId,
} = params;

if (transferNature === TRANSACTION_TRANSFER_NATURE.transfer_out_wallet) {
Expand All @@ -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`,
Expand Down
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 './transfer-linking';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './link-transactions';
export * from './unlink-transfer-transactions';
Loading
Loading