From bc091a1b9b2f67359d7f9616d1b05222d33d4169 Mon Sep 17 00:00:00 2001 From: Dmytro Svyrydenko Date: Thu, 7 Nov 2024 19:10:14 +0100 Subject: [PATCH] chore: intermediate progress --- docker/dev/docker-compose.yml | 2 +- src/services/calculate-ref-amount.service.ts | 2 + .../transactions/create.service.e2e.ts | 174 +++++++++++++++++- .../transactions/create.service.ts | 70 +++++-- .../transactions/delete.service.ts | 6 +- src/tests/helpers/account.ts | 39 ++-- 6 files changed, 253 insertions(+), 40 deletions(-) diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index aebb1a3..52857d4 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -18,7 +18,7 @@ services: - POSTGRES_USER=${APPLICATION_DB_USERNAME} - POSTGRES_PASSWORD=${APPLICATION_DB_PASSWORD} - POSTGRES_DB=${APPLICATION_DB_DATABASE} - ports: ['${APPLICATION_DB_PORT}:5432'] + ports: ['${APPLICATION_DB_PORT}:${APPLICATION_DB_PORT}'] redis: image: redis:6 diff --git a/src/services/calculate-ref-amount.service.ts b/src/services/calculate-ref-amount.service.ts index 9989403..44d808e 100644 --- a/src/services/calculate-ref-amount.service.ts +++ b/src/services/calculate-ref-amount.service.ts @@ -70,6 +70,8 @@ async function calculateRefAmountImpl(params: Params): Promise { }); const rate = result.rate; + console.log('rate', rate); + const isNegative = amount < 0; const refAmount = amount === 0 ? 0 : Math.floor(Math.abs(amount) * rate); diff --git a/src/services/investments/transactions/create.service.e2e.ts b/src/services/investments/transactions/create.service.e2e.ts index 1b4726a..37642a5 100644 --- a/src/services/investments/transactions/create.service.e2e.ts +++ b/src/services/investments/transactions/create.service.e2e.ts @@ -15,7 +15,9 @@ describe('Create investment transaction service', () => { await helpers.syncSecuritiesData(); const securities = await helpers.getSecuritiesList({ raw: true }); const mockedSecurity = securities[0]!; - const account = await helpers.createAccount({ raw: true }); + const account = await helpers.createInvestmentAccount({ + raw: true, + }); const balances = await helpers.makeRequest({ method: 'get', @@ -70,6 +72,8 @@ describe('Create investment transaction service', () => { raw: true, }); + console.log('accountUpdated', accountUpdated); + expect(holdings.length).toBe(1); expect(transactions.length).toBe(1); @@ -106,11 +110,169 @@ describe('Create investment transaction service', () => { { date: format(new Date(), 'yyyy-MM-dd'), amount: expectedAccountBalance }, ]); }); - it.todo( - 'correctly works for non-base currency (ref values are correct for tx, holdings, and account balance)', - ); - it.todo('after creation, the balances table is updated correctly'); - it.todo('after creation, statistics are updated correctly'); + + it.skip('correctly works for non-base currency with multiple securities', async () => { + const currencyUAH = global.MODELS_CURRENCIES.find((item) => item.code === 'UAH'); + // Change base currency to UAH so that we can calculate securities to ref currency + await helpers.makeRequest({ + method: 'post', + url: '/user/currencies/base', + payload: { currencyId: currencyUAH.id }, + }); + + await helpers.syncSecuritiesData(); + // Set up EUR currency and exchange rates + const allCurrencies = await helpers.getAllCurrencies(); + const eur = allCurrencies.find((i) => i.code === 'EUR')!; + const securities = await helpers.getSecuritiesList({ raw: true }); + const security1 = securities[0]!; + const security2 = securities[1]!; + const account = await helpers.createAccount({ + raw: true, + payload: helpers.buildAccountPayload({ currencyId: eur.id }), + }); + + await helpers.makeRequest({ + method: 'post', + url: '/user/currencies', + payload: { currencies: [{ currencyId: eur.id }] }, + raw: false, + }); + await helpers.editCurrencyExchangeRate({ + pairs: [ + { baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }, + { baseCode: 'EUR', quoteCode: 'USD', rate: 1.18 }, + ], + }); + + // Create holdings for both securities + for (const security of [security1, security2]) { + await helpers.createHolding({ + payload: { accountId: account.id, securityId: security.id }, + }); + } + + const transactions = [ + { + securityId: security1.id, + quantity: 10, + price: 25.1, + fees: 0.25, + date: '2024-05-25', + transactionType: TRANSACTION_TYPES.income, + }, + { + securityId: security2.id, + quantity: 5, + price: 50.0, + fees: 0.5, + date: '2024-05-26', + transactionType: TRANSACTION_TYPES.income, + }, + { + securityId: security1.id, + quantity: 3, + price: 27.5, + fees: 0.2, + date: '2024-05-27', + transactionType: TRANSACTION_TYPES.expense, + }, + { + securityId: security2.id, + quantity: 2, + price: 52.0, + fees: 0.3, + date: '2024-05-28', + transactionType: TRANSACTION_TYPES.income, + }, + ]; + + for (const tx of transactions) { + await helpers.createInvestmentTransaction({ + payload: { + ...tx, + accountId: account.id, + date: new Date(tx.date).toISOString(), + }, + }); + } + + const holdings = await helpers.getHoldings({ raw: true }); + const createdTransactions = await helpers.makeRequest({ + method: 'get', + url: '/investing/transactions', + payload: { accountId: account.id }, + raw: true, + }); + // const accountUpdated = await helpers.getAccount({ id: account.id, raw: true }); + + expect(holdings.length).toBe(2); + expect(createdTransactions.length).toBe(transactions.length); + + // Calculate expected values for each security + // const expectedValues = securities.slice(0, 2).map((security) => { + // const securityTxs = transactions.filter((tx) => tx.securityId === security.id); + // const quantity = securityTxs.reduce( + // (sum, tx) => + // tx.transactionType === TRANSACTION_TYPES.income ? sum + tx.quantity : sum - tx.quantity, + // 0, + // ); + // const eurValue = securityTxs.reduce((sum, tx) => { + // const txAmount = tx.quantity * tx.price; + // return tx.transactionType === TRANSACTION_TYPES.income ? sum + txAmount : sum - txAmount; + // }, 0); + // const eurFees = securityTxs.reduce((sum, tx) => sum + tx.fees, 0); + // const eurTotalValue = eurValue - eurFees; + // const usdTotalValue = Math.round(eurTotalValue * 1.18 * 100) / 100; + // return { securityId: security.id, quantity, eurTotalValue, usdTotalValue }; + // }); + + // console.log('expectedValues', expectedValues); + // console.log('holdings', holdings); + + const holdingA = holdings.find((h) => h.securityId === security1.id)!; + console.log('holdingA', holdingA); + expect({ + quantity: Number(holdingA.quantity), + costBasis: Number(holdingA.costBasis), + refCostBasis: Number(holdingA.refCostBasis), + }).toStrictEqual({ + quantity: 7, + costBasis: 10 * 25.1 + 0.25 - 3 * 27.5 + 0.2, + refCostBasis: (10 * 25.1 + 0.25) * 1.18 - (3 * 27.5 + 0.2) * 1.18, + }); + // const holdingB = holdings.find((h) => h.securityId === security1.id)!; + + // holdings.forEach((holding) => { + // const expected = expectedValues.filter((v) => v.securityId === holding.securityId)!; + // expect(Number(holding.quantity)).toBe(expected.reduce((a, b) => a + b.quantity, 0)); + // expect(Number(holding.value)).toBeCloseTo( + // expected.reduce((a, b) => a + b.eurTotalValue, 0), + // 2, + // ); + // expect(Number(holding.refValue)).toBeCloseTo( + // expected.reduce((a, b) => a + b.usdTotalValue, 0), + // 2, + // ); + // }); + + // createdTransactions.forEach((tx, index) => { + // const originalTx = transactions[index]!; + // expect(tx.securityId).toBe(originalTx.securityId); + // expect(Number(tx.quantity)).toBe(originalTx.quantity); + // expect(Number(tx.price)).toBe(originalTx.price); + // expect(Number(tx.fees)).toBe(originalTx.fees); + // expect(tx.transactionType).toBe(originalTx.transactionType); + + // const txEurAmount = originalTx.quantity * originalTx.price; + // expect(Number(tx.amount)).toBeCloseTo(txEurAmount, 2); + // expect(Number(tx.refAmount)).toBeCloseTo(txEurAmount * 1.18, 2); + // }); + + // const totalUsdValue = expectedValues.reduce((sum, v) => sum + v.usdTotalValue, 0); + // const expectedAccountBalance = Math.round(totalUsdValue * 100); + // expect(accountUpdated.currentBalance).toBe(expectedAccountBalance); + }); describe('failure cases', () => { it(`throws when trying to create transaction when: diff --git a/src/services/investments/transactions/create.service.ts b/src/services/investments/transactions/create.service.ts index 35a6d80..b273771 100644 --- a/src/services/investments/transactions/create.service.ts +++ b/src/services/investments/transactions/create.service.ts @@ -41,6 +41,12 @@ export const createInvestmentTransaction = withTransaction( }, }); + const initialAccount = (await Accounts.findOne({ + where: { id: params.accountId, userId }, + }))!; + + console.log('initialAccount_balance', initialAccount.currentBalance); + if (!security) { throw new ValidationError({ message: `Security with id ${params.securityId} does not exist.`, @@ -75,6 +81,10 @@ export const createInvestmentTransaction = withTransaction( ? INVESTMENT_TRANSACTION_CATEGORY.buy : INVESTMENT_TRANSACTION_CATEGORY.sell, }; + + // NFLX-USD – security, but base currency is UAH, means that transaction will have + // amount in USD, and refAmount in {baseCurrency} (UAH) + // Investment account strictly has currencyCode as baseCurrency const amount = parseFloat(params.quantity) * parseFloat(params.price); const refAmount = await calculateRefAmount({ amount, @@ -102,23 +112,40 @@ export const createInvestmentTransaction = withTransaction( refFees: String(refFees), }); - const newQuantity = parseFloat(currentHolding.quantity) + parseFloat(params.quantity); - const value = newQuantity * parseFloat(params.price); + console.log('result', result); + + const newQuantity = + result.category === INVESTMENT_TRANSACTION_CATEGORY.buy + ? parseFloat(currentHolding.quantity) + parseFloat(params.quantity) + : parseFloat(currentHolding.quantity) - parseFloat(params.quantity); - const refValue = await calculateRefAmount({ - amount: value, + const newHoldingValue = newQuantity * parseFloat(params.price); + + console.log('newHoldingValue', newHoldingValue); + + const newHoldingRefValue = await calculateRefAmount({ + amount: newHoldingValue, userId, baseCode: security.currencyCode, }); - const newCostBasis: number = - parseFloat(currentHolding.costBasis) + amount + parseFloat(params.fees); - const newRefCostBasis: number = parseFloat(currentHolding.refCostBasis) + refAmount + refFees; + console.log('newHoldingRefValue', newHoldingRefValue); + + const [newCostBasis, newRefCostBasis] = + params.transactionType === TRANSACTION_TYPES.income + ? [ + parseFloat(currentHolding.costBasis) + amount + parseFloat(params.fees), + parseFloat(currentHolding.refCostBasis) + refAmount + refFees, + ] + : [ + parseFloat(currentHolding.costBasis) - amount + parseFloat(params.fees), + parseFloat(currentHolding.refCostBasis) - refAmount + refFees, + ]; - await Holding.update( + const [, updatedHoldings] = await Holding.update( { - value: String(value), - refValue: String(refValue), + value: String(newHoldingValue), + refValue: String(newHoldingRefValue), quantity: String(newQuantity), costBasis: String(newCostBasis), refCostBasis: String(newRefCostBasis), @@ -128,27 +155,38 @@ export const createInvestmentTransaction = withTransaction( accountId: params.accountId, securityId: params.securityId, }, + returning: true, }, ); + const updatedHolding = updatedHoldings[0]!; const currency = await Currencies.findOne({ where: { code: security.currencyCode }, }); const account = (await Accounts.findOne({ - where: { - id: params.accountId, - userId, - }, + where: { id: params.accountId, userId }, }))!; + console.log({ + userId, + accountId: params.accountId, + transactionType: params.transactionType, + // We store amounts in Account as integer, so need to mutiply that by 100 + amount: Math.floor((parseFloat(updatedHolding.costBasis) + amount) * 100), + refAmount: Math.floor((parseFloat(updatedHolding.refCostBasis) + refAmount) * 100), + currencyId: currency!.id, + accountType: account.type, + time: new Date(params.date).toISOString(), + }); + await updateAccountBalanceForChangedTx({ userId, accountId: params.accountId, transactionType: params.transactionType, // We store amounts in Account as integer, so need to mutiply that by 100 - amount: Math.floor((parseFloat(currentHolding.costBasis) + amount) * 100), - refAmount: Math.floor((parseFloat(currentHolding.refCostBasis) + refAmount) * 100), + amount: Math.floor((parseFloat(updatedHolding.costBasis) + amount) * 100), + refAmount: Math.floor((parseFloat(updatedHolding.refCostBasis) + refAmount) * 100), currencyId: currency!.id, accountType: account.type, time: new Date(params.date).toISOString(), diff --git a/src/services/investments/transactions/delete.service.ts b/src/services/investments/transactions/delete.service.ts index e73ffd1..45ce0b4 100644 --- a/src/services/investments/transactions/delete.service.ts +++ b/src/services/investments/transactions/delete.service.ts @@ -67,7 +67,7 @@ export const deleteInvestmentTransaction = withTransaction( parseFloat(currentTx.refAmount) + parseFloat(currentTx.refFees); - const [, [updatedHolding]] = await Holding.update( + const [, updatedHoldings] = await Holding.update( { value: String(newValue), refValue: String(newRefValue), @@ -83,11 +83,13 @@ export const deleteInvestmentTransaction = withTransaction( returning: true, }, ); + // TODO: when holding quantity turns to 0, make 0 all other fields too. + // `costBasis` cannot be positive or negative when `quantity` is 0 + const updatedHolding = updatedHoldings[0]!; // Recalculate account balance // TODO: maybe not "old costBasis - new costBasis", but "old value - new value"? - if (!updatedHolding) return undefined; const account = (await Accounts.findOne({ where: { diff --git a/src/tests/helpers/account.ts b/src/tests/helpers/account.ts index ea8baa4..4ae61ae 100644 --- a/src/tests/helpers/account.ts +++ b/src/tests/helpers/account.ts @@ -3,7 +3,10 @@ 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'; +import { + createAccount as apiCreateAccount, + updateAccount as apiUpdateAccount, +} from '@root/services/accounts'; import { addUserCurrencies, getCurrenciesRates } from './currencies'; export const buildAccountPayload = ( @@ -17,6 +20,12 @@ export const buildAccountPayload = ( creditLimit: 0, ...overrides, }); +export const buildInvestmentAccountPayload = ( + overrides: Partial = {}, +): endpointsTypes.CreateAccountBody => ({ + ...buildAccountPayload(overrides), + accountCategory: ACCOUNT_CATEGORIES.investment, +}); type BuildAccountPayload = ReturnType; export function getAccount({ id, raw }: { id: number; raw: false }): Promise; @@ -40,29 +49,29 @@ export function getAccounts(): Promise { /** * Creates an account. By default for base currency, but any payload can be passed */ -export function createAccount(): Promise; -export function createAccount({ - payload, - raw, -}: { - payload?: BuildAccountPayload; - raw: false; -}): Promise; -export function createAccount({ - payload, +export function createAccount({ + payload = buildAccountPayload(), raw, }: { payload?: BuildAccountPayload; - raw: true; -}): Promise; -export function createAccount({ payload = buildAccountPayload(), raw = false } = {}) { - return makeRequest({ + raw?: R; +}) { + return makeRequest>>, R>({ method: 'post', url: '/accounts', payload, raw, }); } +export function createInvestmentAccount({ + payload = buildInvestmentAccountPayload(), + raw, +}: { + payload?: BuildAccountPayload; + raw?: R; +}) { + return createAccount({ payload, raw }); +} export function updateAccount< T = Awaited>,