diff --git a/shared-types/api/api.ts b/shared-types/api/api.ts index 04c1e1ae..daa8ba5e 100644 --- a/shared-types/api/api.ts +++ b/shared-types/api/api.ts @@ -9,6 +9,7 @@ export enum API_ERROR_CODES { notFound = 'NOT_FOUND', unexpected = 'UNEXPECTED', validationError = 'VALIDATION_ERROR', + conflict = 'CONFLICT', forbidden = 'FORBIDDEN', BadRequest = 'BAD_REQUEST', diff --git a/shared-types/routes/auth.ts b/shared-types/routes/auth.ts index 86dba3a9..6bdb392e 100644 --- a/shared-types/routes/auth.ts +++ b/shared-types/routes/auth.ts @@ -2,8 +2,8 @@ import { UserModel } from 'shared-types'; import { BodyPayload } from './index'; export interface AuthLoginBody extends BodyPayload { - username: UserModel['username']; - password: UserModel['password']; + username: string; + password: string; } // Bearer token export interface AuthLoginResponse { @@ -11,8 +11,8 @@ export interface AuthLoginResponse { } export interface AuthRegisterBody extends BodyPayload { - username: UserModel['username']; - password: UserModel['password']; + username: string; + password: string; } export interface AuthRegisterResponse { user: UserModel; diff --git a/src/controllers/accounts.controller.e2e.ts b/src/controllers/accounts.controller.e2e.ts index 465c7466..dab9fba2 100644 --- a/src/controllers/accounts.controller.e2e.ts +++ b/src/controllers/accounts.controller.e2e.ts @@ -26,7 +26,8 @@ describe('Accounts controller', () => { expect(account.refCreditLimit).toStrictEqual(creditLimit); }); it('should correctly create account with correct balance for external currency', async () => { - const currency = (await helpers.addUserCurrencies({ currencyCodes: ['UAH'], raw: true }))[0]; + const currency = (await helpers.addUserCurrencies({ currencyCodes: ['UAH'], raw: true })) + .currencies[0]!; const account = await helpers.createAccount({ payload: { @@ -42,19 +43,19 @@ describe('Accounts controller', () => { expect(account.initialBalance).toStrictEqual(initialBalance); expect(account.refInitialBalance).toStrictEqual( - Math.floor(initialBalance * currencyRate.rate), + Math.floor(initialBalance * currencyRate!.rate), ); expect(account.currentBalance).toStrictEqual(initialBalance); expect(account.refCurrentBalance).toStrictEqual( - Math.floor(initialBalance * currencyRate.rate), + Math.floor(initialBalance * currencyRate!.rate), ); expect(account.creditLimit).toStrictEqual(creditLimit); - expect(account.refCreditLimit).toStrictEqual(Math.floor(creditLimit * currencyRate.rate)); + expect(account.refCreditLimit).toStrictEqual(Math.floor(creditLimit * currencyRate!.rate)); }); }); describe('update account', () => { it('should return 404 if try to update unexisting account', async () => { - const res = await helpers.updateAccount({ + const res = await helpers.updateAccount({ id: 1, }); @@ -132,11 +133,11 @@ describe('Accounts controller', () => { currencyCodes: [newCurrency], raw: true, }) - )[0]; + ).currencies[0]; const account = await helpers.createAccount({ payload: { ...helpers.buildAccountPayload(), - currencyId: currency.currencyId, + currencyId: currency!.currencyId, }, raw: true, }); diff --git a/src/controllers/auth.controller.e2e.ts b/src/controllers/auth.controller.e2e.ts index c93ae4fc..7d2026bb 100644 --- a/src/controllers/auth.controller.e2e.ts +++ b/src/controllers/auth.controller.e2e.ts @@ -1,10 +1,10 @@ import { API_ERROR_CODES, API_RESPONSE_STATUS } from 'shared-types'; -import { makeRequest } from '@tests/helpers'; +import { makeRequest, ErrorResponse } from '@tests/helpers'; describe('Auth', () => { describe('Login', () => { it('should return correct error for unexisting user', async () => { - const res = await makeRequest({ + const res = await makeRequest({ method: 'post', url: '/auth/login', payload: { diff --git a/src/controllers/banks/monobank.controller.e2e.ts b/src/controllers/banks/monobank.controller.e2e.ts index fc2d428c..9d1976dc 100644 --- a/src/controllers/banks/monobank.controller.e2e.ts +++ b/src/controllers/banks/monobank.controller.e2e.ts @@ -19,7 +19,7 @@ describe('Monobank integration', () => { it('throws error if invalid "token" is passed', async () => { const result = await helpers.monobank.callPair(); - expect(result.status).toEqual(ERROR_CODES.NotFoundError); + expect(result.status).toEqual(ERROR_CODES.Forbidden); }); it('creates Monobank user and correct accounts with valid token', async () => { const mockedClientData = helpers.monobank.mockedClient(); @@ -49,7 +49,7 @@ describe('Monobank integration', () => { const rates = await helpers.getCurrenciesRates(); const rate = rates.find( (r) => r.baseCode === CURRENCY_NUMBER_TO_CODE[item.currencyCode], - ).rate; + )!.rate; expect(resultItem.initialBalance).toBe(mockedAccount.balance); expect(resultItem.refInitialBalance).toBe(Math.floor(mockedAccount.balance * rate)); diff --git a/src/controllers/banks/monobank.controller.ts b/src/controllers/banks/monobank.controller.ts index 8cced39a..f592cf1e 100644 --- a/src/controllers/banks/monobank.controller.ts +++ b/src/controllers/banks/monobank.controller.ts @@ -40,7 +40,7 @@ const hostname = config.get('bankIntegrations.monobank.apiEndpoint'); function dateRange({ from, to }: { from: number; to: number }): { start: number; end: number }[] { const difference = differenceInCalendarMonths(new Date(to), new Date(from)); - const dates = []; + const dates: { start: number; end: number }[] = []; for (let i = 0; i <= difference; i++) { const start = startOfMonth(addMonths(new Date(from), i)); @@ -85,7 +85,7 @@ async function createMonoTransaction({ if (isTransactionExists) return; - const userData = await usersService.getUser(account.userId); + const userData = (await usersService.getUser(account.userId))!; let mccId = await MerchantCategoryCodes.getByCode({ code: data.mcc }); @@ -99,12 +99,14 @@ async function createMonoTransaction({ userId: userData.id, }); - let categoryId; + let categoryId: number; if (userMcc.length) { - categoryId = userMcc[0].get('categoryId'); + categoryId = userMcc[0]!.get('categoryId'); } else { - categoryId = (await Users.getUserDefaultCategory({ id: userData.id })).get('defaultCategoryId'); + categoryId = (await Users.getUserDefaultCategory({ id: userData.id }))!.get( + 'defaultCategoryId', + ); await UserMerchantCategoryCodes.createEntry({ mccId: mccId.get('id'), @@ -156,7 +158,7 @@ export const pairAccount = async (req, res: CustomResponse) => { userId: systemUserId, }); - if ('connected' in result && result.connected) { + if (result && 'connected' in result && result.connected) { return res.status(404).json({ status: API_RESPONSE_STATUS.error, response: { @@ -527,13 +529,12 @@ export const refreshAccounts = async (req, res) => { externalIds: clientInfo.accounts.map((item) => item.id), }); - const accountsToUpdate = []; - const accountsToCreate = []; + const promises: Promise[] = []; clientInfo.accounts.forEach((account) => { const existingAccount = existingAccounts.find((acc) => acc.externalId === account.id); if (existingAccount) { - accountsToUpdate.push( + promises.push( accountsService.updateAccount({ id: existingAccount.id, currentBalance: account.balance, @@ -547,7 +548,7 @@ export const refreshAccounts = async (req, res) => { }), ); } else { - accountsToCreate.push( + promises.push( accountsService.createSystemAccountsFromMonobankAccounts({ userId: systemUserId, monoAccounts: [account], @@ -556,8 +557,7 @@ export const refreshAccounts = async (req, res) => { } }); - await Promise.all(accountsToUpdate); - await Promise.all(accountsToCreate); + await Promise.all(promises); const accounts = await accountsService.getAccounts({ userId: monoUser.systemUserId, diff --git a/src/controllers/categories.controller/create-category.ts b/src/controllers/categories.controller/create-category.ts index 9ebc2c1f..f11eb00c 100644 --- a/src/controllers/categories.controller/create-category.ts +++ b/src/controllers/categories.controller/create-category.ts @@ -29,7 +29,7 @@ export const createCategory = async (req, res: CustomResponse) => { export const CreateCategoryPayloadSchema = z .object({ name: z.string().min(1).max(200, 'The name must not exceed 200 characters'), - imageUrl: z.string().url().max(500, 'The URL must not exceed 500 characters').nullish(), + imageUrl: z.string().url().max(500, 'The URL must not exceed 500 characters').optional(), type: z .enum(Object.values(CATEGORY_TYPES) as [string, ...string[]]) .default(CATEGORY_TYPES.custom), @@ -41,7 +41,7 @@ export const CreateCategoryPayloadSchema = z color: z .string() .regex(/^#[0-9A-F]{6}$/i) - .nullish(), + .optional(), }), z.object({ parentId: z.undefined(), diff --git a/src/controllers/currencies/add-user-currencies.ts b/src/controllers/currencies/add-user-currencies.ts new file mode 100644 index 00000000..b96f422b --- /dev/null +++ b/src/controllers/currencies/add-user-currencies.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { API_RESPONSE_STATUS } from 'shared-types'; +import { CustomResponse } from '@common/types'; +import * as userCurrenciesService from '@services/currencies/add-user-currency'; +import { errorHandler } from '../helpers'; + +export const addUserCurrencies = async (req, res: CustomResponse) => { + try { + const { id: userId } = req.user; + const { currencies }: AddUserCurrenciesParams = req.validated.body; + + const result = await userCurrenciesService.addUserCurrencies( + currencies.map((item) => ({ userId, ...item })), + ); + + return res.status(200).json({ + status: API_RESPONSE_STATUS.success, + response: result, + }); + } catch (err) { + errorHandler(res, err); + } +}; + +const recordId = () => z.number().int().positive().finite(); +const UserCurrencySchema = z + .object({ + currencyId: recordId(), + exchangeRate: z.number().positive().optional(), + liveRateUpdate: z.boolean().optional(), + }) + .strict(); + +const bodyZodSchema = z + .object({ + currencies: z.array(UserCurrencySchema).nonempty(), + }) + .strict(); + +type AddUserCurrenciesParams = z.infer; + +export const addUserCurrenciesSchema = z.object({ + body: bodyZodSchema, +}); diff --git a/src/controllers/currencies/edit-currency-exchange-rate.ts b/src/controllers/currencies/edit-currency-exchange-rate.ts new file mode 100644 index 00000000..8ae6aec7 --- /dev/null +++ b/src/controllers/currencies/edit-currency-exchange-rate.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import cc from 'currency-codes'; +import { API_RESPONSE_STATUS } from 'shared-types'; +import { CustomResponse } from '@common/types'; +import * as userExchangeRates from '@services/user-exchange-rate'; +import { errorHandler } from '@controllers/helpers'; + +export const editCurrencyExchangeRate = async (req, res: CustomResponse) => { + try { + const { id: userId } = req.user; + const { pairs }: EditCurrencyExchangeRateParams = req.validated.body; + + const data = await userExchangeRates.editUserExchangeRates({ + userId, + pairs, + }); + + return res.status(200).json({ + status: API_RESPONSE_STATUS.success, + response: data, + }); + } catch (err) { + errorHandler(res, err); + } +}; + +const isValidCurrencyCode = (code: string) => cc.code(code) !== undefined; + +const CurrencyCodeSchema = z.string().refine( + (code) => isValidCurrencyCode(code), + (code) => ({ message: `Invalid currency code: ${code}. Use ISO 4217 Code. For example: USD` }), +); + +const UpdateExchangeRatePairSchema = z + .object({ + baseCode: CurrencyCodeSchema, + quoteCode: CurrencyCodeSchema, + rate: z.number().positive(), + }) + .strict(); + +const bodyZodSchema = z + .object({ + pairs: z + .array(UpdateExchangeRatePairSchema) + .nonempty() + .refine( + (pairs) => pairs.every((pair) => pair.baseCode !== pair.quoteCode), + 'You cannot edit pair with the same base and quote currency code.', + ) + .refine( + (pairs) => pairs.every((pair) => pairs.some((item) => item.baseCode === pair.quoteCode)), + "When changing base-quote pair rate, you need to also change opposite pair's rate.", + ), + }) + .strict(); + +type EditCurrencyExchangeRateParams = z.infer; + +export const editCurrencyExchangeRateSchema = z.object({ + body: bodyZodSchema, +}); diff --git a/src/controllers/transactions.controller.ts b/src/controllers/transactions.controller.ts index 9af5dc2b..f527350a 100644 --- a/src/controllers/transactions.controller.ts +++ b/src/controllers/transactions.controller.ts @@ -1,56 +1,9 @@ -import { API_RESPONSE_STATUS, SORT_DIRECTIONS, endpointsTypes } from 'shared-types'; +import { API_RESPONSE_STATUS } from 'shared-types'; import { CustomResponse } from '@common/types'; import { ValidationError } from '@js/errors'; import * as transactionsService from '@services/transactions'; import { errorHandler } from './helpers'; -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, - // isRaw, - excludeTransfer, - excludeRefunds, - }: endpointsTypes.GetTransactionsQuery = req.query; - - const data = await transactionsService.getTransactions({ - userId, - transactionType, - sortDirection: sort, - limit, - from, - accountType, - accountId, - includeUser, - includeAccount, - includeCategory, - includeAll, - nestedInclude, - excludeTransfer, - excludeRefunds, - isRaw: false, - }); - - return res.status(200).json({ - status: API_RESPONSE_STATUS.success, - response: data, - }); - } catch (err) { - errorHandler(res, err); - } -}; - export const getTransactionById = async (req, res: CustomResponse) => { try { const { id } = req.params; diff --git a/src/controllers/transactions.controller/create-transaction.e2e.ts b/src/controllers/transactions.controller/create-transaction.e2e.ts index dbaf53ff..7b056c4e 100644 --- a/src/controllers/transactions.controller/create-transaction.e2e.ts +++ b/src/controllers/transactions.controller/create-transaction.e2e.ts @@ -4,7 +4,8 @@ import * as helpers from '@tests/helpers'; describe('Create transaction controller', () => { it('should return validation error if no data passed', async () => { - const res = await helpers.createTransaction({ payload: null, raw: false }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await helpers.createTransaction({ payload: null as any, raw: false }); expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); }); @@ -55,7 +56,7 @@ describe('Create transaction controller', () => { expect(baseTx.currencyId).toBe(currency.id); expect(baseTx.currencyCode).toBe(currency.code); expect(baseTx.amount).toBe(txPayload.amount); - expect(baseTx.refAmount).toBe(Math.floor(txPayload.amount * currencyRate.rate)); + expect(baseTx.refAmount).toBe(Math.floor(txPayload.amount * currencyRate!.rate)); expect(baseTx.transactionType).toBe(txPayload.transactionType); expect(baseTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.not_transfer); expect(baseTx).toStrictEqual(transactions[0]); @@ -84,23 +85,23 @@ describe('Create transaction controller', () => { expect(baseTx.currencyCode).toBe(global.BASE_CURRENCY.code); expect(baseTx.amount).toBe(txPayload.amount); - expect(oppositeTx.amount).toBe(txPayload.amount); + expect(oppositeTx!.amount).toBe(txPayload.amount); expect(baseTx.accountId).toBe(accountA.id); - expect(oppositeTx.accountId).toBe(accountB.id); + expect(oppositeTx!.accountId).toBe(accountB.id); expect(baseTx.refAmount).toBe(txPayload.amount); - expect(oppositeTx.refAmount).toBe(txPayload.amount); + expect(oppositeTx!.refAmount).toBe(txPayload.amount); expect(baseTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); - expect(oppositeTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); + expect(oppositeTx!.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); // Make sure `transferId` is the same for both transactions expect(baseTx.transferId).toBe(baseTx.transferId); - expect(oppositeTx.transferId).toBe(baseTx.transferId); + expect(oppositeTx!.transferId).toBe(baseTx.transferId); expect(baseTx.transactionType).toBe(txPayload.transactionType); - expect(oppositeTx.transactionType).toBe( + expect(oppositeTx!.transactionType).toBe( txPayload.transactionType === TRANSACTION_TYPES.expense ? TRANSACTION_TYPES.income : TRANSACTION_TYPES.expense, @@ -139,28 +140,28 @@ describe('Create transaction controller', () => { expect(baseTx.currencyId).toBe(global.BASE_CURRENCY.id); expect(baseTx.currencyCode).toBe(global.BASE_CURRENCY.code); - expect(oppositeTx.currencyId).toBe(currencyB.id); - expect(oppositeTx.currencyCode).toBe(currencyB.code); + expect(oppositeTx!.currencyId).toBe(currencyB.id); + expect(oppositeTx!.currencyCode).toBe(currencyB.code); expect(baseTx.amount).toBe(txPayload.amount); - expect(oppositeTx.amount).toBe(DESTINATION_AMOUNT); + expect(oppositeTx!.amount).toBe(DESTINATION_AMOUNT); expect(baseTx.accountId).toBe(accountA.id); - expect(oppositeTx.accountId).toBe(accountB.id); + expect(oppositeTx!.accountId).toBe(accountB.id); // if `from` is base account, then `refAmount` stays the same expect(baseTx.refAmount).toBe(baseTx.amount); - expect(oppositeTx.refAmount).toBe(baseTx.amount); + expect(oppositeTx!.refAmount).toBe(baseTx.amount); expect(baseTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); - expect(oppositeTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); + expect(oppositeTx!.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); // Make sure `transferId` is the same for both transactions expect(baseTx.transferId).toBe(baseTx.transferId); - expect(oppositeTx.transferId).toBe(baseTx.transferId); + expect(oppositeTx!.transferId).toBe(baseTx.transferId); expect(baseTx.transactionType).toBe(txPayload.transactionType); - expect(oppositeTx.transactionType).toBe( + expect(oppositeTx!.transactionType).toBe( txPayload.transactionType === TRANSACTION_TYPES.expense ? TRANSACTION_TYPES.income : TRANSACTION_TYPES.expense, @@ -215,28 +216,28 @@ describe('Create transaction controller', () => { expect(baseTx.currencyId).toBe(currencyA.id); expect(baseTx.currencyCode).toBe(currencyA.code); - expect(oppositeTx.currencyId).toBe(currencyB.id); - expect(oppositeTx.currencyCode).toBe(currencyB.code); + expect(oppositeTx!.currencyId).toBe(currencyB.id); + expect(oppositeTx!.currencyCode).toBe(currencyB.code); expect(baseTx.amount).toBe(txPayload.amount); - expect(oppositeTx.amount).toBe(DESTINATION_AMOUNT); + expect(oppositeTx!.amount).toBe(DESTINATION_AMOUNT); expect(baseTx.accountId).toBe(accountA.id); - expect(oppositeTx.accountId).toBe(accountB.id); + expect(oppositeTx!.accountId).toBe(accountB.id); // Secondary (`to`) transfer tx always same `refAmount` as the general (`from`) tx to keep it consistent - expect(baseTx.refAmount).toBe(Math.floor(baseTx.amount * currencyRate.rate)); - expect(oppositeTx.refAmount).toBe(Math.floor(oppositeTx.amount * oppositeCurrencyRate.rate)); + expect(baseTx.refAmount).toBe(Math.floor(baseTx.amount * currencyRate!.rate)); + expect(oppositeTx!.refAmount).toBe(Math.floor(oppositeTx!.amount * oppositeCurrencyRate!.rate)); expect(baseTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); - expect(oppositeTx.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); + expect(oppositeTx!.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); // Make sure `transferId` is the same for both transactions expect(baseTx.transferId).toBe(baseTx.transferId); - expect(oppositeTx.transferId).toBe(baseTx.transferId); + expect(oppositeTx!.transferId).toBe(baseTx.transferId); expect(baseTx.transactionType).toBe(txPayload.transactionType); - expect(oppositeTx.transactionType).toBe( + expect(oppositeTx!.transactionType).toBe( txPayload.transactionType === TRANSACTION_TYPES.expense ? TRANSACTION_TYPES.income : TRANSACTION_TYPES.expense, @@ -283,11 +284,11 @@ describe('Create transaction controller', () => { 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.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( + expect(oppositeTx!.transactionType).toBe( expectedValues.destinationTransaction.transactionType, ); }); @@ -309,7 +310,7 @@ describe('Create transaction controller', () => { const transferTxPayload = helpers.buildTransactionPayload({ ...expectedValues, transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - destinationTransactionId: externalTransaction.id, + destinationTransactionId: externalTransaction!.id, }); const [baseTx, oppositeTx] = await helpers.createTransaction({ @@ -317,8 +318,8 @@ describe('Create transaction controller', () => { raw: true, }); - expect(baseTx.transferId).toBe(oppositeTx.transferId); - expect(oppositeTx.amount).toBe(externalTransaction.amount); + expect(baseTx.transferId).toBe(oppositeTx!.transferId); + expect(oppositeTx!.amount).toBe(externalTransaction!.amount); expect(baseTx.amount).toBe(expectedValues.amount); }, ); @@ -397,7 +398,7 @@ describe('Create transaction controller', () => { accountId: accountC.id, transactionType: TRANSACTION_TYPES.expense, transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - destinationTransactionId: oppositeTx.id, + destinationTransactionId: oppositeTx!.id, }); const result = await helpers.createTransaction({ diff --git a/src/controllers/transactions.controller/create-transaction.ts b/src/controllers/transactions.controller/create-transaction.ts index c5d902d8..54740bcd 100644 --- a/src/controllers/transactions.controller/create-transaction.ts +++ b/src/controllers/transactions.controller/create-transaction.ts @@ -37,8 +37,8 @@ export const createTransaction = async ( amount, destinationTransactionId, destinationAmount, - note, - time: new Date(time), + note: note || undefined, + time: time ? new Date(time) : undefined, transactionType, paymentType, accountId, @@ -54,12 +54,7 @@ export const createTransaction = async ( // 1. Amount and destinationAmount with same currency should be equal // 2. That transactions here might be created only with system account type - let data = await transactionsService.createTransaction(params); - - if (data[0].dataValues) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data = data.map((d) => d.dataValues ?? d) as any; - } + const data = await transactionsService.createTransaction(params); return res.status(200).json({ status: API_RESPONSE_STATUS.success, diff --git a/src/controllers/transactions.controller/delete-transaction.e2e.ts b/src/controllers/transactions.controller/delete-transaction.e2e.ts index 603ee804..820c499a 100644 --- a/src/controllers/transactions.controller/delete-transaction.e2e.ts +++ b/src/controllers/transactions.controller/delete-transaction.e2e.ts @@ -1,4 +1,4 @@ -import { TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE } from 'shared-types'; +import { TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE, TransactionModel } from 'shared-types'; import { ERROR_CODES } from '@js/errors'; import * as helpers from '@tests/helpers'; @@ -15,7 +15,7 @@ describe('Delete transaction controller', () => { expect(createdTransactions.length).toBe(transactions.length); - const res = await helpers.deleteTransaction({ id: transactions[0].id }); + const res = await helpers.deleteTransaction({ id: transactions[0]!.id }); const txsAfterDeletion = await helpers.getTransactions({ raw: true }); @@ -23,7 +23,7 @@ describe('Delete transaction controller', () => { expect(txsAfterDeletion.length).toBe(0); }); describe('transfer transactions', () => { - let transactions = []; + let transactions: TransactionModel[] = []; beforeEach(async () => { const currencyA = global.MODELS_CURRENCIES.find((item) => item.code === 'EUR'); @@ -63,7 +63,7 @@ describe('Delete transaction controller', () => { }); it('should successfully delete both tx when deleting "from" transaction', async () => { - const res = await helpers.deleteTransaction({ id: transactions[0].id }); + const res = await helpers.deleteTransaction({ id: transactions[0]!.id }); const txsAfterDeletion = await helpers.getTransactions({ raw: true }); @@ -71,7 +71,7 @@ describe('Delete transaction controller', () => { expect(txsAfterDeletion.length).toBe(0); }); it('should successfully delete both tx when deleting "to" transaction', async () => { - const res = await helpers.deleteTransaction({ id: transactions[1].id }); + const res = await helpers.deleteTransaction({ id: transactions[1]!.id }); const txsAfterDeletion = await helpers.getTransactions({ raw: true }); @@ -87,7 +87,7 @@ describe('Delete transaction controller', () => { (item) => item.transactionType === TRANSACTION_TYPES.income, ); - const res = await helpers.deleteTransaction({ id: incomeTransaction.id }); + const res = await helpers.deleteTransaction({ id: incomeTransaction!.id }); expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); }); diff --git a/src/controllers/transactions.controller/get-transaction.ts b/src/controllers/transactions.controller/get-transaction.ts new file mode 100644 index 00000000..a6a2251a --- /dev/null +++ b/src/controllers/transactions.controller/get-transaction.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { + API_RESPONSE_STATUS, + SORT_DIRECTIONS, + TRANSACTION_TYPES, + ACCOUNT_TYPES, +} from 'shared-types'; +import { CustomRequest, CustomResponse } from '@common/types'; +import * as transactionsService from '@services/transactions'; +import { errorHandler } from '../helpers'; + +export const getTransactions = async ( + req: CustomRequest, + res: CustomResponse, +) => { + try { + const { id: userId } = req.user; + const { ...restParams } = req.validated.query; + + const data = await transactionsService.getTransactions({ + ...restParams, + userId, + }); + + return res.status(200).json({ + status: API_RESPONSE_STATUS.success, + response: data, + }); + } catch (err) { + console.log('err', err); + errorHandler(res, err); + } +}; + +const parseCommaSeparatedNumbers = (value: string) => + value + .split(',') + .map(Number) + .filter((n) => !isNaN(n)); + +export const getTransactionsSchema = z.object({ + query: z + .object({ + order: z.nativeEnum(SORT_DIRECTIONS).optional().default(SORT_DIRECTIONS.desc), + limit: z.preprocess((val) => Number(val), z.number().int().positive()).optional(), + from: z + .preprocess((val) => Number(val), z.number().int().nonnegative()) + .optional() + .default(0), + transactionType: z.nativeEnum(TRANSACTION_TYPES).optional(), + accountType: z.nativeEnum(ACCOUNT_TYPES).optional(), + accountIds: z + .preprocess( + (val) => (typeof val === 'string' ? parseCommaSeparatedNumbers(val) : val), + z.array(z.number().int().positive()), + ) + .optional(), + includeUser: z.preprocess((val) => val === 'true', z.boolean()).optional(), + includeAccount: z.preprocess((val) => val === 'true', z.boolean()).optional(), + includeCategory: z.preprocess((val) => val === 'true', z.boolean()).optional(), + includeAll: z.preprocess((val) => val === 'true', z.boolean()).optional(), + nestedInclude: z.preprocess((val) => val === 'true', z.boolean()).optional(), + excludeTransfer: z.preprocess((val) => val === 'true', z.boolean()).optional(), + excludeRefunds: z.preprocess((val) => val === 'true', z.boolean()).optional(), + startDate: z + .string() + .datetime({ message: 'Invalid ISO date string for startDate' }) + .optional(), + endDate: z.string().datetime({ message: 'Invalid ISO date string for endDate' }).optional(), + amountLte: z.preprocess((val) => Number(val), z.number().positive()).optional(), + amountGte: z.preprocess((val) => Number(val), z.number().positive()).optional(), + }) + .refine( + (data) => { + if (data.amountGte && data.amountLte) { + return data.amountGte <= data.amountLte; + } + return true; + }, + { + message: 'amountGte must be less than or equal to amountLte', + path: ['amountGte'], + }, + ), +}); diff --git a/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts b/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts index f378c482..865a8e15 100644 --- a/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts +++ b/src/controllers/transactions.controller/transfer-linking/link-transactions.e2e.ts @@ -57,8 +57,8 @@ describe('link transactions between each other', () => { transferId: expect.toBeAnythingOrNull(), }); - expect(txAfter.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); - expect(txAfter.transferId).toEqual(expect.any(String)); + expect(txAfter!.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); + expect(txAfter!.transferId).toEqual(expect.any(String)); }); // Check that transactions fetching also returns correct result @@ -72,8 +72,8 @@ describe('link transactions between each other', () => { transferId: expect.toBeAnythingOrNull(), }); - expect(txAfter.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); - expect(txAfter.transferId).toEqual(expect.any(String)); + expect(txAfter!.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); + expect(txAfter!.transferId).toEqual(expect.any(String)); }); expect(incomeA.transferId).toBe(expenseB.transferId); @@ -89,7 +89,7 @@ describe('link transactions between each other', () => { const result = await helpers.linkTransactions({ payload: { - ids: [[tx1.id, tx2.id]], + ids: [[tx1!.id, tx2!.id]], }, }); expect(result.statusCode).toBe(ERROR_CODES.ValidationError); @@ -153,12 +153,12 @@ describe('link transactions between each other', () => { raw: true, }); - const expenseTx = transactions.find((t) => t.transactionType === TRANSACTION_TYPES.expense); - const incomeTx = transactions.find((t) => t.transactionType === TRANSACTION_TYPES.income); + 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]], + ids: [[tx1.id, txType === TRANSACTION_TYPES.income ? expenseTx!.id : incomeTx!.id]], }, }); diff --git a/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts index a988613c..575bd7d2 100644 --- a/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts +++ b/src/controllers/transactions.controller/transfer-linking/unlink-transfer-transactions.e2e.ts @@ -83,8 +83,8 @@ describe('Unlink transfer transactions', () => { const [updatedA, updatedB] = await helpers.linkTransactions({ payload: { ids: [ - [expenseExternalTx.id, incomeSystemTx.id], - [incomeExternalTx.id, expenseSystemTx.id], + [expenseExternalTx!.id, incomeSystemTx.id], + [incomeExternalTx!.id, expenseSystemTx.id], ], }, raw: true, @@ -93,28 +93,28 @@ describe('Unlink transfer transactions', () => { // Test that after updation only transfer-related fields were changed for each // transaction expect(expenseExternalTx).toEqual({ - ...updatedA[0], + ...updatedA![0], transferNature: expect.toBeAnythingOrNull(), transferId: expect.toBeAnythingOrNull(), }); expect(incomeSystemTx).toEqual({ - ...updatedA[1], + ...updatedA![1], transferNature: expect.toBeAnythingOrNull(), transferId: expect.toBeAnythingOrNull(), }); expect(incomeExternalTx).toEqual({ - ...updatedB[0], + ...updatedB![0], transferNature: expect.toBeAnythingOrNull(), transferId: expect.toBeAnythingOrNull(), }); expect(expenseSystemTx).toEqual({ - ...updatedB[1], + ...updatedB![1], transferNature: expect.toBeAnythingOrNull(), transferId: expect.toBeAnythingOrNull(), }); // Now unlink all of them - const transferIds = [...updatedA, ...updatedB].map((t) => t.transferId); + const transferIds = [...updatedA!, ...updatedB!].map((t) => t.transferId); const result = await helpers.unlinkTransferTransactions({ transferIds, @@ -123,7 +123,7 @@ describe('Unlink transfer transactions', () => { // 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); + expect(result.find((t) => t.id === tx!.id)).toEqual(tx); }); }); }); diff --git a/src/controllers/transactions.controller/update-transaction.e2e.ts b/src/controllers/transactions.controller/update-transaction.e2e.ts index e2b562af..fe1b5813 100644 --- a/src/controllers/transactions.controller/update-transaction.e2e.ts +++ b/src/controllers/transactions.controller/update-transaction.e2e.ts @@ -44,7 +44,7 @@ describe('Update transaction controller', () => { expect(baseTx.accountId).toStrictEqual(accountUAH.id); expect(baseTx.amount).toStrictEqual(createdTransaction.amount); expect(baseTx.refAmount).toStrictEqual( - Math.floor(createdTransaction.amount * currencyRate.rate), + Math.floor(createdTransaction.amount * currencyRate!.rate), ); }); it('should create transfer tx for ref + non-ref tx, and change destination non-ref account to another non-ref account', async () => { @@ -68,7 +68,7 @@ describe('Update transaction controller', () => { // Even if the currencyRate between USD and UAH has a huge difference, non-ref // tx should always have refAmount same as ref tx in case of transfer - expect(oppositeTx.refAmount).toEqual(baseTx.refAmount); + expect(oppositeTx!.refAmount).toEqual(baseTx.refAmount); const { account: accountEUR, currency: currencyEUR } = await helpers.createAccountWithNewCurrency({ currency: 'EUR' }); @@ -82,8 +82,8 @@ describe('Update transaction controller', () => { expect(newOppositeTx).toMatchObject({ // We only changed account, so amounts should stay same - amount: oppositeTx.amount, - refAmount: oppositeTx.refAmount, + amount: oppositeTx!.amount, + refAmount: oppositeTx!.refAmount, // accountId and currencyCode are changed accountId: accountEUR.id, currencyId: currencyEUR.id, @@ -161,8 +161,8 @@ describe('Update transaction controller', () => { raw: true, }); - expect(updatedOppositeTx.amount).toEqual(updatedOppositeTx.refAmount); - expect(updatedBaseTx.refAmount).toEqual(updatedOppositeTx.refAmount); + expect(updatedOppositeTx!.amount).toEqual(updatedOppositeTx!.refAmount); + expect(updatedBaseTx.refAmount).toEqual(updatedOppositeTx!.refAmount); }); it('UAH->EUR to USD->EUR, refAmount should be same as amount of USD. Because USD is a ref-currency', async () => { const { account: accountEUR } = await helpers.createAccountWithNewCurrency({ @@ -198,8 +198,8 @@ describe('Update transaction controller', () => { }); expect(updatedBaseTx.amount).toEqual(updatedBaseTx.refAmount); - expect(updatedOppositeTx.amount).toEqual(oppositeTx.amount); - expect(updatedOppositeTx.refAmount).toEqual(updatedBaseTx.refAmount); + expect(updatedOppositeTx!.amount).toEqual(oppositeTx!.amount); + expect(updatedOppositeTx!.refAmount).toEqual(updatedBaseTx.refAmount); }); }); @@ -218,11 +218,11 @@ describe('Update transaction controller', () => { }); const [baseTx, oppositeTx] = await helpers.updateTransaction({ - id: externalTransaction.id, + id: externalTransaction!.id, payload: { transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, destinationAccountId: accountB.id, - destinationAmount: externalTransaction.refAmount, + destinationAmount: externalTransaction!.refAmount, }, raw: true, }); @@ -239,32 +239,33 @@ describe('Update transaction controller', () => { const externalTxBalanceRecord = balanceHistory.find( (item) => // eslint-disable-next-line @typescript-eslint/no-explicit-any - item.amount === (externalTransaction.externalData as any).balance, + item.amount === (externalTransaction!.externalData as any).balance, ); const newTxBalanceRecord = balanceHistory.find( (item) => - item.date === externalTxBalanceRecord.date && item.accountId === oppositeTx.accountId, + item.date === externalTxBalanceRecord.date && + item.accountId === oppositeTx!.accountId, ); expect(newTxBalanceRecord.amount).toBe( expected === 0 ? 0 - : oppositeTx.transactionType === TRANSACTION_TYPES.expense + : oppositeTx!.transactionType === TRANSACTION_TYPES.expense ? -expected : expected, ); }; expect(baseTx).toMatchObject({ - amount: externalTransaction.amount, - refAmount: externalTransaction.refAmount, - accountId: externalTransaction.accountId, + amount: externalTransaction!.amount, + refAmount: externalTransaction!.refAmount, + accountId: externalTransaction!.accountId, transferId, transactionType: transactionType, }); expect(oppositeTx).toMatchObject({ - amount: externalTransaction.refAmount, - refAmount: externalTransaction.refAmount, + amount: externalTransaction!.refAmount, + refAmount: externalTransaction!.refAmount, transferId, accountId: accountB.id, transactionType: @@ -273,11 +274,11 @@ describe('Update transaction controller', () => { : TRANSACTION_TYPES.expense, }); - await checkBalanceIsCorrect(externalTransaction.refAmount); + await checkBalanceIsCorrect(externalTransaction!.refAmount); // Now update it back to be non-transfer one await helpers.updateTransaction({ - id: externalTransaction.id, + id: externalTransaction!.id, payload: { transferNature: TRANSACTION_TRANSFER_NATURE.not_transfer, }, @@ -291,9 +292,9 @@ describe('Update transaction controller', () => { }); // Check that opposite tx is deleted - expect(transactionsAfterUpdate.find((i) => i.id === oppositeTx.id)).toBe(undefined); + expect(transactionsAfterUpdate.find((i) => i.id === oppositeTx!.id)).toBe(undefined); // Check that base tx doesn't have transferId anymore - expect(transactionsAfterUpdate.find((i) => i.id === baseTx.id).transferId).toBe(null); + expect(transactionsAfterUpdate.find((i) => i.id === baseTx.id)!.transferId).toBe(null); }, ); it('throws error when trying to make invalid actions', async () => { @@ -309,13 +310,13 @@ describe('Update transaction controller', () => { // when trying to update "transactionType" of the external account const result_a = await helpers.updateTransaction({ - id: incomeTransaction.id, + id: incomeTransaction!.id, payload: { transactionType: TRANSACTION_TYPES.expense }, }); expect(result_a.statusCode).toEqual(ERROR_CODES.ValidationError); const result_b = await helpers.updateTransaction({ - id: expenseTransaction.id, + id: expenseTransaction!.id, payload: { transactionType: TRANSACTION_TYPES.income }, }); expect(result_b.statusCode).toEqual(ERROR_CODES.ValidationError); @@ -330,7 +331,7 @@ describe('Update transaction controller', () => { // Trying to update some of restricted fields for (const field of EXTERNAL_ACCOUNT_RESTRICTED_UPDATION_FIELDS) { const res = await helpers.updateTransaction({ - id: expenseTransaction.id, + id: expenseTransaction!.id, payload: { [field]: mockedData[field] }, }); expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); @@ -389,11 +390,11 @@ describe('Update transaction controller', () => { transferId: expect.toBeAnythingOrNull(), }); - expect(txAfter.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); - expect(txAfter.transferId).toEqual(expect.any(String)); + expect(txAfter!.transferNature).toBe(TRANSACTION_TRANSFER_NATURE.common_transfer); + expect(txAfter!.transferId).toEqual(expect.any(String)); }); - expect(tx1AfterUpdation.transferId).toBe(tx2AfterUpdation.transferId); + expect(tx1AfterUpdation!.transferId).toBe(tx2AfterUpdation!.transferId); }, ); @@ -412,10 +413,10 @@ describe('Update transaction controller', () => { const tx2 = transactions.find((item) => item.transactionType === oppositeTxType); const result = await helpers.updateTransaction({ - id: tx1.id, + id: tx1!.id, payload: { transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, - destinationTransactionId: tx2.id, + destinationTransactionId: tx2!.id, }, }); expect(result.statusCode).toBe(ERROR_CODES.ValidationError); @@ -482,15 +483,17 @@ describe('Update transaction controller', () => { raw: true, }); - const expenseTx = transactions.find((t) => t.transactionType === TRANSACTION_TYPES.expense); - const incomeTx = transactions.find((t) => t.transactionType === TRANSACTION_TYPES.income); + 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, + txType === TRANSACTION_TYPES.income ? expenseTx!.id : incomeTx!.id, }, }); @@ -730,9 +733,9 @@ describe('Update transaction controller', () => { const transactions = await helpers.getTransactions({ raw: true }); // Test that previously refunded tx is now not marked as a refund - expect(transactions.find((i) => i.id === originalTx1.id).refundLinked).toBe(false); + expect(transactions.find((i) => i.id === originalTx1.id)!.refundLinked).toBe(false); expect( - transactions.find((t) => [refundTx.id, originalTx2.id].includes(t.id)).refundLinked, + transactions.find((t) => [refundTx.id, originalTx2.id].includes(t.id))!.refundLinked, ).toBe(true); }, ); diff --git a/src/controllers/transactions.controller/update-transaction.ts b/src/controllers/transactions.controller/update-transaction.ts index 4252047b..0f48d805 100644 --- a/src/controllers/transactions.controller/update-transaction.ts +++ b/src/controllers/transactions.controller/update-transaction.ts @@ -4,7 +4,6 @@ import { PAYMENT_TYPES, TRANSACTION_TRANSFER_NATURE, TRANSACTION_TYPES, - endpointsTypes, } from 'shared-types'; import { CustomResponse, CustomRequest } from '@common/types'; import { errorHandler } from '@controllers/helpers'; @@ -31,7 +30,7 @@ export const updateTransaction = async ( transferNature, refundedByTxIds, refundsTxId, - }: endpointsTypes.UpdateTransactionBody = req.validated.body; + } = req.validated.body; const { id: userId } = req.user; const data = await transactionsService.updateTransaction({ @@ -41,7 +40,7 @@ export const updateTransaction = async ( destinationAmount, destinationTransactionId, note, - time: new Date(time), + time: time ? new Date(time) : undefined, userId, transactionType, paymentType, diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 4e3d7577..074e4abc 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -133,35 +133,6 @@ export const setBaseUserCurrency = async (req, res: CustomResponse) => { } }; -export const addUserCurrencies = async (req, res: CustomResponse) => { - const { id: userId } = req.user; - - const { - currencies, - }: { - currencies: { - currencyId: number; - exchangeRate?: number; - liveRateUpdate?: boolean; - }[]; - } = req.body; - - // TODO: types validation - - try { - const result = await userService.addUserCurrencies( - currencies.map((item) => ({ userId, ...item })), - ); - - return res.status(200).json({ - status: API_RESPONSE_STATUS.success, - response: result, - }); - } catch (err) { - errorHandler(res, err); - } -}; - export const editUserCurrency = async (req, res: CustomResponse) => { const { id: userId } = req.user; diff --git a/src/js/errors.ts b/src/js/errors.ts index c8ceaaaf..73a4c791 100644 --- a/src/js/errors.ts +++ b/src/js/errors.ts @@ -3,6 +3,7 @@ import { API_ERROR_CODES } from 'shared-types'; export enum ERROR_CODES { BadRequest = 400, Unauthorized = 401, + Forbidden = 403, NotFoundError = 404, ConflictError = 409, ValidationError = 422, @@ -13,7 +14,7 @@ export enum ERROR_CODES { export class CustomError extends Error { public httpCode: number; public code: API_ERROR_CODES; - public details: Record; + public details: Record | undefined; constructor(httpCode, code: API_ERROR_CODES, message: string, details?: Record) { super(message); @@ -61,8 +62,16 @@ export class NotFoundError extends CustomError { } export class ConflictError extends CustomError { - constructor(code: API_ERROR_CODES, message: string) { - super(ERROR_CODES.ConflictError, code, message); + constructor({ + code = API_ERROR_CODES.conflict, + message, + details, + }: { + code?: API_ERROR_CODES; + message: string; + details?: Record; + }) { + super(ERROR_CODES.ConflictError, code, message, details); } } @@ -80,6 +89,20 @@ export class ValidationError extends CustomError { } } +export class ForbiddenError extends CustomError { + constructor({ + code = API_ERROR_CODES.forbidden, + message, + details, + }: { + code?: API_ERROR_CODES; + message: string; + details?: Record; + }) { + super(ERROR_CODES.Forbidden, code, message, details); + } +} + export class UnexpectedError extends CustomError { constructor(code: API_ERROR_CODES, message: string) { super(ERROR_CODES.UnexpectedError, code, message); diff --git a/src/js/helpers/index.unit.ts b/src/js/helpers/index.unit.ts index f93611e6..c70beb27 100644 --- a/src/js/helpers/index.unit.ts +++ b/src/js/helpers/index.unit.ts @@ -9,7 +9,7 @@ describe('helpers tests', () => { { id: 1, test: 'test' }, ], [{ id: 1, test: NaN }, { id: 1 }], - [{ id: 1, test: new Date(undefined) }, { id: 1 }], + [{ id: 1, test: new Date('undefined') }, { id: 1 }], ])('%s to be %s', (value, expected) => { expect(removeUndefinedKeys(value)).toStrictEqual(expected); }); diff --git a/src/models/Accounts.model.ts b/src/models/Accounts.model.ts index c2c0797b..26c27234 100644 --- a/src/models/Accounts.model.ts +++ b/src/models/Accounts.model.ts @@ -41,7 +41,7 @@ export interface AccountsAttributes { @Table({ timestamps: false, }) -export default class Accounts extends Model { +export default class Accounts extends Model { @BelongsTo(() => Currencies, { as: 'currency', foreignKey: 'currencyId', diff --git a/src/models/Balances.model.ts b/src/models/Balances.model.ts index d4540c3c..c1b57da0 100644 --- a/src/models/Balances.model.ts +++ b/src/models/Balances.model.ts @@ -12,7 +12,7 @@ interface GetTotalBalanceHistoryPayload { } @Table({ timestamps: true }) -export default class Balances extends Model { +export default class Balances extends Model { @Column({ allowNull: false, primaryKey: true, @@ -163,7 +163,13 @@ export default class Balances extends Model { if (existingRecordForTheDate) { // Store the highest amount existingRecordForTheDate.amount = - existingRecordForTheDate.amount > balance ? existingRecordForTheDate.amount : balance; + existingRecordForTheDate.amount > (balance || 0) + ? existingRecordForTheDate.amount + : (balance as number); + + // existingRecordForTheDate.amount = balance + // ? Math.max(existingRecordForTheDate.amount, balance) + // : existingRecordForTheDate.amount; await existingRecordForTheDate.save(); } else { @@ -224,20 +230,22 @@ export default class Balances extends Model { where: { id: accountId }, }); + // if (account) { // (1) Firstly we now need to create one more record that will represent the // balance before that transaction await this.create({ accountId, date: subDays(new Date(date), 1), - amount: account.initialBalance, + amount: account!.initialBalance, }); // (2) Then we create a record for that transaction await this.create({ accountId, date, - amount: account.initialBalance + amount, + amount: account!.initialBalance + amount, }); + // } } else { // And then create a new record with the amount + latestBalance balanceForTxDate = await this.create({ @@ -253,9 +261,10 @@ export default class Balances extends Model { await balanceForTxDate.save(); } + // if (Balances.sequelize) { // Update the amount of all balances for the account that come after the date await this.update( - { amount: Balances.sequelize.literal(`amount + ${amount}`) }, + { amount: Balances.sequelize!.literal(`amount + ${amount}`) }, { where: { accountId, @@ -265,6 +274,7 @@ export default class Balances extends Model { }, }, ); + // } } static async handleAccountChange({ @@ -287,13 +297,15 @@ export default class Balances extends Model { if (record && prevAccount) { const diff = refInitialBalance - prevAccount.refInitialBalance; + // if (Balances.sequelize) { // Update history for all the records realted to that account await this.update( - { amount: Balances.sequelize.literal(`amount + ${diff}`) }, + { amount: Balances.sequelize!.literal(`amount + ${diff}`) }, { where: { accountId }, }, ); + // } } else { const date = new Date(); date.setHours(0, 0, 0, 0); diff --git a/src/models/Currencies.model.ts b/src/models/Currencies.model.ts index df558a3a..bc3cab52 100644 --- a/src/models/Currencies.model.ts +++ b/src/models/Currencies.model.ts @@ -10,18 +10,15 @@ import { PrimaryKey, BelongsToMany, } from 'sequelize-typescript'; -import { CurrencyModel } from 'shared-types'; import Users from './Users.model'; import UsersCurrencies from './UsersCurrencies.model'; import { ValidationError } from '@js/errors'; import { removeUndefinedKeys } from '@js/helpers'; -interface CurrenciesAttributes extends CurrencyModel {} - @Table({ timestamps: false, }) -export default class Currencies extends Model { +export default class Currencies extends Model { @BelongsToMany(() => Users, { as: 'users', through: () => UsersCurrencies, @@ -77,7 +74,7 @@ export async function getCurrency({ currency?: string; number?: number; code?: string; -}): Promise { +}): Promise { return Currencies.findOne({ where: removeUndefinedKeys({ id, currency, number, code }), include: [{ model: Users }], @@ -117,8 +114,16 @@ export async function getCurrencies({ return Currencies.findAll({ where }); } -export const createCurrency = async ({ code }) => { - const currency = cc.number(code); +export const createCurrency = async ({ code }: { code: number }) => { + const currency = cc.number(String(code)); + + if (!currency) { + return null; + } + + if (!currency) { + throw new ValidationError({ message: `Currency with code {code} is not found.` }); + } const currencyData = { code: currency.code, diff --git a/src/models/RefundTransactions.model.ts b/src/models/RefundTransactions.model.ts index 9f8195be..fc6898d0 100644 --- a/src/models/RefundTransactions.model.ts +++ b/src/models/RefundTransactions.model.ts @@ -40,7 +40,7 @@ export default class RefundTransactions extends Model { // consider that not all user real-life accounts will be present in the system allowNull: true, }) - originalTxId: number | null; + originalTxId: number; @ForeignKey(() => Transactions) @Column({ diff --git a/src/models/Transactions.model.ts b/src/models/Transactions.model.ts index 1a7cba1d..cd83f26b 100644 --- a/src/models/Transactions.model.ts +++ b/src/models/Transactions.model.ts @@ -6,7 +6,7 @@ import { SORT_DIRECTIONS, TransactionModel, } from 'shared-types'; -import { Op } from 'sequelize'; +import { Op, Includeable, WhereOptions } from 'sequelize'; import { Table, BeforeCreate, @@ -44,10 +44,10 @@ const prepareTXInclude = ({ includeAll?: boolean; nestedInclude?: boolean; }) => { - let include = null; + let include: Includeable | Includeable[] | null = null; if (isExist(includeAll)) { - include = { all: true, nested: isExist(nestedInclude) }; + include = { all: true, nested: isExist(nestedInclude) || undefined }; } else { include = []; @@ -98,7 +98,7 @@ export interface TransactionsAttributes { @Table({ timestamps: false, }) -export default class Transactions extends Model { +export default class Transactions extends Model { @Column({ unique: true, allowNull: false, @@ -329,13 +329,13 @@ export default class Transactions extends Model { } } -export const getTransactions = async ({ +export const findWithFilters = async ({ from = 0, limit = 20, accountType, - accountId, + accountIds, userId, - sortDirection = SORT_DIRECTIONS.desc, + order = SORT_DIRECTIONS.desc, includeUser, includeAccount, transactionType, @@ -345,22 +345,30 @@ export const getTransactions = async ({ isRaw = false, excludeTransfer, excludeRefunds, + startDate, + endDate, + amountGte, + amountLte, }: { from: number; - limit: number; - accountType: ACCOUNT_TYPES; - transactionType: string; - accountId: number; + limit?: number; + accountType?: ACCOUNT_TYPES; + transactionType?: TRANSACTION_TYPES; + accountIds?: number[]; userId: number; - sortDirection: SORT_DIRECTIONS; - includeUser: boolean; - includeAccount: boolean; - includeCategory: boolean; - includeAll: boolean; - nestedInclude: boolean; + order?: SORT_DIRECTIONS; + includeUser?: boolean; + includeAccount?: boolean; + includeCategory?: boolean; + includeAll?: boolean; + nestedInclude?: boolean; isRaw: boolean; excludeTransfer?: boolean; excludeRefunds?: boolean; + startDate?: string; + endDate?: string; + amountGte?: number; + amountLte?: number; }) => { const include = prepareTXInclude({ includeUser, @@ -370,21 +378,54 @@ export const getTransactions = async ({ nestedInclude, }); + const whereClause: WhereOptions = { + userId, + ...removeUndefinedKeys({ + accountType, + transactionType, + transferNature: excludeTransfer ? TRANSACTION_TRANSFER_NATURE.not_transfer : undefined, + refundLinked: excludeRefunds ? false : undefined, + }), + }; + + if (accountIds && accountIds.length > 0) { + whereClause.accountId = { + [Op.in]: accountIds, + }; + } + + if (startDate || endDate) { + whereClause.time = {}; + if (startDate && endDate) { + whereClause.time = { + [Op.between]: [new Date(startDate), new Date(endDate)], + }; + } else if (startDate) { + whereClause.time[Op.gte] = new Date(startDate); + } else if (endDate) { + whereClause.time[Op.lte] = new Date(endDate); + } + } + + if (amountGte || amountLte) { + whereClause.amount = {}; + if (amountGte && amountLte) { + whereClause.amount = { + [Op.between]: [amountGte, amountLte], + }; + } else if (amountGte) { + whereClause.amount[Op.gte] = amountGte; + } else if (amountLte) { + whereClause.amount[Op.lte] = amountLte; + } + } + const transactions = await Transactions.findAll({ include, - where: { - userId, - ...removeUndefinedKeys({ - accountType, - accountId, - transactionType, - transferNature: excludeTransfer ? TRANSACTION_TRANSFER_NATURE.not_transfer : undefined, - refundLinked: excludeRefunds ? false : undefined, - }), - }, + where: whereClause, offset: from, limit: limit, - order: [['time', sortDirection]], + order: [['time', order]], raw: isRaw, }); @@ -517,12 +558,10 @@ type CreateTxRequiredParams = Pick< TransactionsAttributes, | 'amount' | 'refAmount' - | 'time' | 'userId' | 'transactionType' | 'paymentType' | 'accountId' - | 'categoryId' | 'currencyId' | 'currencyCode' | 'accountType' @@ -532,6 +571,8 @@ type CreateTxOptionalParams = Partial< Pick< TransactionsAttributes, | 'note' + | 'time' + | 'categoryId' | 'refCurrencyCode' | 'transferId' | 'originalId' @@ -558,7 +599,7 @@ export interface UpdateTransactionByIdParams { userId: number; amount?: number; refAmount?: number; - note?: string; + note?: string | null; time?: Date; transactionType?: TRANSACTION_TYPES; paymentType?: PAYMENT_TYPES; @@ -568,7 +609,7 @@ export interface UpdateTransactionByIdParams { currencyCode?: string; refCurrencyCode?: string; transferNature?: TRANSACTION_TRANSFER_NATURE; - transferId?: string; + transferId?: string | null; refundLinked?: boolean; } @@ -587,7 +628,8 @@ export const updateTransactionById = async ( individualHooks, }); - return getTransactionById({ id, userId }); + // Return transactions exactly like that. Ading `returning: true` causes balances not being updated + return getTransactionById({ id, userId }) as Promise; }; export const updateTransactions = ( @@ -620,6 +662,8 @@ export const updateTransactions = ( export const deleteTransactionById = async ({ id, userId }: { id: number; userId: number }) => { const tx = await getTransactionById({ id, userId }); + if (!tx) return true; + if (tx.accountType !== ACCOUNT_TYPES.system) { throw new ValidationError({ message: "It's not allowed to manually delete external transactions", diff --git a/src/models/UserExchangeRates.model.ts b/src/models/UserExchangeRates.model.ts index 87933ac6..285e4f5c 100644 --- a/src/models/UserExchangeRates.model.ts +++ b/src/models/UserExchangeRates.model.ts @@ -1,14 +1,15 @@ import { Table, Column, Model, ForeignKey } from 'sequelize-typescript'; import { Op } from 'sequelize'; import { UserExchangeRatesModel } from 'shared-types'; -import Currencies, { getCurrencies } from './Currencies.model'; +import * as Currencies from './Currencies.model'; +import * as UsersCurrencies from './UsersCurrencies.model'; import Users from './Users.model'; -import { ValidationError } from '@js/errors'; +import { NotFoundError, ValidationError } from '@js/errors'; type UserExchangeRatesAttributes = Omit; @Table({ timestamps: true }) -export default class UserExchangeRates extends Model { +export default class UserExchangeRates extends Model { @Column({ unique: true, allowNull: false, @@ -21,14 +22,14 @@ export default class UserExchangeRates extends Model Currencies) + @ForeignKey(() => Currencies.default) @Column({ allowNull: false }) baseId: number; @Column({ allowNull: false }) baseCode: string; - @ForeignKey(() => Currencies) + @ForeignKey(() => Currencies.default) @Column({ allowNull: false }) quoteId: number; @@ -93,6 +94,7 @@ export async function getRates({ return UserExchangeRates.findAll({ where, + raw: true, attributes: { exclude: ['userId'] }, }); } @@ -125,8 +127,8 @@ export async function updateRates({ pair?: UpdateExchangeRatePair; pairs?: UpdateExchangeRatePair[]; }): Promise { - const iterations = pairs ?? [pair]; - const returningValues = []; + const iterations = (pairs ?? [pair]) as UpdateExchangeRatePair[]; + const returningValues: UserExchangeRates[] = []; for (const pairItem of iterations) { const foundItem = await UserExchangeRates.findOne({ @@ -135,6 +137,7 @@ export async function updateRates({ baseCode: pairItem.baseCode, quoteCode: pairItem.quoteCode, }, + raw: true, }); if (foundItem) { @@ -152,29 +155,45 @@ export async function updateRates({ }, ); - returningValues.push(updatedItems[0]); + if (updatedItems[0]) returningValues.push(updatedItems[0]); } else { - const currencies = await getCurrencies({ + const currencies = await Currencies.getCurrencies({ codes: [pairItem.baseCode, pairItem.quoteCode], }); + const userCurrencies = await UsersCurrencies.getCurrencies({ + userId, + ids: currencies.map((i) => i.id), + }); + + if (currencies.length !== userCurrencies.length) { + throw new NotFoundError({ + message: + 'Cannot find currencies to update rates for. Make sure wanted currencies are assigned to the user.', + }); + } + const baseCurrency = currencies.find((item) => item.code === pairItem.baseCode); const quoteCurrency = currencies.find((item) => item.code === pairItem.quoteCode); - const res = await UserExchangeRates.create( - { - userId, - rate: pairItem.rate, - baseId: baseCurrency.id, - baseCode: baseCurrency.code, - quoteId: quoteCurrency.id, - quoteCode: quoteCurrency.code, - }, - { - returning: true, - }, - ); + if (baseCurrency && quoteCurrency) { + const res = await UserExchangeRates.create( + { + userId, + rate: pairItem.rate, + baseId: baseCurrency.id, + baseCode: baseCurrency.code, + quoteId: quoteCurrency.id, + quoteCode: quoteCurrency.code, + }, + { + returning: true, + }, + ); - returningValues.push(res); + returningValues.push(res); + } else { + throw new NotFoundError({ message: 'Cannot find currencies to update rates for.' }); + } } } diff --git a/src/models/Users.model.ts b/src/models/Users.model.ts index 40431f92..1b174bf8 100644 --- a/src/models/Users.model.ts +++ b/src/models/Users.model.ts @@ -82,7 +82,7 @@ export const getUsers = async () => { return users; }; -export const getUserById = async ({ id }: { id: number }): Promise => { +export const getUserById = async ({ id }: { id: number }): Promise => { const user = await Users.findOne({ where: { id }, }); @@ -121,7 +121,7 @@ export const getUserByCredentials = async ({ }: { username?: string; email?: string; -}): Promise => { +}): Promise => { const where: Record = {}; if (username) where.username = username; @@ -189,7 +189,7 @@ export const updateUserById = async ({ avatar?: string; totalBalance?: number; defaultCategoryId?: number; -}): Promise => { +}): Promise => { const where = { id }; const updateFields: Record = {}; diff --git a/src/models/UsersCurrencies.model.ts b/src/models/UsersCurrencies.model.ts index 4b680eae..c890d72f 100644 --- a/src/models/UsersCurrencies.model.ts +++ b/src/models/UsersCurrencies.model.ts @@ -1,15 +1,15 @@ import { Op } from 'sequelize'; import { Table, Column, Model, ForeignKey, BelongsTo } from 'sequelize-typescript'; -import { UserCurrencyModel } from 'shared-types'; + import { removeUndefinedKeys } from '@js/helpers'; import Users from './Users.model'; import Currencies from './Currencies.model'; +import { NotFoundError } from '@js/errors'; -interface UserCurrencyAttributes extends UserCurrencyModel {} @Table({ timestamps: false, }) -export default class UsersCurrencies extends Model { +export default class UsersCurrencies extends Model { @BelongsTo(() => Users, { as: 'user', foreignKey: 'userId', @@ -55,14 +55,15 @@ export default class UsersCurrencies extends Model { isDefaultCurrency: boolean; } -export const getCurrencies = ({ userId }: { userId: number }) => { - return UsersCurrencies.findAll({ - where: { userId }, - include: { - model: Currencies, - }, - }); -}; +export async function getCurrencies({ userId, ids }: { userId: number; ids?: number[] }) { + const where: Record = { + userId, + }; + + if (ids) where.currencyId = { [Op.in]: ids }; + + return UsersCurrencies.findAll({ where, include: { model: Currencies } }); +} export const getBaseCurrency = async ({ userId }: { userId: number }) => { const data = (await UsersCurrencies.findOne({ @@ -106,7 +107,7 @@ export const getCurrency: getCurrencyOverload = ({ }) as Promise; }; -export const addCurrency = ({ +export const addCurrency = async ({ userId, currencyId, exchangeRate, @@ -119,6 +120,10 @@ export const addCurrency = ({ liveRateUpdate?: boolean; isDefaultCurrency?: boolean; }) => { + const currency = await Currencies.findByPk(currencyId); + if (!currency) { + throw new NotFoundError({ message: 'Currency with provided id does not exist!' }); + } return UsersCurrencies.create( { userId, diff --git a/src/models/binance/UserSettings.model.ts b/src/models/binance/UserSettings.model.ts index 7a5ba6d7..3518651c 100644 --- a/src/models/binance/UserSettings.model.ts +++ b/src/models/binance/UserSettings.model.ts @@ -1,5 +1,6 @@ import { Table, Column, Model, ForeignKey } from 'sequelize-typescript'; import Users from '../Users.model'; +import { UnwrapArray } from '@common/types'; @Table({ timestamps: false, @@ -46,18 +47,16 @@ export const addSettings = async ({ if (apiKey) settingsData.apiKey = apiKey; if (secretKey) settingsData.secretKey = secretKey; - let userSettings: BinanceUserSettings[] | BinanceUserSettings = await BinanceUserSettings.findOne( - { where: { userId } }, - ); + let userSettings = await BinanceUserSettings.findOne({ where: { userId } }); if (userSettings) { - const result = await BinanceUserSettings.update(settingsData, { + const [, result] = await BinanceUserSettings.update(settingsData, { where: { userId }, returning: true, }); - if (result[1]) { - // eslint-disable-next-line prefer-destructuring - userSettings = result[1]; + + if (result) { + userSettings = result as unknown as UnwrapArray; } } else { userSettings = await BinanceUserSettings.create({ diff --git a/src/models/index.ts b/src/models/index.ts index d0315132..b38cd528 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -27,7 +27,7 @@ const sequelize = new Sequelize({ }, }); -if (['development'].includes(process.env.NODE_ENV)) { +if (process.env.NODE_ENV === 'defelopment') { console.log('DBConfig', DBConfig); } diff --git a/src/models/transactions.ts b/src/models/transactions.ts deleted file mode 100644 index 80d68010..00000000 --- a/src/models/transactions.ts +++ /dev/null @@ -1,19 +0,0 @@ -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; - excludeRefunds?: boolean; -} diff --git a/src/routes/transactions.route.ts b/src/routes/transactions.route.ts index 1022710a..11eb0b2d 100644 --- a/src/routes/transactions.route.ts +++ b/src/routes/transactions.route.ts @@ -1,6 +1,5 @@ import { Router } from 'express'; import { - getTransactions, getTransactionById, getTransactionsByTransferId, createTransaction, @@ -13,6 +12,10 @@ import { } from '@controllers/transactions.controller'; import { createRefund } from '@controllers/transactions.controller/refunds/create-refund'; import { deleteRefund } from '@controllers/transactions.controller/refunds/delete-refund'; +import { + getTransactions, + getTransactionsSchema, +} from '@controllers/transactions.controller/get-transaction'; import { getRefund } from '@controllers/transactions.controller/refunds/get-refund'; import { getRefunds } from '@controllers/transactions.controller/refunds/get-refunds'; import { getRefundsForTransactionById } from '@controllers/transactions.controller/refunds/get-refunds-for-transaction-by-id'; @@ -27,7 +30,7 @@ router.get('/refunds', authenticateJwt, getRefunds); router.post('/refund', authenticateJwt, createRefund); router.delete('/refund', authenticateJwt, deleteRefund); -router.get('/', authenticateJwt, getTransactions); +router.get('/', authenticateJwt, validateEndpoint(getTransactionsSchema), getTransactions); router.get('/:id', authenticateJwt, getTransactionById); router.get('/:id/refunds', authenticateJwt, getRefundsForTransactionById); router.get('/transfer/:transferId', authenticateJwt, getTransactionsByTransferId); diff --git a/src/routes/user.route.ts b/src/routes/user.route.ts index 87719a1b..0a346156 100644 --- a/src/routes/user.route.ts +++ b/src/routes/user.route.ts @@ -3,17 +3,24 @@ import { getUser, getUserCurrencies, getUserBaseCurrency, - addUserCurrencies, editUserCurrency, deleteUserCurrency, setBaseUserCurrency, getCurrenciesExchangeRates, - editUserCurrencyExchangeRate, updateUser, deleteUser, removeUserCurrencyExchangeRate, } from '@controllers/user.controller'; +import { + editCurrencyExchangeRate, + editCurrencyExchangeRateSchema, +} from '@controllers/currencies/edit-currency-exchange-rate'; +import { + addUserCurrencies, + addUserCurrenciesSchema, +} from '@controllers/currencies/add-user-currencies'; import { authenticateJwt } from '@middlewares/passport'; +import { validateEndpoint } from '@middlewares/validations'; const router = Router({}); @@ -25,11 +32,21 @@ router.get('/currencies', authenticateJwt, getUserCurrencies); router.get('/currencies/base', authenticateJwt, getUserBaseCurrency); router.get('/currencies/rates', authenticateJwt, getCurrenciesExchangeRates); -router.post('/currencies', authenticateJwt, addUserCurrencies); +router.post( + '/currencies', + authenticateJwt, + validateEndpoint(addUserCurrenciesSchema), + addUserCurrencies, +); router.post('/currencies/base', authenticateJwt, setBaseUserCurrency); router.put('/currency', authenticateJwt, editUserCurrency); -router.put('/currency/rates', authenticateJwt, editUserCurrencyExchangeRate); +router.put( + '/currency/rates', + authenticateJwt, + validateEndpoint(editCurrencyExchangeRateSchema), + editCurrencyExchangeRate, +); router.delete('/currency', authenticateJwt, deleteUserCurrency); router.delete('/currency/rates', authenticateJwt, removeUserCurrencyExchangeRate); diff --git a/src/services/accounts.service.ts b/src/services/accounts.service.ts index e33116e2..cf8a9cba 100644 --- a/src/services/accounts.service.ts +++ b/src/services/accounts.service.ts @@ -7,13 +7,14 @@ import { MonobankUserModel, ACCOUNT_TYPES, ACCOUNT_CATEGORIES, + API_ERROR_CODES, } from 'shared-types'; import * as Accounts from '@models/Accounts.model'; import * as monobankUsersService from '@services/banks/monobank/users'; import * as Currencies from '@models/Currencies.model'; -import * as userService from '@services/user.service'; +import { addUserCurrencies } from '@services/currencies/add-user-currency'; import { redisClient } from '@root/redis'; -import { NotFoundError } from '@js/errors'; +import { ForbiddenError, NotFoundError, UnexpectedError } from '@js/errors'; import Balances from '@models/Balances.model'; import { calculateRefAmount } from '@services/calculate-ref-amount.service'; import { withTransaction } from './common'; @@ -30,7 +31,7 @@ export const getAccountsByExternalIds = withTransaction( ); export const getAccountById = withTransaction( - async (payload: { id: number; userId: number }): Promise => + async (payload: { id: number; userId: number }): Promise => Accounts.getAccountById(payload), ); @@ -44,19 +45,18 @@ export const createSystemAccountsFromMonobankAccounts = withTransaction( userId: number; monoAccounts: ExternalMonobankClientInfoResponse['accounts']; }) => { - // TODO: wrap createCurrency and createAccount into single transactions const currencyCodes = [...new Set(monoAccounts.map((i) => i.currencyCode))]; - const currencies = await Promise.all( - currencyCodes.map((code) => Currencies.createCurrency({ code })), - ); + const currencies = ( + await Promise.all(currencyCodes.map((code) => Currencies.createCurrency({ code }))) + ).filter(Boolean) as Currencies.default[]; const accountCurrencyCodes = {}; currencies.forEach((item) => { accountCurrencyCodes[item.number] = item.id; }); - await userService.addUserCurrencies( + await addUserCurrencies( currencies.map((item) => ({ userId, currencyId: item.id, @@ -100,36 +100,50 @@ export const pairMonobankAccount = withTransaction( const redisToken = redisKeyFormatter(token); // Otherwise begin user connection - const response: string = await redisClient.get(redisToken); + const response = await redisClient.get(redisToken); let clientInfo: ExternalMonobankClientInfoResponse; if (!response) { // TODO: setup it later // await updateWebhookAxios({ userToken: token }); - const result = await axios({ - method: 'GET', - url: `${hostname}/personal/client-info`, - responseType: 'json', - headers: { - 'X-Token': token, - }, - }); - - if (!result) { - throw new NotFoundError({ - message: - '"token" (Monobank API token) is most likely invalid because we cannot find corresponding user.', + try { + const result = await axios({ + method: 'GET', + url: `${hostname}/personal/client-info`, + responseType: 'json', + headers: { + 'X-Token': token, + }, }); - } - - clientInfo = result.data; - await redisClient - .multi() - .set(redisToken, JSON.stringify(response)) - .expire(redisToken, 60) - .exec(); + if (!result) { + throw new NotFoundError({ + message: + '"token" (Monobank API token) is most likely invalid because we cannot find corresponding user.', + }); + } + + clientInfo = result.data; + + await redisClient + .multi() + .set(redisToken, JSON.stringify(response)) + .expire(redisToken, 60) + .exec(); + } catch (err) { + if (err?.response?.data?.errorDescription === "Unknown 'X-Token'") { + throw new ForbiddenError({ + code: API_ERROR_CODES.monobankTokenInvalid, + message: 'Monobank rejected this token!', + }); + } else { + throw new ForbiddenError({ + code: API_ERROR_CODES.monobankTokenInvalid, + message: 'Token is invalid!', + }); + } + } } else { clientInfo = JSON.parse(response); } @@ -160,7 +174,7 @@ export const pairMonobankAccount = withTransaction( export const createAccount = withTransaction( async ( payload: Omit, - ): Promise => { + ): Promise => { const { userId, creditLimit, currencyId, initialBalance } = payload; const refCreditLimit = await calculateRefAmount({ userId: userId, @@ -194,6 +208,10 @@ export const updateAccount = withTransaction( )) => { const accountData = await Accounts.default.findByPk(id); + if (!accountData) { + throw new NotFoundError({ message: 'Account not found!' }); + } + const currentBalanceIsChanging = payload.currentBalance !== undefined && payload.currentBalance !== accountData.currentBalance; let initialBalance = accountData.initialBalance; @@ -205,7 +223,7 @@ export const updateAccount = withTransaction( * but without creating adjustment transaction, so instead we change both `initialBalance` * and `currentBalance` on the same diff */ - if (currentBalanceIsChanging) { + if (currentBalanceIsChanging && payload.currentBalance !== undefined) { const diff = payload.currentBalance - accountData.currentBalance; const refDiff = await calculateRefAmount({ userId: accountData.userId, @@ -234,6 +252,10 @@ export const updateAccount = withTransaction( ...payload, }); + if (!result) { + throw new UnexpectedError(API_ERROR_CODES.unexpected, 'Account updation is not successful'); + } + await Balances.handleAccountChange({ account: result, prevAccount: accountData }); return result; @@ -319,7 +341,11 @@ export async function updateAccountBalanceForChangedTxImpl({ prevTransactionType = transactionType, // eslint-disable-next-line @typescript-eslint/no-explicit-any }: any): Promise { - const { currentBalance, refCurrentBalance } = await getAccountById({ id: accountId, userId }); + const account = await getAccountById({ id: accountId, userId }); + + if (!account) return undefined; + + const { currentBalance, refCurrentBalance } = account; const newAmount = defineCorrectAmountFromTxType(amount, transactionType); const oldAmount = defineCorrectAmountFromTxType(prevAmount, prevTransactionType); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 46ab13bf..6b180925 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,7 +1,7 @@ import config from 'config'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; -import { API_ERROR_CODES } from 'shared-types'; +import { API_ERROR_CODES, CategoryModel, UserModel } from 'shared-types'; import { connection } from '@models/index'; import * as userService from '@services/user.service'; @@ -55,18 +55,20 @@ export const login = withTransaction( ); export const register = withTransaction( - async ({ username, password }: { username: string; password: string }) => { + async ({ username, password }: { username: string; password: string }): Promise => { try { - // Check if user already exists - let user = await userService.getUserByCredentials({ username }); - if (user) { - throw new ConflictError(API_ERROR_CODES.userExists, 'User already exists!'); + const existingUser = await userService.getUserByCredentials({ username }); + if (existingUser) { + throw new ConflictError({ + code: API_ERROR_CODES.userExists, + message: 'User already exists!', + }); } const salt = bcrypt.genSaltSync(10); // Create user with salted password - user = await userService.createUser({ + let user = await userService.createUser({ username, password: bcrypt.hashSync(password, salt), }); @@ -82,7 +84,7 @@ export const register = withTransaction( { returning: true }, ); - let subcats = []; + let subcats: Omit[] = []; // Loop through categories and make subcats as a raw array of categories // since DB expects that @@ -108,21 +110,26 @@ export const register = withTransaction( await categoriesService.bulkCreate({ data: subcats }); // set defaultCategoryId so the undefined mcc codes will use it - const defaultCategoryId = categories.find( + const defaultCategory = categories.find( (item) => item.name === DEFAULT_CATEGORIES.names.other, - ).id; + ); - if (!defaultCategoryId) { + if (!defaultCategory) { // TODO: return UnexpectedError, but move descriptive message to logger, so users won't see this internal issue throw new UnexpectedError( API_ERROR_CODES.unexpected, "Cannot find 'defaultCategoryId' in the previously create categories.", ); } else { - user = await userService.updateUser({ - defaultCategoryId, - id: user.id, - }); + try { + const updatedUser = await userService.updateUser({ + defaultCategoryId: defaultCategory.id, + id: user.id, + }); + if (updatedUser) user = updatedUser; + } catch (err) { + logger.error(err); + } } return user; diff --git a/src/services/balances.service.e2e.ts b/src/services/balances.service.e2e.ts index 692e6dbc..08653044 100644 --- a/src/services/balances.service.e2e.ts +++ b/src/services/balances.service.e2e.ts @@ -38,14 +38,20 @@ describe('Balances service', () => { }); describe('the balances history table correctly updated when:', () => { - const buildAccount = async ({ accountInitialBalance = 0, currencyCode = null } = {}) => { - let newCurrency: Currencies = undefined; + const buildAccount = async ({ + accountInitialBalance = 0, + currencyCode = null, + }: { + accountInitialBalance?: number; + currencyCode?: string | null; + } = {}) => { + let newCurrency: Currencies | undefined = undefined; let currencyRate = 1; if (currencyCode) { newCurrency = global.MODELS_CURRENCIES.find((item) => item.code === currencyCode); await helpers.addUserCurrencies({ currencyCodes: [currencyCode] }); - currencyRate = (await helpers.getCurrenciesRates({ codes: ['UAH'] }))[0].rate; + currencyRate = (await helpers.getCurrenciesRates({ codes: ['UAH'] }))[0]!.rate; } const account = await helpers.createAccount({ @@ -230,7 +236,7 @@ describe('Balances service', () => { // Send 3 transactions at different days for (const tx of transactionsPayloads) { - const response: Transactions[] = await helpers.createTransaction({ + const response = await helpers.createTransaction({ payload: { ...tx, time: tx.time.toISOString(), @@ -271,7 +277,7 @@ describe('Balances service', () => { { ...expense, amount: 50, time: startOfDay(addDays(new Date(), 2)) }, { ...income, amount: 150, time: new Date() }, ]; - const transactionResults = []; + const transactionResults: Transactions[] = []; for (const tx of transactionsPayloads) { const response = await helpers.createTransaction({ @@ -282,7 +288,7 @@ describe('Balances service', () => { raw: true, }); - transactionResults.push(...response); + if (response) transactionResults.push(...(response as Transactions[])); } const balanceHistory: Balances[] = await callGetBalanceHistory(accountData.id, true); @@ -313,7 +319,7 @@ describe('Balances service', () => { // Update expense transaction await helpers.updateTransaction({ - id: transactionResults[0].id, + id: transactionResults[0]!.id, payload: { amount: 150 }, }); @@ -333,7 +339,7 @@ describe('Balances service', () => { // Update income transaction await helpers.updateTransaction({ - id: transactionResults[1].id, + id: transactionResults[1]!.id, payload: { amount: 350 }, }); @@ -356,7 +362,7 @@ describe('Balances service', () => { const { accountData, transactionResults } = await mockBalanceHistory(); await helpers.updateTransaction({ - id: transactionResults[0].id, + id: transactionResults[0]!.id, payload: { amount: 150, time: startOfDay(subDays(new Date(), 4)).toISOString(), @@ -385,7 +391,7 @@ describe('Balances service', () => { }); await helpers.updateTransaction({ - id: transactionResults[3].id, + id: transactionResults[3]!.id, payload: { amount: 150, time: startOfDay(addDays(new Date(), 5)).toISOString(), diff --git a/src/services/banks/monobank/users.ts b/src/services/banks/monobank/users.ts index 1d26b805..3bb02c3b 100644 --- a/src/services/banks/monobank/users.ts +++ b/src/services/banks/monobank/users.ts @@ -3,25 +3,25 @@ import * as MonobankUsers from '@models/banks/monobank/Users.model'; import { withTransaction } from '@root/services/common'; export const getUserById = withTransaction( - async ({ id }): Promise => MonobankUsers.getById({ id }), + async ({ id }): Promise => MonobankUsers.getById({ id }), ); export const getUserByToken = withTransaction( - async ({ token, userId }: { token: string; userId: number }): Promise => + async ({ token, userId }: { token: string; userId: number }): Promise => MonobankUsers.getUserByToken({ token, userId }), ); export const createUser = withTransaction( - async (payload: MonobankUsers.MonoUserCreationPayload): Promise => + async (payload: MonobankUsers.MonoUserCreationPayload): Promise => MonobankUsers.createUser(payload), ); export const getUserBySystemId = withTransaction( - async ({ systemUserId }: { systemUserId: number }): Promise => + async ({ systemUserId }: { systemUserId: number }): Promise => MonobankUsers.getUserBySystemId({ systemUserId }), ); export const updateUser = withTransaction( - async (payload: MonobankUsers.MonoUserUpdatePayload): Promise => + async (payload: MonobankUsers.MonoUserUpdatePayload): Promise => MonobankUsers.updateUser(payload), ); diff --git a/src/services/calculate-ref-amount.service.ts b/src/services/calculate-ref-amount.service.ts index 322962d3..a7ac1652 100644 --- a/src/services/calculate-ref-amount.service.ts +++ b/src/services/calculate-ref-amount.service.ts @@ -2,6 +2,7 @@ import { logger } from '@js/utils/logger'; import * as UsersCurrencies from '@models/UsersCurrencies.model'; import * as userExchangeRateService from '@services/user-exchange-rate'; import * as Currencies from '@models/Currencies.model'; +import { ValidationError } from '@js/errors'; import { withTransaction } from './common'; /** @@ -31,7 +32,7 @@ async function calculateRefAmountImpl(params: Params): Promise { const { baseId, quoteId, userId, amount } = params; try { - let defaultUserCurrency: Currencies.default; + let defaultUserCurrency: Currencies.default | undefined = undefined; if (!baseCode && baseId) { baseCode = (await Currencies.getCurrency({ id: baseId }))?.code; @@ -42,8 +43,11 @@ async function calculateRefAmountImpl(params: Params): Promise { } if (!quoteCode) { - const { currency } = await UsersCurrencies.getCurrency({ userId, isDefaultCurrency: true }); - defaultUserCurrency = currency; + const result = await UsersCurrencies.getCurrency({ userId, isDefaultCurrency: true }); + if (!result) { + throw new ValidationError({ message: 'Cannot find currency to calculate ref amount!' }); + } + defaultUserCurrency = result.currency; } // If baseCade same as default currency code no need to calculate anything @@ -51,10 +55,17 @@ async function calculateRefAmountImpl(params: Params): Promise { return amount; } + if (!baseCode || (quoteCode === undefined && defaultUserCurrency === undefined)) { + throw new ValidationError({ + message: 'Cannot calculate ref amount', + details: { baseCode, defaultUserCurrency }, + }); + } + const result = await userExchangeRateService.getExchangeRate({ userId, baseCode, - quoteCode: quoteCode || defaultUserCurrency.code, + quoteCode: quoteCode || defaultUserCurrency!.code, }); const rate = result.rate; diff --git a/src/services/categories/create-category.e2e.ts b/src/services/categories/create-category.e2e.ts index 776e700b..012f6731 100644 --- a/src/services/categories/create-category.e2e.ts +++ b/src/services/categories/create-category.e2e.ts @@ -14,23 +14,23 @@ describe('Create custom categories and subcategories', () => { it('should successfully create a custom categories', async () => { const parent = rootCategories[0]; - await helpers.addCustomCategory({ parentId: parent.id, name: CATEGORY_NAME }); + await helpers.addCustomCategory({ parentId: parent!.id, name: CATEGORY_NAME }); const newCategory = (await helpers.getCategoriesList()).find((i) => i.name === CATEGORY_NAME); - expect(newCategory.parentId).toBe(parent.id); + expect(newCategory!.parentId).toBe(parent!.id); }); it('should successfully create a custom category with color when no parentId', async () => { await helpers.addCustomCategory({ name: CATEGORY_NAME, color: CATEGORY_COLOR }); const newCategory = (await helpers.getCategoriesList()).find((i) => i.name === CATEGORY_NAME); - expect(newCategory.color).toBe(CATEGORY_COLOR); + expect(newCategory!.color).toBe(CATEGORY_COLOR); }); it('should allow creating duplicate categories', async () => { const parent = rootCategories[0]; - await helpers.addCustomCategory({ parentId: parent.id, name: CATEGORY_NAME }); - await helpers.addCustomCategory({ parentId: parent.id, name: CATEGORY_NAME }); + await helpers.addCustomCategory({ parentId: parent!.id, name: CATEGORY_NAME }); + await helpers.addCustomCategory({ parentId: parent!.id, name: CATEGORY_NAME }); const newCategories = await helpers.getCategoriesList(); @@ -62,11 +62,11 @@ describe('Create custom categories and subcategories', () => { it('should use parent color if not provided for subcategory', async () => { const parent = rootCategories[0]; const newCategory = await helpers.addCustomCategory({ - parentId: parent.id, + parentId: parent!.id, name: CATEGORY_NAME, raw: true, }); - expect(newCategory.color).toEqual(parent.color); + expect(newCategory.color).toEqual(parent!.color); }); }); diff --git a/src/services/categories/edit-category.e2e.ts b/src/services/categories/edit-category.e2e.ts index 37ecbdfd..882f35d7 100644 --- a/src/services/categories/edit-category.e2e.ts +++ b/src/services/categories/edit-category.e2e.ts @@ -31,16 +31,16 @@ describe('Edit custom categories', () => { raw: true, }); - expect(category.name).toBe(updatedCategory.name); - expect(category.color).toBe(updatedCategory.color); - expect(category.imageUrl).toBe(updatedCategory.imageUrl); + expect(category!.name).toBe(updatedCategory.name); + expect(category!.color).toBe(updatedCategory.color); + expect(category!.imageUrl).toBe(updatedCategory.imageUrl); }); it('should successfully edit a sub-category with all fields', async () => { const parent = (await helpers.getCategoriesList())[0]; const subCategory = await helpers.addCustomCategory({ - parentId: parent.id, + parentId: parent!.id, name: mockedCategory.name, color: mockedCategory.color, raw: true, @@ -51,9 +51,9 @@ describe('Edit custom categories', () => { raw: true, }); - expect(category.name).toBe(updatedCategory.name); - expect(category.color).toBe(updatedCategory.color); - expect(category.imageUrl).toBe(updatedCategory.imageUrl); + expect(category!.name).toBe(updatedCategory.name); + expect(category!.color).toBe(updatedCategory.color); + expect(category!.imageUrl).toBe(updatedCategory.imageUrl); }); it('should successfully edit a category with only name', async () => { @@ -63,8 +63,8 @@ describe('Edit custom categories', () => { raw: true, }); - expect(category.name).toBe(updatedCategory.name); - expect(category.color).toBe(mockedCategory.color); + expect(category!.name).toBe(updatedCategory.name); + expect(category!.color).toBe(mockedCategory.color); }); it('should successfully edit a category with only color', async () => { @@ -74,8 +74,8 @@ describe('Edit custom categories', () => { raw: true, }); - expect(category.color).toBe(updatedCategory.color); - expect(category.name).toBe(mockedCategory.name); + expect(category!.color).toBe(updatedCategory.color); + expect(category!.name).toBe(mockedCategory.name); }); it('should return validation error if no fields provided', async () => { diff --git a/src/services/currencies/add-user-currency.e2e.ts b/src/services/currencies/add-user-currency.e2e.ts new file mode 100644 index 00000000..027f74f8 --- /dev/null +++ b/src/services/currencies/add-user-currency.e2e.ts @@ -0,0 +1,116 @@ +import { expect } from '@jest/globals'; +import { ERROR_CODES } from '@js/errors'; +import * as helpers from '@tests/helpers'; + +describe('Add user currencies', () => { + it('should successfully add user currencies', async () => { + const allCurrencies = await helpers.getAllCurrencies(); + const uah = allCurrencies.find((i) => i.code === 'UAH')!; + const eur = allCurrencies.find((i) => i.code === 'EUR')!; + + const currencies = [ + { currencyId: uah.id, exchangeRate: 37, liveRateUpdate: true }, + { currencyId: eur.id, exchangeRate: 0.85, liveRateUpdate: false }, + ]; + + const res = await helpers.updateUserCurrencies({ + currencies, + raw: false, + }); + + expect(res.statusCode).toEqual(200); + + // Verify that addition request returned added currencies + const returnedValues = helpers.extractResponse(res).currencies; + expect(returnedValues.length).toBe(2); + expect(currencies.every((c) => returnedValues.some((i) => i.currencyId === c.currencyId))).toBe( + true, + ); + + const returnedUah = returnedValues.find((c) => c.currencyId === uah.id)!; + const returnedEur = returnedValues.find((c) => c.currencyId === eur.id)!; + + expect(returnedUah.exchangeRate).toBe(37); + expect(returnedUah.liveRateUpdate).toBe(true); + expect(returnedEur.exchangeRate).toBe(0.85); + expect(returnedEur.liveRateUpdate).toBe(false); + }); + + it('should return validation error if invalid currency code is provided', async () => { + const res = await helpers.addUserCurrencies({ currencyIds: [1111111999] }); + + expect(res.statusCode).toEqual(ERROR_CODES.NotFoundError); + }); + + it('should return validation error if exchange rate is negative', async () => { + const allCurrencies = await helpers.getAllCurrencies(); + const uah = allCurrencies.find((i) => i.code === 'UAH')!; + + const res = await helpers.updateUserCurrencies({ + currencies: [{ currencyId: uah.id, exchangeRate: -1 }], + raw: false, + }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should successfully add currencies without optional fields', async () => { + const allCurrencies = await helpers.getAllCurrencies(); + const uah = allCurrencies.find((i) => i.code === 'UAH')!; + + const res = await helpers.updateUserCurrencies({ + currencies: [{ currencyId: uah.id }], + raw: false, + }); + + expect(res.statusCode).toEqual(200); + const returnedValues = helpers.extractResponse(res).currencies; + expect(returnedValues.length).toBe(1); + expect(returnedValues[0]!.currencyId).toBe(uah.id); + expect(returnedValues[0]!.exchangeRate).toBeNull(); + expect(returnedValues[0]!.liveRateUpdate).toBe(false); + }); + + it('should successfully resolve when trying to add duplicate currencies', async () => { + // First, add a currency + const allCurrencies = await helpers.getAllCurrencies(); + const uah = allCurrencies.find((i) => i.code === 'UAH')!; + const currencies = [{ currencyId: uah.id }]; + + await helpers.updateUserCurrencies({ + currencies, + raw: false, + }); + + // Try to add the same currency again + const res = await helpers.updateUserCurrencies({ + currencies, + raw: false, + }); + + expect(res.statusCode).toEqual(200); + expect(helpers.extractResponse(res).alreadyExistingIds).toEqual( + currencies.map((i) => i.currencyId), + ); + }); + + it('should successfully resolve when trying to add a currency same as base currency', async () => { + const currencies = [{ currencyId: global.BASE_CURRENCY.id }]; + + const res = await helpers.updateUserCurrencies({ + currencies, + raw: false, + }); + + expect(res.statusCode).toEqual(200); + }); + + it('should return validation error when currencies array is empty', async () => { + const res = await helpers.updateUserCurrencies({ + currencies: [], + raw: false, + }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); +}); diff --git a/src/services/currencies/add-user-currency.ts b/src/services/currencies/add-user-currency.ts new file mode 100644 index 00000000..a0dcedb7 --- /dev/null +++ b/src/services/currencies/add-user-currency.ts @@ -0,0 +1,36 @@ +import { ValidationError } from '@js/errors'; +import * as UsersCurrencies from '@models/UsersCurrencies.model'; +import { withTransaction } from '../common'; + +export const addUserCurrencies = withTransaction( + async ( + currencies: { + userId: number; + currencyId: number; + exchangeRate?: number; + liveRateUpdate?: boolean; + }[], + ) => { + if (!currencies.length || !currencies[0]) { + throw new ValidationError({ message: 'Currencies list is empty' }); + } + + const existingCurrencies = await UsersCurrencies.getCurrencies({ + userId: currencies[0].userId, + }); + const alreadyExistsIds: number[] = []; + + existingCurrencies.forEach((item) => { + const index = currencies.findIndex((currency) => currency.currencyId === item.currencyId); + + if (index >= 0) { + alreadyExistsIds.push(currencies[index]!.currencyId); + currencies.splice(index, 1); + } + }); + + const result = await Promise.all(currencies.map((item) => UsersCurrencies.addCurrency(item))); + + return { currencies: result, alreadyExistingIds: alreadyExistsIds }; + }, +); diff --git a/src/services/stats/get-balance-history-for-account.ts b/src/services/stats/get-balance-history-for-account.ts index 1a0268ca..57b6bf67 100644 --- a/src/services/stats/get-balance-history-for-account.ts +++ b/src/services/stats/get-balance-history-for-account.ts @@ -54,11 +54,11 @@ export const getBalanceHistoryForAccount = async ({ data = balancesInRange; if (!balancesInRange.length) { - let balanceRecord: BalanceModel; + let balanceRecord: BalanceModel | undefined = undefined; if (from) { // Check for records before "from" date - balanceRecord = await Balances.default.findOne({ + balanceRecord = (await Balances.default.findOne({ where: { date: { [Op.lt]: new Date(from), @@ -68,13 +68,13 @@ export const getBalanceHistoryForAccount = async ({ order: [['date', 'DESC']], attributes: dataAttributes, raw: true, - }); + }))!; } if (!balanceRecord && to) { // If no record found before "from" date, check for records after "to" // date with amount > 0 - balanceRecord = await Balances.default.findOne({ + balanceRecord = (await Balances.default.findOne({ where: { accountId, date: { @@ -87,18 +87,19 @@ export const getBalanceHistoryForAccount = async ({ order: [['date', 'ASC']], attributes: dataAttributes, raw: true, - }); + }))!; } - // Combine the results - data = [ - ...data, - // filter(Boolean) to remove any null values - { - ...balanceRecord, - date: new Date(to ?? from ?? new Date()), - }, - ]; + if (balanceRecord) { + // Combine the results + data = [ + ...data, + { + ...balanceRecord, + date: new Date(to ?? from ?? new Date()), + }, + ]; + } } return data; diff --git a/src/services/stats/get-balance-history.ts b/src/services/stats/get-balance-history.ts index a4d55993..f1aa2ef1 100644 --- a/src/services/stats/get-balance-history.ts +++ b/src/services/stats/get-balance-history.ts @@ -57,7 +57,7 @@ export const getBalanceHistory = withTransaction( // first record in the range. This is needed to make sure that we know the // balance for each account for the beginning of the date range const accountIdsInRange = balancesInRange - .filter((item) => item.date === balancesInRange[0].date) + .filter((item) => item.date === balancesInRange[0]!.date) .map((b) => b.accountId); // Fetch all accounts for the user diff --git a/src/services/stats/get-spendings-by-categories/get-spendings-by-categories.e2e.ts b/src/services/stats/get-spendings-by-categories/get-spendings-by-categories.e2e.ts index 566cc78c..3caa0cd6 100644 --- a/src/services/stats/get-spendings-by-categories/get-spendings-by-categories.e2e.ts +++ b/src/services/stats/get-spendings-by-categories/get-spendings-by-categories.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@jest/globals'; -import { TRANSACTION_TRANSFER_NATURE, TRANSACTION_TYPES } from 'shared-types'; +import { CategoryModel, TRANSACTION_TRANSFER_NATURE, TRANSACTION_TYPES } from 'shared-types'; import * as helpers from '@tests/helpers'; describe('[Stats] Spendings by categories', () => { @@ -10,7 +10,7 @@ describe('[Stats] Spendings by categories', () => { amount: 100, }); const categoriesList = await helpers.getCategoriesList(); - const category = categoriesList.find((i) => i.id === payload.categoryId); + const category = categoriesList.find((i) => i.id === payload.categoryId)!; await Promise.all([ helpers.createTransaction({ @@ -51,7 +51,7 @@ describe('[Stats] Spendings by categories', () => { .slice(0, CATEGORIES_AMOUNT_FOR_EACH_NESTING_LEVEL); // Prepare nested 1-level categories - const excludedIds = new Set([]); + const excludedIds = new Set([]); const firstLevelNestedCategories = categoriesList.filter((c) => { if (c.parentId) { if (rootCategories.some((e) => e.id === c.parentId) && !excludedIds.has(c.parentId)) { @@ -74,7 +74,7 @@ describe('[Stats] Spendings by categories', () => { ...firstLevelNestedCategories, customCategory1, customCategory2, - ]; + ].filter(Boolean) as CategoryModel[]; const payload = helpers.buildTransactionPayload({ accountId: account.id, @@ -127,19 +127,19 @@ describe('[Stats] Spendings by categories', () => { // Create two refunds based on income tx: // – in first income is being REFUNDED // – in second income is REFUNDING the existing expense - const tx1 = expenseTransactions.flat().find((t) => t.categoryId === customCategory1.id); - const tx2 = expenseTransactions.flat().find((t) => t.categoryId === customCategory2.id); + const tx1 = expenseTransactions.flat().find((t) => t!.categoryId === customCategory1!.id); + const tx2 = expenseTransactions.flat().find((t) => t!.categoryId === customCategory2!.id); await Promise.all([ helpers.createSingleRefund( { originalTxId: incomeThatWillBeRefunded.id, - refundTxId: tx1.id, + refundTxId: tx1!.id, }, true, ), helpers.createSingleRefund( { - originalTxId: tx2.id, + originalTxId: tx2!.id, refundTxId: incomeThatRefunds.id, }, true, @@ -154,13 +154,13 @@ describe('[Stats] Spendings by categories', () => { // Transfers are ignored expect(spendingsByCategories).toEqual({ '1': { - name: rootCategories[0].name, - color: rootCategories[0].color, + name: rootCategories[0]!.name, + color: rootCategories[0]!.color, amount: 400, }, '2': { - name: rootCategories[1].name, - color: rootCategories[1].color, + name: rootCategories[1]!.name, + color: rootCategories[1]!.color, amount: 500, }, }); @@ -177,6 +177,7 @@ describe('[Stats] Spendings by categories', () => { const newCurrencies: string[] = [global.BASE_CURRENCY_CODE, 'UAH', 'EUR']; await helpers.addUserCurrencies({ currencyCodes: newCurrencies, raw: true }); const userCurrencies = await helpers.getUserCurrencies(); + console.log('userCurrencies', userCurrencies); const [usdCurrency, uahCurrency, eurCurrency] = newCurrencies.map((c) => userCurrencies.find((i) => i.currency.code === c), ); @@ -184,23 +185,23 @@ describe('[Stats] Spendings by categories', () => { // Set fake custom exchange rates so it's easier to calculate them in tests await helpers.editUserCurrencyExchangeRate({ pairs: [ - { baseCode: usdCurrency.currency.code, quoteCode: uahCurrency.currency.code, rate: 10 }, - { baseCode: uahCurrency.currency.code, quoteCode: usdCurrency.currency.code, rate: 0.1 }, - { baseCode: usdCurrency.currency.code, quoteCode: eurCurrency.currency.code, rate: 2 }, - { baseCode: eurCurrency.currency.code, quoteCode: usdCurrency.currency.code, rate: 0.5 }, + { baseCode: usdCurrency!.currency.code, quoteCode: uahCurrency!.currency.code, rate: 10 }, + { baseCode: uahCurrency!.currency.code, quoteCode: usdCurrency!.currency.code, rate: 0.1 }, + { baseCode: usdCurrency!.currency.code, quoteCode: eurCurrency!.currency.code, rate: 2 }, + { baseCode: eurCurrency!.currency.code, quoteCode: usdCurrency!.currency.code, rate: 0.5 }, ], }); const uahAccount = await helpers.createAccount({ payload: { ...helpers.buildAccountPayload(), - currencyId: uahCurrency.currencyId, + currencyId: uahCurrency!.currencyId, }, raw: true, }); const eurAccount = await helpers.createAccount({ payload: { ...helpers.buildAccountPayload(), - currencyId: eurCurrency.currencyId, + currencyId: eurCurrency!.currencyId, }, raw: true, }); @@ -242,14 +243,14 @@ describe('[Stats] Spendings by categories', () => { expect(spendingsByCategories).toEqual({ '1': { - name: rootCategories[0].name, - color: rootCategories[0].color, + name: rootCategories[0]!.name, + color: rootCategories[0]!.color, // 400 (initial) + 400 (expense eur 500 - uah income refund 100) + 1000 (uah expense) amount: 400 + 400 + 1000, }, '2': { - name: rootCategories[1].name, - color: rootCategories[1].color, + name: rootCategories[1]!.name, + color: rootCategories[1]!.color, amount: 500, }, }); diff --git a/src/services/stats/get-spendings-by-categories/index.ts b/src/services/stats/get-spendings-by-categories/index.ts index 7e9fb4eb..94e0b64c 100644 --- a/src/services/stats/get-spendings-by-categories/index.ts +++ b/src/services/stats/get-spendings-by-categories/index.ts @@ -83,9 +83,9 @@ const groupAndAdjustData = async (params: { // In case not found refund transactions in current time period, fetch them separately regardless // of time period. - if (!pair.base) pair.base = await Transactions.findByPk(refund.originalTxId, findByPkParams); + if (!pair.base) pair.base = (await Transactions.findByPk(refund.originalTxId, findByPkParams))!; if (!pair.refund) { - pair.refund = await Transactions.findByPk(refund.refundTxId, findByPkParams); + pair.refund = (await Transactions.findByPk(refund.refundTxId, findByPkParams))!; } // We always need to adjust spendings exactly for expense transactions diff --git a/src/services/transactions/create-transaction.ts b/src/services/transactions/create-transaction.ts index 8f0af477..a36b8cb7 100644 --- a/src/services/transactions/create-transaction.ts +++ b/src/services/transactions/create-transaction.ts @@ -1,9 +1,14 @@ -import { ACCOUNT_TYPES, TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE } from 'shared-types'; +import { + ACCOUNT_TYPES, + TRANSACTION_TYPES, + TRANSACTION_TRANSFER_NATURE, + API_ERROR_CODES, +} from 'shared-types'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '@js/utils/logger'; import { UnwrapPromise } from '@common/types'; -import { ValidationError } from '@js/errors'; +import { UnexpectedError, ValidationError } from '@js/errors'; import * as Transactions from '@models/Transactions.model'; import * as Accounts from '@models/Accounts.model'; @@ -150,7 +155,7 @@ export const createOppositeTransaction = async (params: CreateOppositeTransactio transferId, }); - return { baseTx, oppositeTx }; + return { baseTx, oppositeTx: oppositeTx! }; }; /** @@ -174,34 +179,29 @@ export const createTransaction = withTransaction( }); } - const generalTxParams: Transactions.CreateTransactionPayload = { - ...payload, - amount, - userId, - accountId, - transferNature, - refAmount: amount, - // since we already pass accountId, we don't need currencyId (at least for now) - currencyId: undefined, - currencyCode: undefined, - transferId: undefined, - refCurrencyCode: undefined, - }; - const { currency: defaultUserCurrency } = await UsersCurrencies.getCurrency({ userId, isDefaultCurrency: true, }); - generalTxParams.refCurrencyCode = defaultUserCurrency.code; - const { currency: generalTxCurrency } = await Accounts.getAccountCurrency({ userId, id: accountId, }); - generalTxParams.currencyId = generalTxCurrency.id; - generalTxParams.currencyCode = generalTxCurrency.code; + const generalTxParams: Transactions.CreateTransactionPayload = { + ...payload, + amount, + userId, + accountId, + transferNature, + refAmount: amount, + // since we already pass accountId, we don't need currencyId (at least for now) + currencyId: generalTxCurrency.id, + currencyCode: generalTxCurrency.code, + transferId: undefined, + refCurrencyCode: defaultUserCurrency.code, + }; if (defaultUserCurrency.code !== generalTxCurrency.code) { generalTxParams.refAmount = await calculateRefAmount({ @@ -215,14 +215,14 @@ export const createTransaction = withTransaction( const baseTransaction = await Transactions.createTransaction(generalTxParams); let transactions: [baseTx: Transactions.default, oppositeTx?: Transactions.default] = [ - baseTransaction, + baseTransaction!, ]; if (refundsTxId && transferNature !== TRANSACTION_TRANSFER_NATURE.common_transfer) { await createSingleRefund({ userId, originalTxId: refundsTxId, - refundTxId: baseTransaction.id, + refundTxId: baseTransaction!.id, }); } else if (transferNature === TRANSACTION_TRANSFER_NATURE.common_transfer) { /** @@ -238,13 +238,24 @@ export const createTransaction = withTransaction( * We need to update the existing one, or fail the whole creation if it * doesn't exist */ - const [[baseTx, oppositeTx]] = await linkTransactions({ + const result = await linkTransactions({ userId, - ids: [[baseTransaction.id, destinationTransactionId]], + ids: [[baseTransaction!.id, destinationTransactionId]], ignoreBaseTxTypeValidation: true, }); - - transactions = [baseTx, oppositeTx]; + if (result[0]) { + const [baseTx, oppositeTx] = result[0]; + transactions = [baseTx, oppositeTx]; + } else { + logger.info('Cannot create transaction with provided params', { + ids: [[baseTransaction!.id, destinationTransactionId]], + result, + }); + throw new UnexpectedError( + API_ERROR_CODES.unexpected, + 'Cannot create transaction with provided params', + ); + } } else { const res = await createOppositeTransaction([ { @@ -254,7 +265,7 @@ export const createTransaction = withTransaction( transferNature, ...payload, }, - baseTransaction, + baseTransaction!, ]); transactions = [res.baseTx, res.oppositeTx]; } diff --git a/src/services/transactions/delete-transaction.ts b/src/services/transactions/delete-transaction.ts index 05e82e71..bef2bf24 100644 --- a/src/services/transactions/delete-transaction.ts +++ b/src/services/transactions/delete-transaction.ts @@ -12,11 +12,15 @@ import { withTransaction } from '../common'; export const deleteTransaction = withTransaction( async ({ id, userId }: { id: number; userId: number }): Promise => { try { - const { accountType, transferNature, transferId, refundLinked } = await getTransactionById({ + const result = await getTransactionById({ id, userId, }); + if (!result) return undefined; + + const { accountType, transferNature, transferId, refundLinked } = result; + if (accountType !== ACCOUNT_TYPES.system) { throw new ValidationError({ message: "It's not allowed to manually delete external transactions", @@ -66,6 +70,8 @@ const unlinkRefundTransaction = withTransaction(async (id: number) => { }, }); + if (!refundTx) return undefined; + const transactionIdsToUpdate = [refundTx.refundTxId, refundTx.originalTxId].filter( (i) => Boolean(i) && i !== id, ); diff --git a/src/services/transactions/get-transactions.e2e.ts b/src/services/transactions/get-transactions.e2e.ts new file mode 100644 index 00000000..4eed5fa9 --- /dev/null +++ b/src/services/transactions/get-transactions.e2e.ts @@ -0,0 +1,258 @@ +import { TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE, SORT_DIRECTIONS } from 'shared-types'; +import { subDays, compareAsc, compareDesc } from 'date-fns'; +import * as helpers from '@tests/helpers'; +import { ERROR_CODES } from '@js/errors'; + +const dates = { + income: '2024-08-02T00:00:00Z', + expense: '2024-08-03T00:00:00Z', + transfer: '2024-09-03T00:00:00Z', + refunds: '2024-07-03T00:00:00Z', +}; + +describe('Retrieve transactions with filters', () => { + const createMockTransactions = async () => { + const accountA = await helpers.createAccount({ raw: true }); + const { + currencies: [currencyB], + } = await helpers.addUserCurrencies({ currencyCodes: ['UAH'], raw: true }); + const accountB = await helpers.createAccount({ + payload: { + ...helpers.buildAccountPayload(), + currencyId: currencyB!.id, + }, + raw: true, + }); + + const [income] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + amount: 2000, + transactionType: TRANSACTION_TYPES.income, + time: dates.income, + }), + raw: true, + }); + const [expense] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountB.id, + amount: 2000, + transactionType: TRANSACTION_TYPES.expense, + time: dates.expense, + }), + raw: true, + }); + const [transferIncome, transferExpense] = await helpers.createTransaction({ + payload: { + ...helpers.buildTransactionPayload({ accountId: accountA.id, amount: 5000 }), + transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, + destinationAmount: 10000, + destinationAccountId: accountB.id, + time: dates.transfer, + }, + raw: true, + }); + + const [refundOriginal] = await helpers.createTransaction({ + payload: helpers.buildTransactionPayload({ + accountId: accountA.id, + amount: 1000, + transactionType: TRANSACTION_TYPES.income, + time: dates.refunds, + }), + raw: true, + }); + const refundTxPayload = { + ...helpers.buildTransactionPayload({ + accountId: accountA.id, + amount: 1000, + transactionType: TRANSACTION_TYPES.expense, + time: dates.refunds, + }), + refundsTxId: refundOriginal.id, + }; + const [refundTx] = await helpers.createTransaction({ + payload: refundTxPayload, + raw: true, + }); + + return { income, expense, transferIncome, transferExpense, refundOriginal, refundTx }; + }; + + describe('filtered by dates', () => { + it('[success] for `startDate`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: dates.income, + raw: true, + }); + + expect(res.length).toBe(4); // income, expense, two transfers + }); + it('[success] for `endDate`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + endDate: dates.income, + raw: true, + }); + + expect(res.length).toBe(3); // income, two refunds + }); + it('[success] for date range', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: dates.income, + endDate: dates.expense, + raw: true, + }); + + expect(res.length).toBe(2); // income, expense + }); + it('[success] for date range with the same value', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: dates.income, + endDate: dates.income, + raw: true, + }); + + expect(res.length).toBe(1); // income + }); + it('[success] when `startDate` bigger than `endDate`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + startDate: new Date().toISOString(), + endDate: subDays(new Date(), 1).toISOString(), + raw: true, + }); + + expect(res.length).toBe(0); + }); + }); + + it('should retrieve transactions filtered by transactionType', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + transactionType: TRANSACTION_TYPES.expense, + raw: true, + }); + + expect(res.length).toBe(3); // expense, 1 of transfers, 1 of refunds + expect(res.every((t) => t.transactionType === TRANSACTION_TYPES.expense)).toBe(true); + }); + + it('should retrieve transactions excluding transfers', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + excludeTransfer: true, + raw: true, + }); + + expect(res.length).toBe(4); // income, expense, refunds + expect(res.every((t) => t.transferNature === TRANSACTION_TRANSFER_NATURE.not_transfer)).toBe( + true, + ); + }); + + it('should retrieve transactions excluding refunds', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + excludeRefunds: true, + raw: true, + }); + + expect(res.length).toBe(4); + expect(res.every((t) => t.refundLinked === false)).toBe(true); + }); + + it.each([ + [SORT_DIRECTIONS.desc, compareDesc], + [SORT_DIRECTIONS.asc, compareAsc], + ])('should retrieve transactions sorted by time `%s`', async (direction, comparer) => { + const transactions = Object.values(await createMockTransactions()); + + const res = await helpers.getTransactions({ + order: direction, + raw: true, + }); + + expect(res.length).toBe(6); + expect( + transactions.map((t) => t!.time).sort((a, b) => comparer(new Date(a), new Date(b))), + ).toEqual(res.map((t) => t.time)); + }); + + it('should retrieve transactions filtered by accountIds', async () => { + const { expense } = await createMockTransactions(); + + const res = await helpers.getTransactions({ + accountIds: [expense.accountId], + raw: true, + }); + + expect(res.length).toBe(2); // expense, 1 of transfers + expect(res.every((t) => t.accountId === expense.accountId)).toBe(true); + }); + + describe('filter by amount', () => { + it('`amountLte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountLte: 1000, + raw: true, + }); + + expect(res.length).toBe(2); // refunds + res.forEach((tx) => { + expect(tx.amount).toBeGreaterThanOrEqual(1000); + }); + }); + it('`amountGte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountGte: 5000, + raw: true, + }); + + expect(res.length).toBe(2); // transfers + res.forEach((tx) => { + expect(tx.amount).toBeGreaterThanOrEqual(5000); + }); + }); + it('both `amountLte` and `amountGte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountGte: 2000, + amountLte: 5000, + raw: true, + }); + + expect(res.length).toBe(3); // income, expense, 1 of transfers + res.forEach((tx) => { + expect(tx.amount >= 2000 && tx.amount <= 5000).toBe(true); + }); + }); + + it('fails when `amountLte` bigger than `amountGte`', async () => { + await createMockTransactions(); + + const res = await helpers.getTransactions({ + amountLte: 2000, + amountGte: 5000, + }); + + expect(res.statusCode).toBe(ERROR_CODES.ValidationError); + }); + }); +}); diff --git a/src/services/transactions/get-transactions.ts b/src/services/transactions/get-transactions.ts index 09894aeb..ff69d899 100644 --- a/src/services/transactions/get-transactions.ts +++ b/src/services/transactions/get-transactions.ts @@ -1,9 +1,10 @@ import * as Transactions from '@models/Transactions.model'; -import type { GetTransactionsParams } from '@models/transactions'; import { withTransaction } from '../common'; -export const getTransactions = withTransaction(async (params: GetTransactionsParams) => { - const data = await Transactions.getTransactions(params); +export const getTransactions = withTransaction( + async (params: Omit[0], 'isRaw'>) => { + const data = await Transactions.findWithFilters({ ...params, isRaw: true }); - return data; -}); + return data; + }, +); diff --git a/src/services/transactions/transactions-linking/link-transactions.ts b/src/services/transactions/transactions-linking/link-transactions.ts index f30498ee..61bc8691 100644 --- a/src/services/transactions/transactions-linking/link-transactions.ts +++ b/src/services/transactions/transactions-linking/link-transactions.ts @@ -43,6 +43,7 @@ const validateTransactionLinking = ({ } }; +type ResultStruct = [baseTx: Transactions.default, oppositeTx: Transactions.default]; export const linkTransactions = withTransaction( async ({ userId, @@ -54,10 +55,10 @@ export const linkTransactions = withTransaction( ignoreBaseTxTypeValidation?: boolean; }): Promise<[baseTx: Transactions.default, oppositeTx: Transactions.default][]> => { try { - const result: [baseTx: Transactions.default, oppositeTx: Transactions.default][] = []; + const result: ResultStruct[] = []; for (const [baseTxId, oppositeTxId] of ids) { - let transactions = await Transactions.getTransactionsByArrayOfField({ + const transactions = await Transactions.getTransactionsByArrayOfField({ userId, fieldName: 'id', fieldValues: [baseTxId, oppositeTxId], @@ -69,13 +70,24 @@ export const linkTransactions = withTransaction( }); } + const base = transactions.find((tx) => tx.id === baseTxId); + const opposite = transactions.find((tx) => tx.id === oppositeTxId); + + if (!base || !opposite) { + logger.info('Cannot find base or opposite transactions', { + base, + opposite, + }); + throw new ValidationError({ message: 'Cannot find base or opposite transactions' }); + } + validateTransactionLinking({ - base: transactions.find((tx) => tx.id === baseTxId), - opposite: transactions.find((tx) => tx.id === oppositeTxId), + base, + opposite, ignoreBaseTxTypeValidation, }); - await Transactions.default.update( + const [, results] = await Transactions.default.update( { transferId: uuidv4(), transferNature: TRANSACTION_TRANSFER_NATURE.common_transfer, @@ -85,19 +97,14 @@ export const linkTransactions = withTransaction( userId, id: { [Op.in]: [baseTxId, oppositeTxId] }, }, + returning: true, }, ); - transactions = await Transactions.getTransactionsByArrayOfField({ - userId, - fieldName: 'id', - fieldValues: [baseTxId, oppositeTxId], - }); - result.push([ - transactions.find((tx) => tx.id === baseTxId), - transactions.find((tx) => tx.id === oppositeTxId), - ]); + results.find((tx) => tx.id === baseTxId), + results.find((tx) => tx.id === oppositeTxId), + ] as ResultStruct); } return result; diff --git a/src/services/transactions/types.ts b/src/services/transactions/types.ts index 8d8e1197..03e1b7fe 100644 --- a/src/services/transactions/types.ts +++ b/src/services/transactions/types.ts @@ -15,15 +15,15 @@ interface UpdateParams { id: number; userId: number; amount?: number; - note?: string; + note?: string | null; time?: Date; transactionType?: TRANSACTION_TYPES; paymentType?: PAYMENT_TYPES; accountId?: number; categoryId?: number; transferNature?: TRANSACTION_TRANSFER_NATURE; - refundsTxId: number | null; - refundedByTxIds: number[] | null; + refundsTxId?: number | null; + refundedByTxIds?: number[] | null; } interface UpdateTransferParams { diff --git a/src/services/transactions/update-transaction.ts b/src/services/transactions/update-transaction.ts index 4cabdd9c..9efefc64 100644 --- a/src/services/transactions/update-transaction.ts +++ b/src/services/transactions/update-transaction.ts @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; import { ACCOUNT_TYPES, TRANSACTION_TYPES, TRANSACTION_TRANSFER_NATURE } from 'shared-types'; import { logger } from '@js/utils/logger'; -import { ValidationError } from '@js/errors'; +import { NotFoundError, ValidationError } from '@js/errors'; import * as Transactions from '@models/Transactions.model'; import * as UsersCurrencies from '@models/UsersCurrencies.model'; import * as Accounts from '@models/Accounts.model'; @@ -87,7 +87,11 @@ const makeBasicBaseTxUpdation = async ( ? newData.transactionType : prevData.transactionType; - const baseTransactionUpdateParams: Transactions.UpdateTransactionByIdParams = { + const baseTransactionUpdateParams: Transactions.UpdateTransactionByIdParams & { + amount: number; + refAmount: number; + currencyCode: string; + } = { id: newData.id, amount: newData.amount ?? prevData.amount, refAmount: newData.amount ?? prevData.refAmount, @@ -169,7 +173,7 @@ const makeBasicBaseTxUpdation = async ( } await Promise.all( - newData.refundedByTxIds.map((id) => + newData.refundedByTxIds!.map((id) => refundsService.createSingleRefund({ originalTxId: newData.id, refundTxId: id, @@ -231,6 +235,10 @@ const updateTransferTransaction = async (params: HelperFunctionsArgs) => { }) ).find((item) => Number(item.id) !== Number(newData.id)); + if (!oppositeTx) { + throw new NotFoundError({ message: 'Cannot find opposite tx to make an updation' }); + } + let updateOppositeTxParams = removeUndefinedKeys({ id: oppositeTx.id, userId, @@ -265,7 +273,7 @@ const updateTransferTransaction = async (params: HelperFunctionsArgs) => { await calcTransferTransactionRefAmount({ userId, baseTransaction, - destinationAmount: updateOppositeTxParams.amount, + destinationAmount: updateOppositeTxParams.amount!, oppositeTxCurrencyCode: updateOppositeTxParams.currencyCode, }); @@ -347,6 +355,10 @@ export const updateTransaction = withTransaction( try { const prevData = await getTransactionById({ id: payload.id, userId: payload.userId }); + if (!prevData) { + throw new NotFoundError({ message: 'Transaction with provided `id` does not exist!' }); + } + // Validate that passed parameters are not breaking anything validateTransaction(payload, prevData); @@ -373,11 +385,12 @@ export const updateTransaction = withTransaction( updatedTransactions = [baseTx, oppositeTx]; } else if (isCreatingTransfer(payload, prevData)) { if (payload.destinationTransactionId) { - const [[baseTx, oppositeTx]] = await linkTransactions({ + const result = await linkTransactions({ userId: payload.userId, ids: [[updatedTransactions[0].id, payload.destinationTransactionId]], ignoreBaseTxTypeValidation: true, }); + const [baseTx, oppositeTx] = result[0]!; updatedTransactions = [baseTx, oppositeTx]; } else { diff --git a/src/services/tx-refunds/create-single-refund.service.ts b/src/services/tx-refunds/create-single-refund.service.ts index 491d14b8..9cfbfbb1 100644 --- a/src/services/tx-refunds/create-single-refund.service.ts +++ b/src/services/tx-refunds/create-single-refund.service.ts @@ -42,7 +42,7 @@ export const createSingleRefund = withTransaction( try { // Fetch original and refund transactions const [originalTx, refundTx] = await Promise.all([ - Transactions.getTransactionById({ userId, id: originalTxId }), + Transactions.getTransactionById({ userId, id: originalTxId! }), Transactions.getTransactionById({ userId, id: refundTxId }), ]); @@ -116,7 +116,7 @@ export const createSingleRefund = withTransaction( }); } - if (originalTxId) { + if (originalTxId && originalTx) { // Fetch all existing refunds for the original transaction const existingRefunds = await RefundTransactions.default.findAll({ where: { originalTxId, userId }, diff --git a/src/services/user-exchange-rate/get-exchange-rate.service.ts b/src/services/user-exchange-rate/get-exchange-rate.service.ts index fd2f6d9b..c35cb884 100644 --- a/src/services/user-exchange-rate/get-exchange-rate.service.ts +++ b/src/services/user-exchange-rate/get-exchange-rate.service.ts @@ -3,7 +3,7 @@ import * as ExchangeRates from '@models/ExchangeRates.model'; import * as Currencies from '@models/Currencies.model'; // Round to 5 precision -const formatRate = (rate) => Math.trunc(rate * 100000) / 100000; +const formatRate = (rate: number) => Math.trunc(rate * 100000) / 100000; export async function getExchangeRate({ userId, @@ -37,38 +37,36 @@ export async function getExchangeRate({ quoteId?: number; baseCode?: string; quoteCode?: string; -}): Promise { - let pair = { baseCode, quoteCode }; +}): Promise { + let pair = { baseCode, quoteCode } as { baseCode: string; quoteCode: string }; - if (!baseCode && !quoteCode) { + if (!baseCode || !quoteCode) { const { code: base } = await Currencies.getCurrency({ id: Number(baseId) }); const { code: quote } = await Currencies.getCurrency({ id: Number(quoteId) }); pair = { baseCode: base, quoteCode: quote }; } - try { - const [userExchangeRate] = await UserExchangeRates.getRates({ - userId, - pair, - }); - - if (userExchangeRate) { - // Add `custom` so client can understand which rate is custom - return { - ...userExchangeRate, - rate: formatRate(userExchangeRate.rate), - custom: true, - }; - } - - const [exchangeRate] = await ExchangeRates.getRatesForCurrenciesPairs([pair]); + const [userExchangeRate] = await UserExchangeRates.getRates({ + userId, + pair: pair, + }); + if (userExchangeRate) { + // Add `custom` so client can understand which rate is custom return { - ...exchangeRate, - rate: formatRate(exchangeRate.rate), - } as ExchangeRates.default; - } catch (err) { - throw new err(); + ...userExchangeRate, + rate: formatRate(userExchangeRate.rate), + custom: true, + }; } + + const [exchangeRate] = await ExchangeRates.getRatesForCurrenciesPairs([pair]); + + return exchangeRate + ? ({ + ...exchangeRate, + rate: formatRate(exchangeRate.rate), + } as ExchangeRates.default) + : null; } diff --git a/src/services/user-exchange-rate/update-exchange-rates.service.e2e.ts b/src/services/user-exchange-rate/update-exchange-rates.service.e2e.ts new file mode 100644 index 00000000..3279d4dc --- /dev/null +++ b/src/services/user-exchange-rate/update-exchange-rates.service.e2e.ts @@ -0,0 +1,95 @@ +import { expect } from '@jest/globals'; +import * as helpers from '@tests/helpers'; +import { ERROR_CODES } from '@js/errors'; + +describe('Edit currency exchange rate controller', () => { + it('should fail editing currency exchange rates for non-connected currencies', async () => { + const pairs = [ + { baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }, + { baseCode: 'EUR', quoteCode: 'USD', rate: 1.18 }, + ]; + const res = await helpers.editCurrencyExchangeRate({ pairs }); + expect(res.statusCode).toEqual(ERROR_CODES.NotFoundError); + }); + + describe('', () => { + beforeEach(async () => { + // Setup: Ensure the user has the necessary currencies + await helpers.addUserCurrencies({ currencyCodes: ['USD', 'EUR', 'GBP'] }); + }); + + it('should successfully edit currency exchange rates', async () => { + const allCurrencies = await helpers.getAllCurrencies(); + const eur = allCurrencies.find((i) => i.code === 'EUR')!; + + await helpers.makeRequest({ + method: 'post', + url: '/user/currencies', + payload: { + currencies: [{ currencyId: eur.id }], + }, + raw: false, + }); + + const pairs = [ + { baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }, + { baseCode: 'EUR', quoteCode: 'USD', rate: 1.18 }, + ]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(200); + + // Verify that edition request returned edited currencies + const returnedValues = helpers.extractResponse(res); + expect(['USD', 'EUR'].every((code) => returnedValues.map((r) => r.baseCode === code))).toBe( + true, + ); + + const usdEurRate = returnedValues.find( + (rate) => rate.baseCode === 'USD' && rate.quoteCode === 'EUR', + )!; + const eurUsdRate = returnedValues.find( + (rate) => rate.baseCode === 'EUR' && rate.quoteCode === 'USD', + )!; + + expect(usdEurRate.rate).toBeCloseTo(0.85); + expect(eurUsdRate.rate).toBeCloseTo(1.18); + }); + + it('should return validation error if invalid currency code is provided', async () => { + const pairs = [{ baseCode: 'USD', quoteCode: 'INVALID', rate: 1.5 }]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should return error when trying to edit pair with same base and quote currency', async () => { + const pairs = [{ baseCode: 'USD', quoteCode: 'USD', rate: 1 }]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should require opposite pair rate change', async () => { + const pairs = [{ baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should return error when trying to edit non-existent currency pair', async () => { + const pairs = [ + { baseCode: 'USD', quoteCode: 'JPY', rate: 110 }, + { baseCode: 'JPY', quoteCode: 'USD', rate: 0.0091 }, + ]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.NotFoundError); + }); + }); +}); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 0f575f82..27c01459 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,6 +1,6 @@ -import { ACCOUNT_TYPES } from 'shared-types'; +import { ACCOUNT_TYPES, API_ERROR_CODES } from 'shared-types'; -import { ValidationError } from '@js/errors'; +import { UnexpectedError, ValidationError } from '@js/errors'; import * as Users from '@models/Users.model'; import * as Transactions from '@models/Transactions.model'; import * as UsersCurrencies from '@models/UsersCurrencies.model'; @@ -8,6 +8,7 @@ import * as Currencies from '@models/Currencies.model'; import * as ExchangeRates from '@models/ExchangeRates.model'; import * as Accounts from '@models/Accounts.model'; import { withTransaction } from './common'; +import { addUserCurrencies } from './currencies/add-user-currency'; export const getUser = withTransaction(async (id: number) => { const user = await Users.getUserById({ id }); @@ -133,6 +134,10 @@ export const setBaseUserCurrency = withTransaction( { baseCode: currency.code, quoteCode: currency.code }, ]); + if (!exchangeRate) { + throw new ValidationError({ message: 'No exchange rate for current pair!' }); + } + await addUserCurrencies([ { userId, @@ -147,35 +152,6 @@ export const setBaseUserCurrency = withTransaction( }, ); -export const addUserCurrencies = withTransaction( - async ( - currencies: { - userId: number; - currencyId: number; - exchangeRate?: number; - liveRateUpdate?: boolean; - }[], - ) => { - if (!currencies.length) { - throw new ValidationError({ message: 'Currencies list is empty' }); - } - - const existingCurrencies = await UsersCurrencies.getCurrencies({ - userId: currencies[0].userId, - }); - - existingCurrencies.forEach((item) => { - const index = currencies.findIndex((currency) => currency.currencyId === item.currencyId); - - if (index >= 0) currencies.splice(index, 1); - }); - - const result = await Promise.all(currencies.map((item) => UsersCurrencies.addCurrency(item))); - - return result; - }, -); - export const editUserCurrency = withTransaction( async ({ userId, @@ -274,6 +250,13 @@ export const deleteUserCurrency = withTransaction( const defaultCurrency = await UsersCurrencies.getCurrency({ userId, isDefaultCurrency: true }); + if (!defaultCurrency) { + throw new UnexpectedError( + API_ERROR_CODES.unexpected, + 'Cannot delete currency. Default currency is not present in the system', + ); + } + await Transactions.updateTransactions( { currencyId: defaultCurrency.currencyId, diff --git a/src/tests/helpers/account.ts b/src/tests/helpers/account.ts index 3c89f975..63b5f66d 100644 --- a/src/tests/helpers/account.ts +++ b/src/tests/helpers/account.ts @@ -3,6 +3,7 @@ import { ACCOUNT_TYPES, type endpointsTypes, ACCOUNT_CATEGORIES } from 'shared-t import Accounts from '@models/Accounts.model'; import Currencies from '@models/Currencies.model'; import { makeRequest } from './common'; +import { updateAccount as apiUpdateAccount } from '@root/services/accounts.service'; import { addUserCurrencies, getCurrenciesRates } from './currencies'; export const buildAccountPayload = ( @@ -63,26 +64,11 @@ export function createAccount({ payload = buildAccountPayload(), raw = false } = }); } -export function updateAccount({ - id, - payload, - raw, -}: { - id: number; - payload?: Partial; - raw?: false; -}): Promise; -export function updateAccount({ - id, - payload, - raw, -}: { - id: number; - payload?: Partial; - raw?: true; -}): Promise; -export function updateAccount({ id, payload = {}, raw = false }) { - return makeRequest({ +export function updateAccount< + T = Awaited>, + R extends boolean | undefined = undefined, +>({ id, payload = {}, raw }: { id: number; payload?: Partial; raw?: R }) { + return makeRequest({ method: 'put', url: `/accounts/${id}`, payload, diff --git a/src/tests/helpers/common.ts b/src/tests/helpers/common.ts index 522b4f22..b91d2cbb 100644 --- a/src/tests/helpers/common.ts +++ b/src/tests/helpers/common.ts @@ -1,26 +1,53 @@ import config from 'config'; +import { CustomResponse as ExpressCustomResponse } from '@common/types'; import request from 'supertest'; import { app } from '@root/app'; +import { API_ERROR_CODES, API_RESPONSE_STATUS } from '../../../shared-types/api'; const apiPrefix = config.get('apiPrefix'); -interface MakeRequestParams { +interface MakeRequestParams { url: string; method: 'get' | 'post' | 'put' | 'delete'; - payload?: object; + payload?: object | null; headers?: object; - raw?: boolean; + raw?: T; } -export const extractResponse = (response) => response?.body?.response; +export interface CustomResponse extends ExpressCustomResponse { + body: { + code: API_ERROR_CODES; + status: API_RESPONSE_STATUS; + response: T; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const extractResponse = (response: CustomResponse) => response.body.response; + +export type MakeRequestReturn = R extends true + ? T + : CustomResponse; + +export interface ErrorResponse { + message: string; + code: string; +} + +export type UtilizeReturnType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends (...args: any[]) => any, + R extends boolean | undefined, +> = Promise>, R>>; -export async function makeRequest({ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function makeRequest({ url, method, payload = null, headers = {}, raw = false, -}: MakeRequestParams) { +}: MakeRequestParams): Promise> { let tempUrl = url; if (method === 'get') { @@ -33,8 +60,8 @@ export async function makeRequest({ if (Object.keys(headers).length) base.set(headers); if (payload) base.send(payload); - const result = await base; - return raw ? extractResponse(result) : result; + const result: CustomResponse = await base; + return (raw ? extractResponse(result) : result) as MakeRequestReturn; } export const sleep = (time = 1000) => { diff --git a/src/tests/helpers/currencies.ts b/src/tests/helpers/currencies.ts index 23149af4..7173972a 100644 --- a/src/tests/helpers/currencies.ts +++ b/src/tests/helpers/currencies.ts @@ -3,6 +3,7 @@ import ExchangeRates from '@models/ExchangeRates.model'; import Currencies from '@models/Currencies.model'; import UsersCurrencies from '@models/UsersCurrencies.model'; import { UpdateExchangeRatePair } from '@models/UserExchangeRates.model'; +import { addUserCurrencies as apiAddUserCurrencies } from '@root/services/currencies/add-user-currency'; export async function getUserCurrencies(): Promise<(UsersCurrencies & { currency: Currencies })[]> { const data = await makeRequest({ @@ -26,27 +27,16 @@ export async function getCurrenciesRates({ codes }: { codes?: string[] } = {}): return codes ? data.filter((item) => codes.includes(item.baseCode)) : data; } -interface AddUserCurrenciesBaseParams { - currencyIds?: number[]; - currencyCodes?: string[]; - raw?: true | false; -} -export function addUserCurrencies({ - currencyIds, - currencyCodes, - raw, -}: AddUserCurrenciesBaseParams & { raw?: false }): Promise; -export function addUserCurrencies({ - currencyIds, - currencyCodes, - raw, -}: AddUserCurrenciesBaseParams & { raw?: true }): Promise; -export function addUserCurrencies({ +export function addUserCurrencies({ currencyIds = [], currencyCodes = [], - raw = false, -}: AddUserCurrenciesBaseParams = {}) { - return makeRequest({ + raw, +}: { + currencyIds?: number[]; + currencyCodes?: string[]; + raw?: R; +} = {}) { + return makeRequest>, R>({ method: 'post', url: '/user/currencies', payload: { @@ -69,3 +59,26 @@ export function editUserCurrencyExchangeRate({ pairs }: { pairs: UpdateExchangeR raw: true, }); } + +export function getAllCurrencies(): Promise { + return makeRequest({ + method: 'get', + url: '/models/currencies', + raw: true, + }); +} + +export async function updateUserCurrencies({ + currencies, + raw, +}: { + currencies: { currencyId: number; exchangeRate?: number; liveRateUpdate?: boolean }[]; + raw?: R; +}) { + return makeRequest>, R>({ + method: 'post', + url: '/user/currencies', + payload: { currencies }, + raw, + }); +} diff --git a/src/tests/helpers/exchange-rates.ts b/src/tests/helpers/exchange-rates.ts new file mode 100644 index 00000000..2d8e114d --- /dev/null +++ b/src/tests/helpers/exchange-rates.ts @@ -0,0 +1,25 @@ +import { makeRequest } from './common'; +import { editUserExchangeRates } from '@root/services/user-exchange-rate'; + +type ExchangeRatePair = { + baseCode: string; + quoteCode: string; + rate: number; +}; + +export async function editCurrencyExchangeRate({ + pairs, + raw, +}: { + pairs: ExchangeRatePair[]; + raw?: R; +}) { + const result = await makeRequest>, R>({ + method: 'put', + url: '/user/currency/rates', + payload: { pairs }, + raw, + }); + + return result; +} diff --git a/src/tests/helpers/index.ts b/src/tests/helpers/index.ts index 383245e9..d3533d80 100644 --- a/src/tests/helpers/index.ts +++ b/src/tests/helpers/index.ts @@ -8,3 +8,4 @@ export * from './stats'; export * from './categories'; export * from './currencies'; export * from './transactions'; +export * from './exchange-rates'; diff --git a/src/tests/helpers/monobank.ts b/src/tests/helpers/monobank.ts index cadf8a56..ce45122a 100644 --- a/src/tests/helpers/monobank.ts +++ b/src/tests/helpers/monobank.ts @@ -122,7 +122,7 @@ const addTransactions = async ({ amount = 10 }: { amount?: number } = {}): Promi url: '/accounts', }), ); - const account = accounts[1]; + const account = accounts[1]!; const mockedTransactions = helpers.monobank.mockedTransactions(amount, { initialBalance: account.initialBalance, diff --git a/src/tests/helpers/refunds.ts b/src/tests/helpers/refunds.ts index d84bd564..fb5e135f 100644 --- a/src/tests/helpers/refunds.ts +++ b/src/tests/helpers/refunds.ts @@ -2,7 +2,7 @@ import * as helpers from '@tests/helpers'; import type { GetRefundTransactionsParams } from '@services/tx-refunds/get-refunds.service'; export const createSingleRefund = async ( - payload: { originalTxId: number; refundTxId: number }, + payload: { originalTxId: number | null; refundTxId: number }, raw = false, ) => { const result = await helpers.makeRequest({ @@ -15,7 +15,7 @@ export const createSingleRefund = async ( }; export const getSingleRefund = async ( - { originalTxId, refundTxId }: { originalTxId: number; refundTxId: number }, + { originalTxId, refundTxId }: { originalTxId: number | null; refundTxId: number }, raw = false, ) => { const result = await helpers.makeRequest({ @@ -45,7 +45,7 @@ export const getRefundTransactions = async ( }; export const deleteRefund = async ( - payload: { originalTxId: number; refundTxId: number }, + payload: { originalTxId: number | null; refundTxId: number | null }, raw = false, ) => { const result = await helpers.makeRequest({ diff --git a/src/tests/helpers/transactions.ts b/src/tests/helpers/transactions.ts index 14b50eed..5f6a1110 100644 --- a/src/tests/helpers/transactions.ts +++ b/src/tests/helpers/transactions.ts @@ -9,6 +9,7 @@ import { import { CreateTransactionBody } from '../../../shared-types/routes'; import Transactions from '@models/Transactions.model'; import * as transactionsService from '@services/transactions'; +import { getTransactions as apiGetTransactions } from '@services/transactions/get-transactions'; import { makeRequest } from './common'; import { createAccount } from './account'; @@ -41,8 +42,11 @@ export async function createTransaction({ }: CreateTransactionBasePayload & { raw?: true }): Promise< [baseTx: Transactions, oppositeTx?: Transactions] >; -export async function createTransaction({ raw = false, payload = undefined } = {}) { - let txPayload: ReturnType = payload; +export async function createTransaction({ + raw = false, + payload = undefined, +}: CreateTransactionBasePayload & { raw?: boolean } = {}) { + let txPayload: ReturnType | undefined = payload; if (payload === undefined) { const account = await createAccount({ raw: true }); @@ -95,13 +99,14 @@ export function deleteTransaction({ id }: { id?: number } = {}): Promise; -export function getTransactions({ raw }: { raw?: false }): Promise; -export function getTransactions({ raw }: { raw?: true }): Promise; -export function getTransactions({ raw = false } = {}) { - return makeRequest({ +export function getTransactions({ + raw, + ...rest +}: { raw?: R } & Partial[0], 'userId'>> = {}) { + return makeRequest>, R>({ method: 'get', url: '/transactions', + payload: rest, raw, }); } @@ -120,7 +125,13 @@ export function unlinkTransferTransactions({ transferIds: string[]; raw?: true; }): Promise; -export function unlinkTransferTransactions({ raw = false, transferIds = [] } = {}) { +export function unlinkTransferTransactions({ + raw = false, + transferIds = [], +}: { + transferIds: string[]; + raw?: boolean; +}) { return makeRequest({ method: 'put', url: '/transactions/unlink', diff --git a/tsconfig.json b/tsconfig.json index 8d2ec81e..964a76c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ }, "esModuleInterop": true, "skipLibCheck": true, + "strictNullChecks": true, "sourceMap": true, "strict": false, "allowJs": true,