diff --git a/test/config/default.json b/test/config/default.json index bc2254b82..226849f7c 100644 --- a/test/config/default.json +++ b/test/config/default.json @@ -1,7 +1,9 @@ { "app": { "port": 31339, - "host": "127.0.0.1" + "host": "127.0.0.1", + "httpRpcTimeout": 600000, + "wsRpcTimeout": 3600000 }, "grenacheClient": { "query": "rest:report:api", diff --git a/test/helpers/mock-data.js b/test/helpers/mock-data.js index dd927b363..8e423591e 100644 --- a/test/helpers/mock-data.js +++ b/test/helpers/mock-data.js @@ -130,7 +130,7 @@ module.exports = new Map([ 'trades', [ [ - 12345, + 100012345, 'tBTCUSD', _ms, 12345, @@ -143,7 +143,33 @@ module.exports = new Map([ 'USD' ], [ - 10012346, + 110012345, + 'tETHUSD', + _ms, + 12345, + 0.12345, + 12345, + null, + null, + false, + -3.0001, + 'USD' + ], + [ + 120012345, + 'tETHUSD', + _ms, + 12345, + -0.011, + 12355, + null, + null, + false, + -3.0001, + 'USD' + ], + [ + 130012345, 'tBTCEUR', _ms, 12345, @@ -156,7 +182,20 @@ module.exports = new Map([ 'BTC' ], [ - 20012347, + 140012345, + 'tBTCEUR', + _ms, + 12345, + -0.011, + 12355, + null, + null, + false, + -0.0001, + 'BTC' + ], + [ + 150012347, 'tBTCGBP', _ms, 12345, @@ -169,7 +208,20 @@ module.exports = new Map([ 'BTC' ], [ - 30012345, + 160012347, + 'tBTCGBP', + _ms, + 12345, + -0.011, + 12355, + null, + null, + false, + -0.0001, + 'BTC' + ], + [ + 170012345, 'tBTCJPY', _ms, 12345, @@ -180,6 +232,58 @@ module.exports = new Map([ false, -0.0001, 'BTC' + ], + [ + 180012345, + 'tBTCJPY', + _ms, + 12345, + -0.011, + 12355, + null, + null, + false, + -0.0001, + 'BTC' + ], + [ + 190012345, + 'tETHBTC', + _ms, + 12345, + 0.01, + 12345, + null, + null, + false, + -0.0001, + 'BTC' + ], + [ + 200012345, + 'tETHBTC', + _ms, + 12345, + -0.01, + 12355, + null, + null, + false, + -0.0001, + 'BTC' + ], + [ + 210012345, + 'tBTCUSD', + _ms, + 12345, + 0.12345, + 12345, + null, + null, + false, + -3.0001, + 'USD' ] ] ], diff --git a/test/test-cases/additional-api-sync-mode-sqlite-test-cases.js b/test/test-cases/additional-api-sync-mode-sqlite-test-cases.js index 768dc5d2e..55be51e4e 100644 --- a/test/test-cases/additional-api-sync-mode-sqlite-test-cases.js +++ b/test/test-cases/additional-api-sync-mode-sqlite-test-cases.js @@ -413,6 +413,82 @@ module.exports = ( } }) + it('it should be successfully performed by the getTransactionTaxReport method, LIFO strategy', async function () { + this.timeout(60000) + + const res = await agent + .post(`${basePath}/json-rpc`) + .type('json') + .send({ + auth, + method: 'getTransactionTaxReport', + params: { + end, + start: start + (45 * 24 * 60 * 60 * 1000), + strategy: 'LIFO' + }, + id: 5 + }) + .expect('Content-Type', /json/) + .expect(200) + + assert.isObject(res.body) + assert.propertyVal(res.body, 'id', 5) + assert.isArray(res.body.result) + assert.isAtLeast(res.body.result.length, 1) + + res.body.result.forEach((item) => { + assert.isObject(item) + assert.containsAllKeys(item, [ + 'asset', + 'amount', + 'mtsAcquired', + 'mtsSold', + 'proceeds', + 'cost', + 'gainOrLoss' + ]) + }) + }) + + it('it should be successfully performed by the getTransactionTaxReport method, FIFO strategy', async function () { + this.timeout(60000) + + const res = await agent + .post(`${basePath}/json-rpc`) + .type('json') + .send({ + auth, + method: 'getTransactionTaxReport', + params: { + end, + start: start + (45 * 24 * 60 * 60 * 1000), + strategy: 'FIFO' + }, + id: 5 + }) + .expect('Content-Type', /json/) + .expect(200) + + assert.isObject(res.body) + assert.propertyVal(res.body, 'id', 5) + assert.isArray(res.body.result) + assert.isAtLeast(res.body.result.length, 1) + + res.body.result.forEach((item) => { + assert.isObject(item) + assert.containsAllKeys(item, [ + 'asset', + 'amount', + 'mtsAcquired', + 'mtsSold', + 'proceeds', + 'cost', + 'gainOrLoss' + ]) + }) + }) + it('it should be successfully performed by the getTradedVolume method', async function () { this.timeout(60000) @@ -1034,6 +1110,33 @@ module.exports = ( ) }) + it('it should be successfully performed by the getTransactionTaxReportFile method', async function () { + this.timeout(60000) + + const procPromise = queueToPromise(params.processorQueue) + const aggrPromise = queueToPromise(params.aggregatorQueue) + + const res = await agent + .post(`${basePath}/json-rpc`) + .type('json') + .send({ + auth, + method: 'getTransactionTaxReportFile', + params: { + isPDFRequired, + end, + start, + strategy: 'LIFO', + email + }, + id: 5 + }) + .expect('Content-Type', /json/) + .expect(200) + + await testMethodOfGettingReportFile(procPromise, aggrPromise, res) + }) + it('it should be successfully performed by the getTradedVolumeFile method', async function () { this.timeout(60000) diff --git a/test/test-cases/api-sync-mode-sqlite-test-cases.js b/test/test-cases/api-sync-mode-sqlite-test-cases.js index ae03d76f5..644b29f5f 100644 --- a/test/test-cases/api-sync-mode-sqlite-test-cases.js +++ b/test/test-cases/api-sync-mode-sqlite-test-cases.js @@ -1808,7 +1808,7 @@ module.exports = ( symbol: ['tBTCUSD', 'tETHUSD'], start: 0, end, - limit: 10 + limit: 2 }, id: 5 }) diff --git a/workers/loc.api/di/app.deps.js b/workers/loc.api/di/app.deps.js index db53474f0..732d19245 100644 --- a/workers/loc.api/di/app.deps.js +++ b/workers/loc.api/di/app.deps.js @@ -97,6 +97,7 @@ const { fullTaxReportCsvWriter } = require('../generate-report-file/csv-writer') const FullTaxReport = require('../sync/full.tax.report') +const TransactionTaxReport = require('../sync/transaction.tax.report') const WeightedAveragesReport = require('../sync/weighted.averages.report') const SqliteDbMigrator = require( '../sync/dao/db-migrations/sqlite.db.migrator' @@ -151,6 +152,7 @@ module.exports = ({ ['_positionsSnapshot', TYPES.PositionsSnapshot], ['_fullSnapshotReport', TYPES.FullSnapshotReport], ['_fullTaxReport', TYPES.FullTaxReport], + ['_transactionTaxReport', TYPES.TransactionTaxReport], ['_tradedVolume', TYPES.TradedVolume], ['_totalFeesReport', TYPES.TotalFeesReport], ['_performingLoan', TYPES.PerformingLoan], @@ -393,6 +395,8 @@ module.exports = ({ ) bind(TYPES.FullTaxReport) .to(FullTaxReport) + bind(TYPES.TransactionTaxReport) + .to(TransactionTaxReport) rebind(TYPES.WeightedAveragesReport) .to(WeightedAveragesReport) rebind(TYPES.ReportFileJobData) diff --git a/workers/loc.api/di/types.js b/workers/loc.api/di/types.js index ab72b5cd5..0f1286839 100644 --- a/workers/loc.api/di/types.js +++ b/workers/loc.api/di/types.js @@ -69,5 +69,6 @@ module.exports = { SyncUserStepData: Symbol.for('SyncUserStepData'), SyncUserStepDataFactory: Symbol.for('SyncUserStepDataFactory'), HTTPRequest: Symbol.for('HTTPRequest'), - SummaryByAsset: Symbol.for('SummaryByAsset') + SummaryByAsset: Symbol.for('SummaryByAsset'), + TransactionTaxReport: Symbol.for('TransactionTaxReport') } diff --git a/workers/loc.api/errors/index.js b/workers/loc.api/errors/index.js index 6cd45c546..36a3a8d04 100644 --- a/workers/loc.api/errors/index.js +++ b/workers/loc.api/errors/index.js @@ -248,6 +248,24 @@ class AuthTokenTTLSettingError extends ArgsParamsError { } } +class CurrencyConversionError extends BaseError { + constructor (data, message = 'ERR_CURRENCY_HAS_NOT_BEEN_CONVERTED_TO_USD') { + super({ data, message }) + } +} + +class CurrencyPairSeparationError extends BaseError { + constructor (data, message = 'ERR_CURRENCY_PAIR_HAS_NOT_BEEN_SEPARATED_CORRECTLY') { + super({ data, message }) + } +} + +class TrxTaxReportGenerationTimeoutError extends BaseError { + constructor (message = 'ERR_TRX_TAX_REPORT_GENERATION_TIMEOUT') { + super(message) + } +} + module.exports = { BaseError, CollSyncPermissionError, @@ -284,5 +302,8 @@ module.exports = { LastSyncedInfoGettingError, SyncInfoUpdatingError, AuthTokenGenerationError, - AuthTokenTTLSettingError + AuthTokenTTLSettingError, + CurrencyConversionError, + CurrencyPairSeparationError, + TrxTaxReportGenerationTimeoutError } diff --git a/workers/loc.api/generate-report-file/report.file.job.data.js b/workers/loc.api/generate-report-file/report.file.job.data.js index 430725f1c..ad545f3a3 100644 --- a/workers/loc.api/generate-report-file/report.file.job.data.js +++ b/workers/loc.api/generate-report-file/report.file.job.data.js @@ -528,6 +528,50 @@ class ReportFileJobData extends BaseReportFileJobData { return jobData } + async getTransactionTaxReportFileJobData ( + args, + uId, + uInfo + ) { + checkParams(args, 'paramsSchemaForTransactionTaxReportFile') + + const { + userId, + userInfo + } = await checkJobAndGetUserData( + this.rService, + uId, + uInfo + ) + + const reportFileArgs = getReportFileArgs(args) + + const jobData = { + userInfo, + userId, + name: 'getTransactionTaxReport', + fileNamesMap: [['getTransactionTaxReport', 'transaction-tax-report']], + args: reportFileArgs, + propNameForPagination: null, + columnsCsv: { + asset: 'DESCRIPTION OF PROPERTY', + amount: 'AMOUNT', + mtsAcquired: 'DATE ACQUIRED', + mtsSold: 'DATE SOLD', + proceeds: 'PROCEEDS', + cost: 'COST', + gainOrLoss: 'GAIN OR LOSS' + }, + formatSettings: { + asset: 'symbol', + mtsAcquired: 'date', + mtsSold: 'date' + } + } + + return jobData + } + async getTradedVolumeFileJobData ( args, uId, diff --git a/workers/loc.api/helpers/index.js b/workers/loc.api/helpers/index.js index 677615245..528f54f6f 100644 --- a/workers/loc.api/helpers/index.js +++ b/workers/loc.api/helpers/index.js @@ -11,7 +11,8 @@ const { pickLowerObjectsNumbers, sumAllObjectsNumbers, pickAllLowerObjectsNumbers, - sumArrayVolumes + sumArrayVolumes, + pushLargeArr } = require('./utils') const { isSubAccountApiKeys, @@ -33,5 +34,6 @@ module.exports = { pickLowerObjectsNumbers, sumAllObjectsNumbers, pickAllLowerObjectsNumbers, - sumArrayVolumes + sumArrayVolumes, + pushLargeArr } diff --git a/workers/loc.api/helpers/schema.js b/workers/loc.api/helpers/schema.js index db81e9197..0059fd710 100644 --- a/workers/loc.api/helpers/schema.js +++ b/workers/loc.api/helpers/schema.js @@ -214,6 +214,25 @@ const paramsSchemaForFullTaxReportApi = { } } +const paramsSchemaForTransactionTaxReportApi = { + type: 'object', + properties: { + end: { + type: 'integer' + }, + start: { + type: 'integer' + }, + strategy: { + type: 'string', + enum: [ + 'FIFO', + 'LIFO' + ] + } + } +} + const paramsSchemaForWinLossApi = { type: 'object', properties: { @@ -412,6 +431,15 @@ const paramsSchemaForFullTaxReportFile = { } } +const paramsSchemaForTransactionTaxReportFile = { + type: 'object', + properties: { + ...cloneDeep(paramsSchemaForTransactionTaxReportApi.properties), + timezone, + dateFormat + } +} + const paramsSchemaForTradedVolumeFile = { type: 'object', properties: { @@ -460,6 +488,7 @@ module.exports = { paramsSchemaForPositionsSnapshotApi, paramsSchemaForFullSnapshotReportApi, paramsSchemaForFullTaxReportApi, + paramsSchemaForTransactionTaxReportApi, paramsSchemaForTradedVolumeApi, paramsSchemaForTotalFeesReportApi, paramsSchemaForPerformingLoanApi, @@ -471,6 +500,7 @@ module.exports = { paramsSchemaForPositionsSnapshotFile, paramsSchemaForFullSnapshotReportFile, paramsSchemaForFullTaxReportFile, + paramsSchemaForTransactionTaxReportFile, paramsSchemaForTradedVolumeFile, paramsSchemaForTotalFeesReportFile, paramsSchemaForPerformingLoanFile, diff --git a/workers/loc.api/helpers/utils.js b/workers/loc.api/helpers/utils.js index 541bb2591..75a8a374e 100644 --- a/workers/loc.api/helpers/utils.js +++ b/workers/loc.api/helpers/utils.js @@ -266,6 +266,12 @@ const sumArrayVolumes = (propName, objects = []) => { }, []) } +const pushLargeArr = (dest, src) => { + for (const item of src) { + dest.push(item) + } +} + module.exports = { checkParamsAuth, tryParseJSON, @@ -276,5 +282,6 @@ module.exports = { pickLowerObjectsNumbers, sumAllObjectsNumbers, pickAllLowerObjectsNumbers, - sumArrayVolumes + sumArrayVolumes, + pushLargeArr } diff --git a/workers/loc.api/service.report.framework.js b/workers/loc.api/service.report.framework.js index 057c195a8..2e0ea6bf7 100644 --- a/workers/loc.api/service.report.framework.js +++ b/workers/loc.api/service.report.framework.js @@ -1409,6 +1409,28 @@ class FrameworkReportService extends ReportService { }, 'getFullTaxReport', args, cb) } + getTransactionTaxReport (space, args, cb) { + return this._privResponder(async () => { + await this._dataConsistencyChecker + .check(this._CHECKER_NAMES.TRANSACTION_TAX_REPORT, args) + + checkParams(args, 'paramsSchemaForTransactionTaxReportApi') + + return this._transactionTaxReport.getTransactionTaxReport(args) + }, 'getTransactionTaxReport', args, cb) + } + + makeTrxTaxReportInBackground (space, args, cb) { + return this._privResponder(async () => { + await this._dataConsistencyChecker + .check(this._CHECKER_NAMES.TRANSACTION_TAX_REPORT, args) + + checkParams(args, 'paramsSchemaForTransactionTaxReportApi') + + return this._transactionTaxReport.makeTrxTaxReportInBackground(args) + }, 'makeTrxTaxReportInBackground', args, cb) + } + getTradedVolume (space, args, cb) { return this._privResponder(async () => { await this._dataConsistencyChecker @@ -1553,6 +1575,20 @@ class FrameworkReportService extends ReportService { }, 'getFullTaxReportFile', args, cb) } + /** + * @deprecated + */ + getTransactionTaxReportCsv (...args) { return this.getTransactionTaxReportFile(...args) } + + getTransactionTaxReportFile (space, args, cb) { + return this._responder(() => { + return this._generateReportFile( + 'getTransactionTaxReportFileJobData', + args + ) + }, 'getTransactionTaxReportFile', args, cb) + } + /** * @deprecated */ diff --git a/workers/loc.api/sync/data.consistency.checker/checker.names.js b/workers/loc.api/sync/data.consistency.checker/checker.names.js index db644c065..f4dff2943 100644 --- a/workers/loc.api/sync/data.consistency.checker/checker.names.js +++ b/workers/loc.api/sync/data.consistency.checker/checker.names.js @@ -7,6 +7,7 @@ module.exports = { POSITIONS_SNAPSHOT: 'getPositionsSnapshot', FULL_SNAPSHOT_REPORT: 'getFullSnapshotReport', FULL_TAX_REPORT: 'getFullTaxReport', + TRANSACTION_TAX_REPORT: 'getTransactionTaxReport', TRADED_VOLUME: 'getTradedVolume', TOTAL_FEES_REPORT: 'getTotalFeesReport', PERFORMING_LOAN: 'getPerformingLoan', diff --git a/workers/loc.api/sync/data.consistency.checker/checkers.js b/workers/loc.api/sync/data.consistency.checker/checkers.js index 041ed2cbf..ff5b666e4 100644 --- a/workers/loc.api/sync/data.consistency.checker/checkers.js +++ b/workers/loc.api/sync/data.consistency.checker/checkers.js @@ -103,6 +103,20 @@ class Checkers { }) } + [CHECKER_NAMES.TRANSACTION_TAX_REPORT] (auth) { + return this.syncCollsManager + .haveCollsBeenSyncedUpToDate({ + auth, + params: { + schema: [ + this.SYNC_API_METHODS.TRADES, + this.SYNC_API_METHODS.LEDGERS, + this.SYNC_API_METHODS.MOVEMENTS + ] + } + }) + } + [CHECKER_NAMES.TRADED_VOLUME] (auth) { return this.syncCollsManager .haveCollsBeenSyncedUpToDate({ diff --git a/workers/loc.api/sync/helpers/get-back-iterable.js b/workers/loc.api/sync/helpers/get-back-iterable.js index 18eee8180..2e31f278b 100644 --- a/workers/loc.api/sync/helpers/get-back-iterable.js +++ b/workers/loc.api/sync/helpers/get-back-iterable.js @@ -5,15 +5,18 @@ module.exports = (array) => { [Symbol.iterator] (areEntriesReturned) { return { index: array.length, + res: { + done: false, + value: undefined + }, next () { this.index -= 1 + this.res.done = this.index < 0 + this.res.value = areEntriesReturned + ? [this.index, array[this.index]] + : array[this.index] - return { - done: this.index < 0, - value: areEntriesReturned - ? [this.index, array[this.index]] - : array[this.index] - } + return this.res } } }, diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/convert-currency-by-symbol.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/convert-currency-by-symbol.spec.js new file mode 100644 index 000000000..d8ff64949 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/convert-currency-by-symbol.spec.js @@ -0,0 +1,143 @@ +'use strict' + +const { assert } = require('chai') + +const convertCurrencyBySymbol = require('../convert-currency-by-symbol') + +const { + getMockedTrxMapByCcy +} = require('./helpers') +const { + testConvertedCurrencyBySymbol +} = require('./test-cases') + +const mockTrades = [ + { + mtsCreate: Date.UTC(2024, 0, 9, 17), + symbol: 'tBTCJPY', + execPrice: 6_041_453 + }, + { + mtsCreate: Date.UTC(2024, 0, 9, 16, 1), + symbol: 'tBTCGBP', + execPrice: 30_550 + }, + + { + mtsCreate: Date.UTC(2024, 0, 8, 16), + symbol: 'tBTCEUR', + execPrice: 33_000 + }, + { + mtsCreate: Date.UTC(2024, 0, 8, 15), + symbol: 'tBTCUSD', + execPrice: 0, + isMovements: true + }, + { + mtsCreate: Date.UTC(2024, 0, 8, 14), + symbol: 'tETHBTC', + execPrice: 0.512 + }, + { + mtsCreate: Date.UTC(2024, 0, 8, 14), + symbol: 'tETHBTC', + execPrice: 0.511 + }, + + { + mtsCreate: Date.UTC(2024, 0, 5, 9), + symbol: 'tBTCUSD', + execPrice: 0, + isMovements: true + }, + + { + mtsCreate: Date.UTC(2024, 0, 5, 7), + symbol: 'tBTCUST', + execPrice: 20_050 + } +] +const mockedPubTrades = [ + { mts: Date.UTC(2024, 0, 9, 17, 1), price: 39_400 }, + { mts: Date.UTC(2024, 0, 9, 16, 50), price: 39_000 }, + { mts: Date.UTC(2024, 0, 9, 16, 1), price: 38_000 }, + + { mts: Date.UTC(2024, 0, 8, 16), price: 35_000 }, + { mts: Date.UTC(2024, 0, 8, 15, 30), price: 22_700 }, + { mts: Date.UTC(2024, 0, 8, 14, 30), price: 22_500 }, + { mts: Date.UTC(2024, 0, 8, 14), price: 22_000 }, + + { mts: Date.UTC(2024, 0, 5, 10), price: 21_300 }, + { mts: Date.UTC(2024, 0, 5, 9), price: 21_000 }, + { mts: Date.UTC(2024, 0, 5, 8), price: 21_100 } +] + +describe('convertCurrencyBySymbol helper for trx tax report', () => { + it('should convert trx BTC to USD using pub trades', function () { + const mockedTrxMapByCcy = getMockedTrxMapByCcy(mockTrades) + const trxData = mockedTrxMapByCcy.get('BTC') + + convertCurrencyBySymbol(trxData, mockedPubTrades) + + assert.isArray(trxData) + assert.lengthOf(trxData, 8) + + testConvertedCurrencyBySymbol(trxData, 0, { + mtsCreate: Date.UTC(2024, 0, 9, 17), + symbol: 'tBTCJPY', + execPrice: 6_041_453, + firstSymbPrice: 39_000, + lastSymbPrice: 39_000 / 6_041_453 + }) + testConvertedCurrencyBySymbol(trxData, 1, { + mtsCreate: Date.UTC(2024, 0, 9, 16, 1), + symbol: 'tBTCGBP', + execPrice: 30_550, + firstSymbPrice: 38_000, + lastSymbPrice: 38_000 / 30_550 + }) + testConvertedCurrencyBySymbol(trxData, 2, { + mtsCreate: Date.UTC(2024, 0, 8, 16), + symbol: 'tBTCEUR', + execPrice: 33_000, + firstSymbPrice: 35_000, + lastSymbPrice: 35_000 / 33_000 + }) + testConvertedCurrencyBySymbol(trxData, 3, { + mtsCreate: Date.UTC(2024, 0, 8, 15), + symbol: 'tBTCUSD', + execPrice: 22_500, + firstSymbPrice: 22_500, + lastSymbPrice: 1 + }) + testConvertedCurrencyBySymbol(trxData, 4, { + mtsCreate: Date.UTC(2024, 0, 8, 14), + symbol: 'tETHBTC', + execPrice: 0.512, + firstSymbPrice: null, + lastSymbPrice: 22_000 + }) + testConvertedCurrencyBySymbol(trxData, 5, { + mtsCreate: Date.UTC(2024, 0, 8, 14), + symbol: 'tETHBTC', + execPrice: 0.511, + firstSymbPrice: null, + lastSymbPrice: 22_000 + }) + testConvertedCurrencyBySymbol(trxData, 6, { + mtsCreate: Date.UTC(2024, 0, 5, 9), + symbol: 'tBTCUSD', + execPrice: 21_000, + firstSymbPrice: 21_000, + lastSymbPrice: 1 + }) + testConvertedCurrencyBySymbol(trxData, 7, { + mtsCreate: Date.UTC(2024, 0, 5, 7), + symbol: 'tBTCUST', + execPrice: 20_050, + firstSymbPrice: 21_100, + lastSymbPrice: null + }) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-pub-trade-chunk-payloads.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-pub-trade-chunk-payloads.spec.js new file mode 100644 index 000000000..9ad8f13cd --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-pub-trade-chunk-payloads.spec.js @@ -0,0 +1,82 @@ +'use strict' + +const { assert } = require('chai') + +const getPubTradeChunkPayloads = require('../get-pub-trade-chunk-payloads') + +const { + testPubTradeChunkPayloads +} = require('./test-cases') + +describe('getPubTradeChunkPayloads helper for trx tax report', () => { + it('should push separate request payload for trxs with mts more than 24h', function () { + const trxData = [ + { mtsCreate: Date.UTC(2024, 10, 11, 12) }, + + { mtsCreate: Date.UTC(2024, 8, 15, 11) }, + { mtsCreate: Date.UTC(2024, 8, 15, 10) }, + + { mtsCreate: Date.UTC(2024, 0, 9, 17) }, + { mtsCreate: Date.UTC(2024, 0, 9, 16, 1) }, + + { mtsCreate: Date.UTC(2024, 0, 8, 16) }, + { mtsCreate: Date.UTC(2024, 0, 8, 15) }, + { mtsCreate: Date.UTC(2024, 0, 8, 14) }, + + { mtsCreate: Date.UTC(2024, 0, 5, 9) } + ].map((trx) => ({ trx })) + const pubTradeChunkPayloads = getPubTradeChunkPayloads('BTC', trxData) + + assert.isArray(pubTradeChunkPayloads) + assert.lengthOf(pubTradeChunkPayloads, 5) + + testPubTradeChunkPayloads(pubTradeChunkPayloads, 0, { + symbol: 'BTC', + end: Date.UTC(2024, 10, 11, 12), + start: null + }) + testPubTradeChunkPayloads(pubTradeChunkPayloads, 1, { + symbol: 'BTC', + end: Date.UTC(2024, 8, 15, 11), + start: Date.UTC(2024, 8, 15, 10) + }) + testPubTradeChunkPayloads(pubTradeChunkPayloads, 2, { + symbol: 'BTC', + end: Date.UTC(2024, 0, 9, 17), + start: Date.UTC(2024, 0, 9, 16, 1) + }) + testPubTradeChunkPayloads(pubTradeChunkPayloads, 3, { + symbol: 'BTC', + end: Date.UTC(2024, 0, 8, 16), + start: Date.UTC(2024, 0, 8, 14) + }) + testPubTradeChunkPayloads(pubTradeChunkPayloads, 4, { + symbol: 'BTC', + end: Date.UTC(2024, 0, 5, 9), + start: null + }) + }) + + it('should not push separate request payload for trxs', function () { + const trxData = [ + { mtsCreate: Date.UTC(2024, 1, 9, 1) }, + { mtsCreate: Date.UTC(2024, 1, 8, 16, 12, 15) }, + { mtsCreate: Date.UTC(2024, 1, 8, 16, 1) }, + { mtsCreate: Date.UTC(2024, 1, 8, 16) }, + { mtsCreate: Date.UTC(2024, 1, 8, 15) }, + { mtsCreate: Date.UTC(2024, 1, 8, 14) }, + { mtsCreate: Date.UTC(2024, 1, 8, 1) }, + { mtsCreate: Date.UTC(2024, 1, 7, 2) } + ].map((trx) => ({ trx })) + const pubTradeChunkPayloads = getPubTradeChunkPayloads('ETH', trxData) + + assert.isArray(pubTradeChunkPayloads) + assert.lengthOf(pubTradeChunkPayloads, 1) + + testPubTradeChunkPayloads(pubTradeChunkPayloads, 0, { + symbol: 'ETH', + end: Date.UTC(2024, 1, 9, 1), + start: Date.UTC(2024, 1, 7, 2) + }) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-pub-trade-chunk.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-pub-trade-chunk.spec.js new file mode 100644 index 000000000..7b45d5eff --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-pub-trade-chunk.spec.js @@ -0,0 +1,117 @@ +'use strict' + +const { assert } = require('chai') + +const getPubTradeChunk = require('../get-pub-trade-chunk') + +const { + getMockedPubTrades +} = require('./helpers') + +describe('getPubTradeChunk helper for trx tax report', () => { + it('should get pub trades for required start and end', async function () { + const params = { + symbol: 'BTC', + start: Date.UTC(2024, 0, 1), + end: Date.UTC(2024, 11, 31) + } + + let nextRequiredEndPoint = params.end + let callAmount = 0 + const requiredCallAmount = 3 + const diff = params.end - params.start + const step = diff / requiredCallAmount + + const pubTrades = await getPubTradeChunk(params, (args) => { + callAmount += 1 + + assert.isString(args.symbol) + assert.equal(args.symbol, 'tBTCUSD') + assert.isNumber(args.start) + assert.equal(args.start, 0) + assert.isNumber(args.end) + assert.equal(args.end, nextRequiredEndPoint) + + const res = getMockedPubTrades({ + lenght: 100, + end: args.end, + start: callAmount < requiredCallAmount + ? Math.trunc(args.end - step) + : params.start, + price: 51_000 + }) + + nextRequiredEndPoint = res[res.length - 1].mts - 1 + + return { res } + }) + + assert.isArray(pubTrades) + assert.lengthOf(pubTrades, 300) + + for (const pubTrade of pubTrades) { + assert.isObject(pubTrade) + assert.isNumber(pubTrade.mts) + assert.isNumber(pubTrade.price) + assert.equal(pubTrade.price, 51_000) + } + + assert.equal(pubTrades[0].mts, params.end) + assert.equal(pubTrades[pubTrades.length - 1].mts, params.start) + }) + + it('should get pub trades for required start and end with empty array in last response', async function () { + const params = { + symbol: 'ETH', + start: Date.UTC(2024, 1, 5), + end: Date.UTC(2024, 10, 25) + } + + let nextRequiredEndPoint = params.end + let callAmount = 0 + const requiredCallAmount = 5 + const diff = params.end - params.start + const step = diff / requiredCallAmount + + const pubTrades = await getPubTradeChunk(params, (args) => { + callAmount += 1 + + assert.isString(args.symbol) + assert.equal(args.symbol, 'tETHUSD') + assert.isNumber(args.start) + assert.equal(args.start, 0) + assert.isNumber(args.end) + assert.equal(args.end, nextRequiredEndPoint) + + if (callAmount === requiredCallAmount) { + return { res: [] } + } + + const res = getMockedPubTrades({ + lenght: 10_000, + end: args.end, + start: callAmount < requiredCallAmount + ? Math.trunc(args.end - step) + : params.start, + price: 33_000 + }) + + nextRequiredEndPoint = res[res.length - 1].mts - 1 + + return { res } + }) + + assert.isArray(pubTrades) + assert.lengthOf(pubTrades, 40_000) + + for (const pubTrade of pubTrades) { + assert.isObject(pubTrade) + assert.isNumber(pubTrade.mts) + assert.isNumber(pubTrade.price) + assert.equal(pubTrade.price, 33_000) + } + + assert.equal(pubTrades[0].mts, params.end) + assert.equal(pubTrades[pubTrades.length - 1].mts, nextRequiredEndPoint + 1) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-trx-map-by-ccy.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-trx-map-by-ccy.spec.js new file mode 100644 index 000000000..c9b18fdfc --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/get-trx-map-by-ccy.spec.js @@ -0,0 +1,116 @@ +'use strict' + +const { assert } = require('chai') + +const getTrxMapByCcy = require('../get-trx-map-by-ccy') + +const { + testTrxMapByCcy +} = require('./test-cases') + +const firstSymbPrice = 12345 +const lastSymbPrice = 54321 + +const mockedTrxs = [ + { + mtsCreate: Date.UTC(2024, 5, 19), + firstSymb: 'BTC', + lastSymb: 'USD' + }, + { + mtsCreate: Date.UTC(2024, 4, 18), + firstSymb: 'UST', + lastSymb: 'EUR' + }, + { + mtsCreate: Date.UTC(2024, 3, 21), + firstSymb: 'ETH', + lastSymb: 'GBP' + }, + { + mtsCreate: Date.UTC(2024, 2, 12), + firstSymb: 'LTC', + lastSymb: 'JPY' + }, + { + mtsCreate: Date.UTC(2024, 1, 2), + firstSymb: 'ETH', + lastSymb: 'BTC' + }, + { + mtsCreate: Date.UTC(2024, 0, 8), + firstSymb: 'UST', + lastSymb: 'USD' + } +].map((trx) => ({ + ...trx, + firstSymbPrice, + lastSymbPrice +})) + +describe('getTrxMapByCcy helper for trx tax report', () => { + it('Get trx map by currency for conversion to USD', function () { + const trxMapByCcy = getTrxMapByCcy(mockedTrxs) + + assert.instanceOf(trxMapByCcy, Map) + assert.lengthOf(trxMapByCcy, 4) + + testTrxMapByCcy(trxMapByCcy, 'BTC', [ + { + mtsCreate: Date.UTC(2024, 5, 19), + isNotFirstSymbForex: true, + isNotLastSymbForex: false, + mainPrice: firstSymbPrice, + secondPrice: lastSymbPrice + }, + { + mtsCreate: Date.UTC(2024, 1, 2), + isNotFirstSymbForex: true, + isNotLastSymbForex: true, + mainPrice: lastSymbPrice, + secondPrice: firstSymbPrice + } + ]) + testTrxMapByCcy(trxMapByCcy, 'UST', [ + { + mtsCreate: Date.UTC(2024, 4, 18), + isNotFirstSymbForex: true, + isNotLastSymbForex: false, + mainPrice: firstSymbPrice, + secondPrice: lastSymbPrice + }, + { + mtsCreate: Date.UTC(2024, 0, 8), + isNotFirstSymbForex: true, + isNotLastSymbForex: false, + mainPrice: firstSymbPrice, + secondPrice: lastSymbPrice + } + ]) + testTrxMapByCcy(trxMapByCcy, 'ETH', [ + { + mtsCreate: Date.UTC(2024, 3, 21), + isNotFirstSymbForex: true, + isNotLastSymbForex: false, + mainPrice: firstSymbPrice, + secondPrice: lastSymbPrice + }, + { + mtsCreate: Date.UTC(2024, 1, 2), + isNotFirstSymbForex: true, + isNotLastSymbForex: true, + mainPrice: firstSymbPrice, + secondPrice: lastSymbPrice + } + ]) + testTrxMapByCcy(trxMapByCcy, 'LTC', [ + { + mtsCreate: Date.UTC(2024, 2, 12), + isNotFirstSymbForex: true, + isNotLastSymbForex: false, + mainPrice: firstSymbPrice, + secondPrice: lastSymbPrice + } + ]) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-movements.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-movements.js new file mode 100644 index 000000000..66c402e50 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-movements.js @@ -0,0 +1,35 @@ +'use strict' + +module.exports = (mockMovements, opts) => { + const missingFields = { + _id: 1, + id: 1, + currencyName: 'CCY_NAME_EXAMPLE', + status: 'COMPLETED', + amountUsd: null, // It's not used here + fee: -0.5, + destinationAddress: '12345qwerty54321', + transactionId: 'qwerty12345ytrewq', + note: 'Mocked movements', + subUserId: null, + user_id: 1 + } + + return mockMovements.map((movement, i) => { + const mtsUpdated = opts?.year + ? new Date(movement.mtsUpdated).setUTCFullYear(opts?.year) + : movement.mtsUpdated + + return { + ...missingFields, + + _id: i + 1, + id: i + 1, + + ...movement, + + mtsStarted: mtsUpdated - 60000, + mtsUpdated + } + }) +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-pub-trades.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-pub-trades.js new file mode 100644 index 000000000..7f74c4174 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-pub-trades.js @@ -0,0 +1,22 @@ +'use strict' + +module.exports = (opts) => { + const { + lenght = 10, + start = Date.UTC(2025, 0, 1), + end = Date.UTC(2025, 11, 31), + price = 55_000 + } = opts ?? {} + const diff = end - start + const step = diff / (lenght - 1) + + return new Array(lenght) + .fill(null) + .map((item, i, arr) => { + const mts = i < arr.length - 1 + ? Math.trunc(end - step * i) + : start + + return { mts, price } + }) +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-trades.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-trades.js new file mode 100644 index 000000000..888584732 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-trades.js @@ -0,0 +1,50 @@ +'use strict' + +const splitSymbolPairs = require( + 'bfx-report/workers/loc.api/helpers/split-symbol-pairs' +) + +module.exports = (mockTrades, opts) => { + const missingFields = { + _id: 1, + id: 1, + orderID: 1, + orderType: 'EXCHANGE LIMIT', + orderPrice: null, + maker: 1, + fee: -0.5, + feeCurrency: 'USD', + subUserId: null, + user_id: 1, + + firstSymb: null, + lastSymb: null, + firstSymbPrice: null, + lastSymbPrice: null + } + + return mockTrades.map((trade, i) => { + const isMovements = opts?.isMovements ?? trade?.isMovements + const [firstSymb, lastSymb] = splitSymbolPairs(trade.symbol) + + return { + ...missingFields, + + isMovements, + _id: i + 1, + id: i + 1, + orderID: i + 1, + orderPrice: trade.execPrice, + firstSymb, + lastSymb, + firstSymbPrice: lastSymb === 'USD' ? trade.execPrice : null, + lastSymbPrice: lastSymb === 'USD' ? 1 : null, + + ...trade, + + mtsCreate: opts?.year + ? new Date(trade.mtsCreate).setUTCFullYear(opts?.year) + : trade.mtsCreate + } + }) +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-trx-map-by-ccy.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-trx-map-by-ccy.js new file mode 100644 index 000000000..2fc1a51ad --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/get-mocked-trx-map-by-ccy.js @@ -0,0 +1,11 @@ +'use strict' + +const getMockedTrades = require('./get-mocked-trades') +const getTrxMapByCcy = require('../../get-trx-map-by-ccy') + +module.exports = (mockTrades, opts) => { + const mockedTrades = getMockedTrades(mockTrades, opts) + const trxMapByCcy = getTrxMapByCcy(mockedTrades) + + return trxMapByCcy +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/index.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/index.js new file mode 100644 index 000000000..a5692cae2 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/index.js @@ -0,0 +1,21 @@ +'use strict' + +const { + mockTradesForNextYear, + mockTrades +} = require('./mock-trades') +const getMockedTrades = require('./get-mocked-trades') +const mockMovements = require('./mock-movements') +const getMockedMovements = require('./get-mocked-movements') +const getMockedTrxMapByCcy = require('./get-mocked-trx-map-by-ccy') +const getMockedPubTrades = require('./get-mocked-pub-trades') + +module.exports = { + mockTradesForNextYear, + mockTrades, + getMockedTrades, + mockMovements, + getMockedMovements, + getMockedTrxMapByCcy, + getMockedPubTrades +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/mock-movements.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/mock-movements.js new file mode 100644 index 000000000..82552bb2f --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/mock-movements.js @@ -0,0 +1,64 @@ +'use strict' + +module.exports = [ + { + currency: 'BTC', + mtsUpdated: Date.UTC(2023, 8, 29), + amount: -0.9 + }, + { + currency: 'USD', + mtsUpdated: Date.UTC(2023, 7, 17), + amount: -123 + }, + { + currency: 'JPY', + mtsUpdated: Date.UTC(2023, 6, 19), + amount: 111.2 + }, + { + currency: 'ETH', + mtsUpdated: Date.UTC(2023, 5, 12), + amount: 19.6 + }, + { + currency: '', + mtsUpdated: Date.UTC(2023, 5, 11), + amount: 19.6 + }, + { + currency: 'LTC', + mtsUpdated: Date.UTC(2023, 5, 10), + amount: 0 + }, + { + currency: 'LTC', + mtsUpdated: null, + amount: 10.2 + }, + { + currency: 'GBP', + mtsUpdated: Date.UTC(2023, 4, 3), + amount: 602 + }, + { + currency: 'BTC', + mtsUpdated: Date.UTC(2023, 3, 21), + amount: 2.4 + }, + { + currency: 'EUR', + mtsUpdated: Date.UTC(2023, 2, 17), + amount: 210 + }, + { + currency: 'UST', + mtsUpdated: Date.UTC(2023, 1, 15), + amount: 301 + }, + { + currency: 'USD', + mtsUpdated: Date.UTC(2023, 0, 11), + amount: 200 + } +] diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/mock-trades.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/mock-trades.js new file mode 100644 index 000000000..9df101bab --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/helpers/mock-trades.js @@ -0,0 +1,113 @@ +'use strict' + +const mockTradesForNextYear = [ + { + symbol: 'tUSTUSD', + mtsCreate: Date.UTC(2024, 3, 27), + execAmount: -200, + execPrice: 0.98 + }, + { + isMovements: true, + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2024, 3, 1), + execAmount: -1, + execPrice: 41_000 + }, + { + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2024, 2, 17), + execAmount: -5, + execPrice: 61_000 + }, + { + isMovements: true, + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2024, 1, 8), + execAmount: -3, + execPrice: 44_000 + }, + { + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2024, 0, 14), + execAmount: 2, + execPrice: 48_000 + } +] +const mockTrades = [ + { + symbol: 'tUSTEUR', + mtsCreate: Date.UTC(2023, 6, 21), + execAmount: -100, + execPrice: 0.9, + firstSymbPrice: 1.05, + lastSymbPrice: 0.95 + }, + { + symbol: 'tETHUST', + mtsCreate: Date.UTC(2023, 5, 11), + execAmount: -1, + execPrice: 2800, + firstSymbPrice: 3_110, + lastSymbPrice: 1.11 + }, + { + symbol: 'tETHBTC', + mtsCreate: Date.UTC(2023, 4, 22), + execAmount: -1, + execPrice: 0.055, + firstSymbPrice: 2_650, + lastSymbPrice: 48_000 + }, + { + symbol: 'tETHUSD', + mtsCreate: Date.UTC(2023, 4, 10), + execAmount: -1, + execPrice: 2_000 + }, + { + symbol: 'tETHUSD', + mtsCreate: Date.UTC(2023, 3, 10), + execAmount: -2, + execPrice: 3_200 + }, + { + isMovements: true, + symbol: 'tETHUSD', + mtsCreate: Date.UTC(2023, 3, 2), + execAmount: -2, + execPrice: 3000 + }, + { + symbol: 'tETHBTC', + mtsCreate: Date.UTC(2023, 2, 23), + execAmount: 10, + execPrice: 0.05, + firstSymbPrice: 2_601, + lastSymbPrice: 50_000 + }, + { + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2023, 2, 3), + execAmount: -2, + execPrice: 33_000 + }, + { + isMovements: true, + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2023, 1, 5), + execAmount: 20, + execPrice: 43_000 + }, + { + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2023, 0, 10), + execAmount: 3, + execPrice: 20_000 + } +] + +module.exports = { + mockTradesForNextYear, + mockTrades +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/look-up-trades.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/look-up-trades.spec.js new file mode 100644 index 000000000..d2b3e749b --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/look-up-trades.spec.js @@ -0,0 +1,404 @@ +'use strict' + +const { assert } = require('chai') + +const lookUpTrades = require('../look-up-trades') +const { + mockTradesForNextYear, + mockTrades, + getMockedTrades +} = require('./helpers') +const { + testBuyTradesWithUnrealizedProfit, + testSaleTradesWithRealizedProfit +} = require('./test-cases') + +describe('lookUpTrades helper for trx tax report', () => { + it('Lookup buy trx with unrealized profit, LIFO strategy', async function () { + const { + buyTradesWithUnrealizedProfit + } = await lookUpTrades( + getMockedTrades(mockTrades), + { + isBackIterativeSaleLookUp: false, + isBackIterativeBuyLookUp: false, + isBuyTradesWithUnrealizedProfitRequired: true, + isNotGainOrLossRequired: true + } + ) + + assert.isArray(buyTradesWithUnrealizedProfit) + assert.equal(buyTradesWithUnrealizedProfit.length, 5) + + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 0, { + isMovements: false, + mtsCreate: Date.UTC(2023, 5, 11), + firstSymb: 'ETH', + lastSymb: 'UST', + execAmount: -1, + execPrice: 2_800, + buyFilledAmount: 100 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 1, { + isMovements: false, + mtsCreate: Date.UTC(2023, 4, 22), + firstSymb: 'ETH', + lastSymb: 'BTC', + execAmount: -1, + execPrice: 0.055, + buyFilledAmount: 0 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 2, { + isMovements: false, + mtsCreate: Date.UTC(2023, 2, 23), + firstSymb: 'ETH', + lastSymb: 'BTC', + execAmount: 10, + execPrice: 0.05, + buyFilledAmount: 7 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 3, { + isMovements: true, + mtsCreate: Date.UTC(2023, 1, 5), + firstSymb: 'BTC', + lastSymb: 'USD', + execAmount: 20, + execPrice: 43_000, + buyFilledAmount: 2.5 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 4, { + isMovements: false, + mtsCreate: Date.UTC(2023, 0, 10), + firstSymb: 'BTC', + lastSymb: 'USD', + execAmount: 3, + execPrice: 20_000, + buyFilledAmount: 0 + }) + }) + + it('Lookup sale trx with realized profit, LIFO strategy', async function () { + const { + saleTradesWithRealizedProfit + } = await lookUpTrades( + getMockedTrades(mockTrades), + { + isBackIterativeSaleLookUp: false, + isBackIterativeBuyLookUp: false, + isBuyTradesWithUnrealizedProfitRequired: false, + isNotGainOrLossRequired: false + } + ) + + assert.isArray(saleTradesWithRealizedProfit) + assert.equal(saleTradesWithRealizedProfit.length, 7) + + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 0, { + asset: 'UST', + amount: 100, + mtsAcquired: Date.UTC(2023, 5, 11), + mtsSold: Date.UTC(2023, 6, 21), + proceeds: 105, + cost: 111, + gainOrLoss: -6 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 1, { + asset: 'ETH', + amount: 1, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 5, 11), + proceeds: 3_110, + cost: 2_601, + gainOrLoss: 509 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 2, { + asset: 'ETH', + amount: 1, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 4, 22), + proceeds: 2_650, + cost: 2_601, + gainOrLoss: 49 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 3, { + asset: 'ETH', + amount: 1, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 4, 10), + proceeds: 2_000, + cost: 2_601, + gainOrLoss: -601 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 4, { + asset: 'ETH', + amount: 2, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 3, 10), + proceeds: 6_400, + cost: 5_202, + gainOrLoss: 1_198 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 5, { + asset: 'BTC', + amount: 0.5, + mtsAcquired: Date.UTC(2023, 1, 5), + mtsSold: Date.UTC(2023, 2, 23), + proceeds: 25_000, + cost: 21_500, + gainOrLoss: 3_500 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 6, { + asset: 'BTC', + amount: 2, + mtsAcquired: Date.UTC(2023, 1, 5), + mtsSold: Date.UTC(2023, 2, 3), + proceeds: 66_000, + cost: 86_000, + gainOrLoss: -20_000 + }) + }) + + it('Lookup buy trx with unrealized profit, FIFO strategy', async function () { + const { + buyTradesWithUnrealizedProfit + } = await lookUpTrades( + getMockedTrades(mockTrades), + { + isBackIterativeSaleLookUp: true, + isBackIterativeBuyLookUp: true, + isBuyTradesWithUnrealizedProfitRequired: true, + isNotGainOrLossRequired: true + } + ) + + assert.isArray(buyTradesWithUnrealizedProfit) + assert.equal(buyTradesWithUnrealizedProfit.length, 5) + + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 0, { + isMovements: false, + mtsCreate: Date.UTC(2023, 5, 11), + firstSymb: 'ETH', + lastSymb: 'UST', + execAmount: -1, + execPrice: 2_800, + buyFilledAmount: 100 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 1, { + isMovements: false, + mtsCreate: Date.UTC(2023, 4, 22), + firstSymb: 'ETH', + lastSymb: 'BTC', + execAmount: -1, + execPrice: 0.055, + buyFilledAmount: 0 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 2, { + isMovements: false, + mtsCreate: Date.UTC(2023, 2, 23), + firstSymb: 'ETH', + lastSymb: 'BTC', + execAmount: 10, + execPrice: 0.05, + buyFilledAmount: 7 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 3, { + isMovements: true, + mtsCreate: Date.UTC(2023, 1, 5), + firstSymb: 'BTC', + lastSymb: 'USD', + execAmount: 20, + execPrice: 43_000, + buyFilledAmount: 0 + }) + testBuyTradesWithUnrealizedProfit(buyTradesWithUnrealizedProfit, 4, { + isMovements: false, + mtsCreate: Date.UTC(2023, 0, 10), + firstSymb: 'BTC', + lastSymb: 'USD', + execAmount: 3, + execPrice: 20_000, + buyFilledAmount: 2.5 + }) + }) + + it('Lookup sale trx with realized profit, FIFO strategy', async function () { + const { + saleTradesWithRealizedProfit + } = await lookUpTrades( + getMockedTrades(mockTrades), + { + isBackIterativeSaleLookUp: true, + isBackIterativeBuyLookUp: true, + isBuyTradesWithUnrealizedProfitRequired: false, + isNotGainOrLossRequired: false + } + ) + + assert.isArray(saleTradesWithRealizedProfit) + assert.equal(saleTradesWithRealizedProfit.length, 7) + + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 0, { + asset: 'UST', + amount: 100, + mtsAcquired: Date.UTC(2023, 5, 11), + mtsSold: Date.UTC(2023, 6, 21), + proceeds: 105, + cost: 111, + gainOrLoss: -6 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 1, { + asset: 'ETH', + amount: 1, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 5, 11), + proceeds: 3_110, + cost: 2_601, + gainOrLoss: 509 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 2, { + asset: 'ETH', + amount: 1, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 4, 22), + proceeds: 2_650, + cost: 2_601, + gainOrLoss: 49 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 3, { + asset: 'ETH', + amount: 1, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 4, 10), + proceeds: 2_000, + cost: 2_601, + gainOrLoss: -601 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 4, { + asset: 'ETH', + amount: 2, + mtsAcquired: Date.UTC(2023, 2, 23), + mtsSold: Date.UTC(2023, 3, 10), + proceeds: 6_400, + cost: 5_202, + gainOrLoss: 1_198 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 5, { + asset: 'BTC', + amount: 0.5, + mtsAcquired: Date.UTC(2023, 0, 10), + mtsSold: Date.UTC(2023, 2, 23), + proceeds: 25_000, + cost: 10_000, + gainOrLoss: 15_000 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 6, { + asset: 'BTC', + amount: 2, + mtsAcquired: Date.UTC(2023, 0, 10), + mtsSold: Date.UTC(2023, 2, 3), + proceeds: 66_000, + cost: 40_000, + gainOrLoss: 26_000 + }) + }) + + it('Lookup sale trx with realized profit considering prev year, LIFO strategy', async function () { + const { + buyTradesWithUnrealizedProfit + } = await lookUpTrades( + getMockedTrades(mockTrades), + { + isBackIterativeSaleLookUp: false, + isBackIterativeBuyLookUp: false, + isBuyTradesWithUnrealizedProfitRequired: true, + isNotGainOrLossRequired: true + } + ) + const _mockTradesForNextYear = getMockedTrades(mockTradesForNextYear) + _mockTradesForNextYear.push(...buyTradesWithUnrealizedProfit) + + const { + saleTradesWithRealizedProfit + } = await lookUpTrades( + _mockTradesForNextYear, + { + isBackIterativeSaleLookUp: false, + isBackIterativeBuyLookUp: false, + isBuyTradesWithUnrealizedProfitRequired: false, + isNotGainOrLossRequired: false + } + ) + + assert.isArray(saleTradesWithRealizedProfit) + assert.equal(saleTradesWithRealizedProfit.length, 2) + + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 0, { + asset: 'UST', + amount: 200, + mtsAcquired: Date.UTC(2023, 5, 11), + mtsSold: Date.UTC(2024, 3, 27), + proceeds: 196, + cost: 222, + gainOrLoss: -26 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 1, { + asset: 'BTC', + amount: 5, + mtsAcquired: Date.UTC(2023, 1, 5), + mtsSold: Date.UTC(2024, 2, 17), + proceeds: 305_000, + cost: 220_275, + gainOrLoss: 84_725 + }) + }) + + it('Lookup sale trx with realized profit considering prev year, FIFO strategy', async function () { + const { + buyTradesWithUnrealizedProfit + } = await lookUpTrades( + getMockedTrades(mockTrades), + { + isBackIterativeSaleLookUp: true, + isBackIterativeBuyLookUp: true, + isBuyTradesWithUnrealizedProfitRequired: true, + isNotGainOrLossRequired: true + } + ) + const _mockTradesForNextYear = getMockedTrades(mockTradesForNextYear) + _mockTradesForNextYear.push(...buyTradesWithUnrealizedProfit) + + const { + saleTradesWithRealizedProfit + } = await lookUpTrades( + _mockTradesForNextYear, + { + isBackIterativeSaleLookUp: true, + isBackIterativeBuyLookUp: true, + isBuyTradesWithUnrealizedProfitRequired: false, + isNotGainOrLossRequired: false + } + ) + + assert.isArray(saleTradesWithRealizedProfit) + assert.equal(saleTradesWithRealizedProfit.length, 2) + + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 0, { + asset: 'UST', + amount: 200, + mtsAcquired: Date.UTC(2023, 5, 11), + mtsSold: Date.UTC(2024, 3, 27), + proceeds: 196, + cost: 222, + gainOrLoss: -26 + }) + testSaleTradesWithRealizedProfit(saleTradesWithRealizedProfit, 1, { + asset: 'BTC', + amount: 5, + mtsAcquired: Date.UTC(2023, 1, 5), + mtsSold: Date.UTC(2024, 2, 17), + proceeds: 305_000, + cost: 215_000, + gainOrLoss: 90_000 + }) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/remap-movements.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/remap-movements.spec.js new file mode 100644 index 000000000..6533be952 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/remap-movements.spec.js @@ -0,0 +1,60 @@ +'use strict' + +const { assert } = require('chai') + +const remapMovements = require('../remap-movements') +const { + mockMovements, + getMockedMovements +} = require('./helpers') +const { + testRemappedMovements +} = require('./test-cases') + +describe('remapMovements helper for trx tax report', () => { + it('Remap movements to trx data structure', function () { + const remappedTrxs = [] + const remappedTrxsForConvToUsd = [] + const params = { remappedTrxs, remappedTrxsForConvToUsd } + + const returnedParams = remapMovements( + getMockedMovements(mockMovements), + params + ) + + assert.isObject(returnedParams) + assert.equal(returnedParams, params) + assert.isArray(returnedParams.remappedTrxs) + assert.equal(returnedParams.remappedTrxs, remappedTrxs) + assert.isArray(returnedParams.remappedTrxsForConvToUsd) + assert.equal(returnedParams.remappedTrxsForConvToUsd, remappedTrxsForConvToUsd) + + assert.equal(remappedTrxs.length, 4) + assert.equal(remappedTrxsForConvToUsd.length, remappedTrxs.length) + + testRemappedMovements(remappedTrxs, 0, { + mtsCreate: Date.UTC(2023, 8, 29), + symbol: 'tBTCUSD', + firstSymb: 'BTC', + execAmount: -0.9 + }) + testRemappedMovements(remappedTrxs, 1, { + mtsCreate: Date.UTC(2023, 5, 12), + symbol: 'tETHUSD', + firstSymb: 'ETH', + execAmount: 19.6 + }) + testRemappedMovements(remappedTrxs, 2, { + mtsCreate: Date.UTC(2023, 3, 21), + symbol: 'tBTCUSD', + firstSymb: 'BTC', + execAmount: 2.4 + }) + testRemappedMovements(remappedTrxs, 3, { + mtsCreate: Date.UTC(2023, 1, 15), + symbol: 'tUSTUSD', + firstSymb: 'UST', + execAmount: 301 + }) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/remap-trades.spec.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/remap-trades.spec.js new file mode 100644 index 000000000..20df884c3 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/remap-trades.spec.js @@ -0,0 +1,119 @@ +'use strict' + +const { assert } = require('chai') + +const remapTrades = require('../remap-trades') + +const { + testRemappedTrades +} = require('./test-cases') + +const mockedTrades = [ + { + symbol: 'tUSTEUR', + mtsCreate: Date.UTC(2024, 5, 19), + execAmount: 303.2, + execPrice: 3_123 + }, + { + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2024, 4, 18), + execAmount: 3.1, + execPrice: 20_000 + }, + { + symbol: 'tBTCUSD', + mtsCreate: Date.UTC(2024, 3, 21), + execAmount: 0, + execPrice: 20_000 + }, + { + symbol: '', + mtsCreate: Date.UTC(2024, 2, 12), + execAmount: 4.6, + execPrice: 20_000 + }, + { + symbol: 'tETHUSD', + mtsCreate: Date.UTC(2024, 1, 2), + execAmount: 4.6, + execPrice: 0 + }, + { + symbol: 'tETHUSD', + mtsCreate: null, + execAmount: 4.6, + execPrice: 0.512 + }, + { + symbol: 'tETHBTC', + mtsCreate: Date.UTC(2024, 0, 8), + execAmount: -12.4, + execPrice: 20_000 + } +] + +describe('remapTrades helper for trx tax report', () => { + it('Remap trades to trx data structure', function () { + const remappedTrxs = [] + const remappedTrxsForConvToUsd = [] + const params = { remappedTrxs, remappedTrxsForConvToUsd } + + const returnedParams = remapTrades( + mockedTrades, + params + ) + + assert.isObject(returnedParams) + assert.equal(returnedParams, params) + assert.isArray(returnedParams.remappedTrxs) + assert.equal(returnedParams.remappedTrxs, remappedTrxs) + assert.isArray(returnedParams.remappedTrxsForConvToUsd) + assert.equal(returnedParams.remappedTrxsForConvToUsd, remappedTrxsForConvToUsd) + + assert.equal(remappedTrxs.length, 3) + assert.equal(remappedTrxsForConvToUsd.length, 2) + + testRemappedTrades(remappedTrxs, 0, { + mtsCreate: Date.UTC(2024, 5, 19), + symbol: 'tUSTEUR', + firstSymb: 'UST', + lastSymb: 'EUR', + firstSymbPrice: null, + lastSymbPrice: null + }) + testRemappedTrades(remappedTrxs, 1, { + mtsCreate: Date.UTC(2024, 4, 18), + symbol: 'tBTCUSD', + firstSymb: 'BTC', + lastSymb: 'USD', + firstSymbPrice: 20_000, + lastSymbPrice: 1 + }) + testRemappedTrades(remappedTrxs, 2, { + mtsCreate: Date.UTC(2024, 0, 8), + symbol: 'tETHBTC', + firstSymb: 'ETH', + lastSymb: 'BTC', + firstSymbPrice: null, + lastSymbPrice: null + }) + + testRemappedTrades(remappedTrxsForConvToUsd, 0, { + mtsCreate: Date.UTC(2024, 5, 19), + symbol: 'tUSTEUR', + firstSymb: 'UST', + lastSymb: 'EUR', + firstSymbPrice: null, + lastSymbPrice: null + }) + testRemappedTrades(remappedTrxsForConvToUsd, 1, { + mtsCreate: Date.UTC(2024, 0, 8), + symbol: 'tETHBTC', + firstSymb: 'ETH', + lastSymb: 'BTC', + firstSymbPrice: null, + lastSymbPrice: null + }) + }) +}) diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/index.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/index.js new file mode 100644 index 000000000..1dda6f08d --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/index.js @@ -0,0 +1,19 @@ +'use strict' + +const testBuyTradesWithUnrealizedProfit = require('./test-buy-trades-with-unrealized-profit') +const testSaleTradesWithRealizedProfit = require('./test-sale-trades-with-realized-profit') +const testRemappedMovements = require('./test-remapped-movements') +const testRemappedTrades = require('./test-remapped-trades') +const testTrxMapByCcy = require('./test-trx-map-by-ccy') +const testPubTradeChunkPayloads = require('./test-pub-trade-chunk-payloads') +const testConvertedCurrencyBySymbol = require('./test-converted-currency-by-symbol') + +module.exports = { + testBuyTradesWithUnrealizedProfit, + testSaleTradesWithRealizedProfit, + testRemappedMovements, + testRemappedTrades, + testTrxMapByCcy, + testPubTradeChunkPayloads, + testConvertedCurrencyBySymbol +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-buy-trades-with-unrealized-profit.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-buy-trades-with-unrealized-profit.js new file mode 100644 index 000000000..d1eeb4a85 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-buy-trades-with-unrealized-profit.js @@ -0,0 +1,48 @@ +'use strict' + +const { assert } = require('chai') + +module.exports = (arr, index, props) => { + const trade = arr[index] + const { + isMovements, + mtsCreate, + firstSymb, + lastSymb, + execAmount, + execPrice, + + buyFilledAmount + } = props ?? {} + + assert.isObject(trade) + assert.isBoolean(trade.isBuyTradesWithUnrealizedProfitForPrevPeriod) + assert.isOk(trade.isBuyTradesWithUnrealizedProfitForPrevPeriod) + assert.isBoolean(trade.isBuyTrx) + assert.isOk(trade.isBuyTrx) + assert.isBoolean(trade.isBuyTrxHistFilled) + assert.isNotOk(trade.isBuyTrxHistFilled) + assert.isNumber(trade.proceedsForBuyTrx) + assert.equal(trade.proceedsForBuyTrx, 0) + assert.isNumber(trade.proceedsForBuyTrx) + assert.equal(trade.proceedsForBuyTrx, 0) + assert.isNumber(trade.firstSymbPrice) + assert.isNumber(trade.lastSymbPrice) + assert.isArray(trade.saleTrxsForRealizedProfit) + + assert.isBoolean(trade.isMovements) + assert.equal(trade.isMovements, isMovements) + assert.isNumber(trade.mtsCreate) + assert.equal(trade.mtsCreate, mtsCreate) + assert.isString(trade.firstSymb) + assert.equal(trade.firstSymb, firstSymb) + assert.isString(trade.lastSymb) + assert.equal(trade.lastSymb, lastSymb) + assert.isNumber(trade.execAmount) + assert.equal(trade.execAmount, execAmount) + assert.isNumber(trade.execPrice) + assert.equal(trade.execPrice, execPrice) + + assert.isNumber(trade.buyFilledAmount) + assert.equal(trade.buyFilledAmount, buyFilledAmount) +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-converted-currency-by-symbol.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-converted-currency-by-symbol.js new file mode 100644 index 000000000..73fafa1dc --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-converted-currency-by-symbol.js @@ -0,0 +1,35 @@ +'use strict' + +const { assert } = require('chai') + +module.exports = (arr, index, props) => { + const { trx } = arr[index] + const { + mtsCreate, + symbol, + execPrice, + firstSymbPrice, + lastSymbPrice + } = props ?? {} + + assert.isObject(trx) + assert.isNumber(trx.mtsCreate) + assert.equal(trx.mtsCreate, mtsCreate) + assert.isString(trx.symbol) + assert.equal(trx.symbol, symbol) + assert.isNumber(trx.execPrice) + assert.equal(trx.execPrice, execPrice) + + if (firstSymbPrice) { + assert.isNumber(trx.firstSymbPrice) + assert.equal(trx.firstSymbPrice, firstSymbPrice) + } else { + assert.isNull(trx.firstSymbPrice) + } + if (lastSymbPrice) { + assert.isNumber(trx.lastSymbPrice) + assert.equal(trx.lastSymbPrice, lastSymbPrice) + } else { + assert.isNull(trx.lastSymbPrice) + } +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-pub-trade-chunk-payloads.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-pub-trade-chunk-payloads.js new file mode 100644 index 000000000..401159284 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-pub-trade-chunk-payloads.js @@ -0,0 +1,25 @@ +'use strict' + +const { assert } = require('chai') + +module.exports = (arr, index, props) => { + const payload = arr[index] + const { + symbol, + end, + start + } = props ?? {} + + assert.isObject(payload) + assert.isString(payload.symbol) + assert.equal(payload.symbol, symbol) + assert.isNumber(payload.end) + assert.equal(payload.end, end) + + if (start) { + assert.isNumber(payload.start) + assert.equal(payload.start, start) + } else { + assert.isNull(payload.start) + } +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-remapped-movements.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-remapped-movements.js new file mode 100644 index 000000000..163d9702b --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-remapped-movements.js @@ -0,0 +1,32 @@ +'use strict' + +const { assert } = require('chai') + +module.exports = (arr, index, props) => { + const trx = arr[index] + const { + mtsCreate, + symbol, + firstSymb, + execAmount + } = props ?? {} + + assert.isObject(trx) + assert.isBoolean(trx.isMovements) + assert.isOk(trx.isMovements) + assert.isNumber(trx.mtsCreate) + assert.equal(trx.mtsCreate, mtsCreate) + assert.isNull(trx.firstSymbPrice) + assert.isNumber(trx.lastSymbPrice) + assert.equal(trx.lastSymbPrice, 1) + assert.isString(trx.symbol) + assert.equal(trx.symbol, symbol) + assert.isString(trx.firstSymb) + assert.equal(trx.firstSymb, firstSymb) + assert.isString(trx.lastSymb) + assert.equal(trx.lastSymb, 'USD') + assert.isNumber(trx.execAmount) + assert.equal(trx.execAmount, execAmount) + assert.isNumber(trx.execPrice) + assert.equal(trx.execPrice, 0) +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-remapped-trades.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-remapped-trades.js new file mode 100644 index 000000000..3ee09bbb6 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-remapped-trades.js @@ -0,0 +1,38 @@ +'use strict' + +const { assert } = require('chai') + +module.exports = (arr, index, props) => { + const trx = arr[index] + const { + mtsCreate, + symbol, + firstSymb, + lastSymb, + firstSymbPrice, + lastSymbPrice + } = props ?? {} + + assert.isObject(trx) + assert.isNumber(trx.mtsCreate) + assert.equal(trx.mtsCreate, mtsCreate) + assert.isString(trx.symbol) + assert.equal(trx.symbol, symbol) + assert.isString(trx.firstSymb) + assert.equal(trx.firstSymb, firstSymb) + assert.isString(trx.lastSymb) + assert.equal(trx.lastSymb, lastSymb) + + if (firstSymbPrice) { + assert.isNumber(trx.firstSymbPrice) + assert.equal(trx.firstSymbPrice, firstSymbPrice) + } else { + assert.isNull(trx.firstSymbPrice) + } + if (lastSymbPrice) { + assert.isNumber(trx.lastSymbPrice) + assert.equal(trx.lastSymbPrice, lastSymbPrice) + } else { + assert.isNull(trx.lastSymbPrice) + } +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-sale-trades-with-realized-profit.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-sale-trades-with-realized-profit.js new file mode 100644 index 000000000..4fdadffcf --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-sale-trades-with-realized-profit.js @@ -0,0 +1,43 @@ +'use strict' + +const { assert } = require('chai') + +/* + * It's a simple workaround for passing test with + * issue `0.1 + 0.2 = 0.30000000000000004` + */ +const truncFloat = (num, precision) => { + const _precision = precision ?? 13 + + return Math.trunc(num * 10 ** _precision) / 10 ** _precision +} + +module.exports = (arr, index, props) => { + const trade = arr[index] + const { + asset, + amount, + mtsAcquired, + mtsSold, + proceeds, + cost, + gainOrLoss + } = props ?? {} + + assert.isObject(trade) + + assert.isString(trade.asset) + assert.equal(trade.asset, asset) + assert.isNumber(trade.amount) + assert.equal(trade.amount, amount) + assert.isNumber(trade.mtsAcquired) + assert.equal(trade.mtsAcquired, mtsAcquired) + assert.isNumber(trade.mtsSold) + assert.equal(trade.mtsSold, mtsSold) + assert.isNumber(trade.proceeds) + assert.equal(truncFloat(trade.proceeds), proceeds) + assert.isNumber(trade.cost) + assert.equal(truncFloat(trade.cost), cost) + assert.isNumber(trade.gainOrLoss) + assert.equal(truncFloat(trade.gainOrLoss), gainOrLoss) +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-trx-map-by-ccy.js b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-trx-map-by-ccy.js new file mode 100644 index 000000000..f048bc115 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/__test__/test-cases/test-trx-map-by-ccy.js @@ -0,0 +1,38 @@ +'use strict' + +const { assert } = require('chai') + +module.exports = (trxMapByCcy, ccy, dataArray) => { + const array = trxMapByCcy.get(ccy) + + assert.isArray(array) + assert.lengthOf(array, dataArray.length) + + for (const [i, item] of array.entries()) { + assert.isObject(dataArray[i]) + + const { + mtsCreate, + isNotFirstSymbForex, + isNotLastSymbForex, + mainPrice, + secondPrice + } = dataArray[i] + + assert.isObject(item) + assert.isBoolean(item.isNotFirstSymbForex) + assert.equal(item.isNotFirstSymbForex, isNotFirstSymbForex) + assert.isBoolean(item.isNotLastSymbForex) + assert.equal(item.isNotLastSymbForex, isNotLastSymbForex) + assert.isString(item.mainPricePropName) + assert.isString(item.secondPricePropName) + + assert.isObject(item.trx) + assert.isNumber(item.trx.mtsCreate) + assert.equal(item.trx.mtsCreate, mtsCreate) + assert.isNumber(item.trx[item.mainPricePropName]) + assert.equal(item.trx[item.mainPricePropName], mainPrice) + assert.isNumber(item.trx[item.secondPricePropName]) + assert.equal(item.trx[item.secondPricePropName], secondPrice) + } +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/convert-currency-by-symbol.js b/workers/loc.api/sync/transaction.tax.report/helpers/convert-currency-by-symbol.js new file mode 100644 index 000000000..1cdda13dc --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/convert-currency-by-symbol.js @@ -0,0 +1,56 @@ +'use strict' + +module.exports = (trxData, pubTrades) => { + for (const trxDataItem of trxData) { + let lastIndex = 0 + + for (let i = lastIndex; pubTrades.length > i; i += 1) { + const pubTrade = pubTrades[i] + const isLastPubTrade = (i + 1) === pubTrades.length + + if ( + ( + pubTrade?.mts > trxDataItem.trx.mtsCreate && + !isLastPubTrade + ) || + !Number.isFinite(pubTrade?.price) || + pubTrade.price === 0 + ) { + continue + } + + lastIndex = i + trxDataItem.trx[trxDataItem.mainPricePropName] = pubTrade.price + + if (trxDataItem.trx.isMovements) { + trxDataItem.trx.execPrice = pubTrade.price + + break + } + if ( + !Number.isFinite(trxDataItem.trx.execPrice) || + trxDataItem.trx.execPrice === 0 + ) { + break + } + if ( + trxDataItem.isNotFirstSymbForex && + !trxDataItem.isNotLastSymbForex + ) { + trxDataItem.trx[trxDataItem.secondPricePropName] = ( + pubTrade.price / trxDataItem.trx.execPrice + ) + } + if ( + !trxDataItem.isNotFirstSymbForex && + trxDataItem.isNotLastSymbForex + ) { + trxDataItem.trx[trxDataItem.secondPricePropName] = ( + pubTrade.price * trxDataItem.trx.execPrice + ) + } + + break + } + } +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/get-pub-trade-chunk-payloads.js b/workers/loc.api/sync/transaction.tax.report/helpers/get-pub-trade-chunk-payloads.js new file mode 100644 index 000000000..2266c239e --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/get-pub-trade-chunk-payloads.js @@ -0,0 +1,38 @@ +'use strict' + +module.exports = (symbol, trxData) => { + const pubTradeChunkPayloads = [] + + for (const { trx } of trxData) { + const lastPayloads = pubTradeChunkPayloads[pubTradeChunkPayloads.length - 1] + const lastMts = lastPayloads?.start ?? lastPayloads?.end + const currMts = trx.mtsCreate + + if (!lastPayloads?.end) { + pubTradeChunkPayloads.push({ + symbol, + end: currMts, + start: null + }) + + continue + } + + const mtsDiff = lastMts - currMts + const maxAllowedTimeframe = 1000 * 60 * 60 * 24 + + if (mtsDiff < maxAllowedTimeframe) { + lastPayloads.start = currMts + + continue + } + + pubTradeChunkPayloads.push({ + symbol, + end: currMts, + start: null + }) + } + + return pubTradeChunkPayloads +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/get-pub-trade-chunk.js b/workers/loc.api/sync/transaction.tax.report/helpers/get-pub-trade-chunk.js new file mode 100644 index 000000000..162253392 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/get-pub-trade-chunk.js @@ -0,0 +1,54 @@ +'use strict' + +const { + TrxTaxReportGenerationTimeoutError +} = require('../../../errors') + +module.exports = async (params, pubTradeGetter) => { + const symbol = params?.symbol + const start = params?.start + let end = params?.end + let timeoutMts = Date.now() + const res = [] + + while (true) { + const currMts = Date.now() + const mtsDiff = currMts - timeoutMts + + if (mtsDiff > 1000 * 60 * 60 * 12) { + throw new TrxTaxReportGenerationTimeoutError() + } + + timeoutMts = currMts + + const { res: pubTrades } = await pubTradeGetter({ + symbol: `t${symbol}USD`, + start: 0, + end + }) + + if (!Array.isArray(pubTrades)) { + break + } + if ( + pubTrades.length === 0 || + !Number.isFinite(start) || + !Number.isFinite(pubTrades[0]?.mts) || + !Number.isFinite(pubTrades[pubTrades.length - 1]?.mts) || + ( + res.length !== 0 && + pubTrades[0]?.mts >= res[res.length - 1]?.mts + ) || + pubTrades[pubTrades.length - 1]?.mts <= start + ) { + res.push(...pubTrades) + + break + } + + end = pubTrades[pubTrades.length - 1].mts - 1 + res.push(...pubTrades) + } + + return res +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/get-trx-map-by-ccy.js b/workers/loc.api/sync/transaction.tax.report/helpers/get-trx-map-by-ccy.js new file mode 100644 index 000000000..662678718 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/get-trx-map-by-ccy.js @@ -0,0 +1,48 @@ +'use strict' + +const { + isForexSymb +} = require('../../helpers') + +// Handle tETHF0:USTF0 symbols +const symbRegExpNormalizer = /F0$/i + +module.exports = (trxs) => { + const trxMapByCcy = new Map() + + for (const trx of trxs) { + const firstSymb = trx.firstSymb.replace(symbRegExpNormalizer, '') + const lastSymb = trx.lastSymb.replace(symbRegExpNormalizer, '') + const isNotFirstSymbForex = !isForexSymb(trx.firstSymb) + const isNotLastSymbForex = !isForexSymb(trx.lastSymb) + + if (isNotFirstSymbForex) { + if (!trxMapByCcy.has(firstSymb)) { + trxMapByCcy.set(firstSymb, []) + } + + trxMapByCcy.get(firstSymb).push({ + isNotFirstSymbForex, + isNotLastSymbForex, + mainPricePropName: 'firstSymbPrice', + secondPricePropName: 'lastSymbPrice', + trx + }) + } + if (isNotLastSymbForex) { + if (!trxMapByCcy.has(lastSymb)) { + trxMapByCcy.set(lastSymb, []) + } + + trxMapByCcy.get(lastSymb).push({ + isNotFirstSymbForex, + isNotLastSymbForex, + mainPricePropName: 'lastSymbPrice', + secondPricePropName: 'firstSymbPrice', + trx + }) + } + } + + return trxMapByCcy +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/index.js b/workers/loc.api/sync/transaction.tax.report/helpers/index.js new file mode 100644 index 000000000..301113101 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/index.js @@ -0,0 +1,21 @@ +'use strict' + +const lookUpTrades = require('./look-up-trades') +const getTrxMapByCcy = require('./get-trx-map-by-ccy') +const getPubTradeChunkPayloads = require('./get-pub-trade-chunk-payloads') +const TRX_TAX_STRATEGIES = require('./trx.tax.strategies') +const remapTrades = require('./remap-trades') +const remapMovements = require('./remap-movements') +const convertCurrencyBySymbol = require('./convert-currency-by-symbol') +const getPubTradeChunk = require('./get-pub-trade-chunk') + +module.exports = { + lookUpTrades, + getTrxMapByCcy, + getPubTradeChunkPayloads, + TRX_TAX_STRATEGIES, + remapTrades, + remapMovements, + convertCurrencyBySymbol, + getPubTradeChunk +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/look-up-trades.js b/workers/loc.api/sync/transaction.tax.report/helpers/look-up-trades.js new file mode 100644 index 000000000..6593b8af2 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/look-up-trades.js @@ -0,0 +1,331 @@ +'use strict' + +const { setImmediate } = require('node:timers/promises') +const splitSymbolPairs = require( + 'bfx-report/workers/loc.api/helpers/split-symbol-pairs' +) + +const { + isForexSymb, + getBackIterable +} = require('../../helpers') + +const { + CurrencyConversionError, + CurrencyPairSeparationError +} = require('../../../errors') + +module.exports = async (trades, opts) => { + const { + isBackIterativeSaleLookUp = false, + isBackIterativeBuyLookUp = false, + isBuyTradesWithUnrealizedProfitRequired = false, + isNotGainOrLossRequired = false + } = opts ?? {} + + const saleTradesWithRealizedProfit = [] + const buyTradesWithUnrealizedProfit = [] + + if ( + !Array.isArray(trades) || + trades.length === 0 + ) { + return { + saleTradesWithRealizedProfit, + buyTradesWithUnrealizedProfit + } + } + + let lastLoopUnlockMts = Date.now() + const tradeIterator = isBackIterativeSaleLookUp + ? getBackIterable(trades) + : trades + + for (const [i, trade] of tradeIterator.entries()) { + const currentLoopUnlockMts = Date.now() + + /* + * Trx hist restoring is a hard sync operation, + * to prevent EventLoop locking more than 1sec + * it needs to resolve async queue + */ + if ((currentLoopUnlockMts - lastLoopUnlockMts) > 1000) { + await setImmediate() + + lastLoopUnlockMts = currentLoopUnlockMts + } + + trade.isMovements = trade.isMovements ?? false + + trade.isSaleTrx = trade.isSaleTrx ?? false + trade.isSaleTrxHistFilled = trade.isSaleTrxHistFilled ?? false + trade.saleFilledAmount = trade.saleFilledAmount ?? 0 + trade.costForSaleTrx = trade.costForSaleTrx ?? 0 + trade.buyTrxsForRealizedProfit = trade + .buyTrxsForRealizedProfit ?? [] + + trade.isBuyTrx = trade.isBuyTrx ?? false + trade.isBuyTrxHistFilled = trade.isBuyTrxHistFilled ?? false + trade.buyFilledAmount = trade.buyFilledAmount ?? 0 + trade.proceedsForBuyTrx = trade.proceedsForBuyTrx ?? 0 + trade.saleTrxsForRealizedProfit = trade + .saleTrxsForRealizedProfit ?? [] + + if ( + !trade?.symbol || + !Number.isFinite(trade?.execPrice) || + ( + !isBuyTradesWithUnrealizedProfitRequired && + trade.execPrice === 0 + ) || + !Number.isFinite(trade?.execAmount) || + trade.execAmount === 0 + ) { + continue + } + + const [firstSymb, lastSymb] = ( + trade?.firstSymb && + trade?.lastSymb + ) + ? [trade?.firstSymb, trade?.lastSymb] + : splitSymbolPairs(trade.symbol) + trade.firstSymb = firstSymb + trade.lastSymb = lastSymb + + /* + * Exapmle of considered trxs as sale: + * - buy ETC:BTC -> amount 5, price 0.5 (here needs to be considered as 2 trxs: buy ETC and sale BTC) + * - sale ETC:BTC -> amount -2, price 0.6 (here needs to be considered as 2 trxs: sale ETC and buy BTC) + * - sale ETC:USD -> amount -3, price 4000 + * - sale UST:EUR - > amount -3, price 0.9 (here needs to be considered EUR price and converted to USD) + */ + const isLastSymbForex = isForexSymb(lastSymb) + const isDistinctSale = trade.execAmount < 0 + const isSaleBetweenCrypto = ( + trade.execAmount > 0 && + !isLastSymbForex + ) + trade.isSaleTrx = isDistinctSale || isSaleBetweenCrypto + trade.isBuyTrx = ( + trade.execAmount > 0 || + !isLastSymbForex + ) + + if ( + !trade.isSaleTrx || + trade.isBuyTradesWithUnrealizedProfitForPrevPeriod + ) { + continue + } + if ( + !firstSymb || + !lastSymb + ) { + throw new CurrencyPairSeparationError({ + symbol: trade.symbol, + firstSymb, + lastSymb + }) + } + + const saleAmount = trade.execAmount < 0 + ? Math.abs(trade.execAmount) + : Math.abs(trade.execAmount * trade.execPrice) + const _salePrice = isDistinctSale + ? trade.firstSymbPrice + : trade.lastSymbPrice + const salePrice = isNotGainOrLossRequired ? 0 : _salePrice + const saleAsset = isDistinctSale + ? firstSymb + : lastSymb + + if (!Number.isFinite(salePrice)) { + throw new CurrencyConversionError({ + symbol: saleAsset, + price: salePrice + }) + } + + const startPoint = isBackIterativeBuyLookUp + ? trades.length - 1 + : i + 1 + const checkPoint = (j) => ( + isBackIterativeBuyLookUp + ? i < j + : trades.length > j + ) + const shiftPoint = (j) => ( + isBackIterativeBuyLookUp + ? j - 1 + : j + 1 + ) + + for (let j = startPoint; checkPoint(j); j = shiftPoint(j)) { + if (trade.isSaleTrxHistFilled) { + break + } + + const tradeForLookup = trades[j] + + if ( + tradeForLookup?.isBuyTrxHistFilled || + !tradeForLookup?.symbol || + !Number.isFinite(tradeForLookup?.execAmount) || + tradeForLookup.execAmount === 0 || + !Number.isFinite(tradeForLookup?.execPrice) || + ( + !isBuyTradesWithUnrealizedProfitRequired && + tradeForLookup.execPrice === 0 + ) + ) { + continue + } + + tradeForLookup.isBuyTrx = tradeForLookup.isBuyTrx ?? false + tradeForLookup.isBuyTrxHistFilled = tradeForLookup + .isBuyTrxHistFilled ?? false + tradeForLookup.buyFilledAmount = tradeForLookup + .buyFilledAmount ?? 0 + tradeForLookup.proceedsForBuyTrx = tradeForLookup.proceedsForBuyTrx ?? 0 + tradeForLookup.saleTrxsForRealizedProfit = tradeForLookup + .saleTrxsForRealizedProfit ?? [] + + const [firstSymbForLookup, lastSymbForLookup] = ( + tradeForLookup?.firstSymb && + tradeForLookup?.lastSymb + ) + ? [tradeForLookup?.firstSymb, tradeForLookup?.lastSymb] + : splitSymbolPairs(tradeForLookup.symbol) + tradeForLookup.firstSymb = firstSymbForLookup + tradeForLookup.lastSymb = lastSymbForLookup + + if ( + !firstSymbForLookup || + !lastSymbForLookup + ) { + throw new CurrencyPairSeparationError({ + symbol: tradeForLookup.symbol, + firstSymb: firstSymbForLookup, + lastSymb: lastSymbForLookup + }) + } + + if ( + tradeForLookup.execAmount < 0 && + isForexSymb(lastSymbForLookup) + ) { + continue + } + + tradeForLookup.isBuyTrx = true + + const buyAsset = tradeForLookup.execAmount > 0 + ? firstSymbForLookup + : lastSymbForLookup + + if (saleAsset !== buyAsset) { + continue + } + + tradeForLookup.saleTrxsForRealizedProfit.push(trade) + trade.buyTrxsForRealizedProfit.push(tradeForLookup) + + const buyAmount = tradeForLookup.execAmount > 0 + ? Math.abs(tradeForLookup.execAmount) + : Math.abs(tradeForLookup.execAmount * tradeForLookup.execPrice) + const _buyPrice = tradeForLookup.execAmount > 0 + ? tradeForLookup.firstSymbPrice + : tradeForLookup.lastSymbPrice + const buyPrice = isNotGainOrLossRequired ? 0 : _buyPrice + const buyRestAmount = buyAmount - tradeForLookup.buyFilledAmount + const saleRestAmount = saleAmount - trade.saleFilledAmount + + if (!Number.isFinite(buyPrice)) { + throw new CurrencyConversionError({ + symbol: buyAsset, + price: buyPrice + }) + } + + if (buyRestAmount < saleRestAmount) { + tradeForLookup.buyFilledAmount = buyAmount + trade.saleFilledAmount += buyRestAmount + tradeForLookup.proceedsForBuyTrx += buyRestAmount * salePrice + trade.costForSaleTrx += buyRestAmount * buyPrice + tradeForLookup.isBuyTrxHistFilled = true + } + if (buyRestAmount > saleRestAmount) { + tradeForLookup.buyFilledAmount += saleRestAmount + trade.saleFilledAmount = saleAmount + tradeForLookup.proceedsForBuyTrx += saleRestAmount * salePrice + trade.costForSaleTrx += saleRestAmount * buyPrice + trade.isSaleTrxHistFilled = true + } + if (buyRestAmount === saleRestAmount) { + tradeForLookup.buyFilledAmount = buyAmount + trade.saleFilledAmount = saleAmount + tradeForLookup.proceedsForBuyTrx += buyRestAmount * salePrice + trade.costForSaleTrx += buyRestAmount * buyPrice + tradeForLookup.isBuyTrxHistFilled = true + trade.isSaleTrxHistFilled = true + } + + if (tradeForLookup.isBuyTrxHistFilled) { + tradeForLookup.buyAsset = buyAsset + tradeForLookup.buyAmount = buyAmount + tradeForLookup.mtsAcquiredForBuyTrx = tradeForLookup.mtsCreate + tradeForLookup.mtsSoldForBuyTrx = trade.mtsCreate + tradeForLookup.costForBuyTrx = buyAmount * buyPrice + tradeForLookup.gainOrLossForBuyTrx = tradeForLookup.proceedsForBuyTrx - tradeForLookup.costForBuyTrx + } + } + + trade.saleAsset = saleAsset + trade.saleAmount = saleAmount + trade.mtsAcquiredForSaleTrx = ( + trade.buyTrxsForRealizedProfit[0]?.mtsCreate > + trade.buyTrxsForRealizedProfit[trade.buyTrxsForRealizedProfit.length - 1]?.mtsCreate + ) + ? trade.buyTrxsForRealizedProfit[trade.buyTrxsForRealizedProfit.length - 1]?.mtsCreate + : trade.buyTrxsForRealizedProfit[0]?.mtsCreate + trade.mtsSoldForSaleTrx = trade.mtsCreate + trade.proceedsForSaleTrx = saleAmount * salePrice + trade.gainOrLoss = trade.proceedsForSaleTrx - trade.costForSaleTrx + } + + for (const trade of trades) { + if ( + isBuyTradesWithUnrealizedProfitRequired && + trade?.isBuyTrx && + !trade?.isBuyTrxHistFilled + ) { + trade.isBuyTradesWithUnrealizedProfitForPrevPeriod = true + buyTradesWithUnrealizedProfit.push(trade) + } + + if ( + isBuyTradesWithUnrealizedProfitRequired || + trade?.isBuyTradesWithUnrealizedProfitForPrevPeriod || + !trade?.isSaleTrx || + trade?.isMovements + ) { + continue + } + + saleTradesWithRealizedProfit.push({ + asset: trade.saleAsset, + amount: trade.saleAmount, + mtsAcquired: trade.mtsAcquiredForSaleTrx, + mtsSold: trade.mtsSoldForSaleTrx, + proceeds: trade.proceedsForSaleTrx, + cost: trade.costForSaleTrx, + gainOrLoss: trade.gainOrLoss + }) + } + + return { + saleTradesWithRealizedProfit, + buyTradesWithUnrealizedProfit + } +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/remap-movements.js b/workers/loc.api/sync/transaction.tax.report/helpers/remap-movements.js new file mode 100644 index 000000000..eda6e5c31 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/remap-movements.js @@ -0,0 +1,48 @@ +'use strict' + +const { + isForexSymb +} = require('../../helpers') + +module.exports = (movements, params) => { + const { + remappedTrxs, + remappedTrxsForConvToUsd + } = params + + for (const movement of movements) { + if ( + !movement?.currency || + isForexSymb(movement.currency) || + !Number.isFinite(movement?.amount) || + movement.amount === 0 || + !Number.isFinite(movement?.mtsUpdated) + ) { + continue + } + + const firstSymb = movement.currency + const lastSymb = 'USD' + const symbSeparator = firstSymb.length > 3 + ? ':' + : '' + + const remappedMovement = { + isMovements: true, + symbol: `t${firstSymb}${symbSeparator}${lastSymb}`, + mtsCreate: movement.mtsUpdated, + firstSymb, + lastSymb, + firstSymbPrice: null, + lastSymbPrice: 1, + execAmount: movement.amount, + // NOTE: execPrice = firstSymbPrice and should be set when converting currencies + execPrice: 0 + } + + remappedTrxs.push(remappedMovement) + remappedTrxsForConvToUsd.push(remappedMovement) + } + + return params +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/remap-trades.js b/workers/loc.api/sync/transaction.tax.report/helpers/remap-trades.js new file mode 100644 index 000000000..080580db4 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/remap-trades.js @@ -0,0 +1,44 @@ +'use strict' + +const splitSymbolPairs = require( + 'bfx-report/workers/loc.api/helpers/split-symbol-pairs' +) + +module.exports = (trades, params) => { + const { + remappedTrxs, + remappedTrxsForConvToUsd + } = params + + for (const trade of trades) { + if ( + !trade?.symbol || + !Number.isFinite(trade?.execAmount) || + trade.execAmount === 0 || + !Number.isFinite(trade?.execPrice) || + trade.execPrice === 0 || + !Number.isFinite(trade?.mtsCreate) + ) { + continue + } + + const [firstSymb, lastSymb] = splitSymbolPairs(trade.symbol) + trade.firstSymb = firstSymb + trade.lastSymb = lastSymb + trade.firstSymbPrice = null + trade.lastSymbPrice = null + + remappedTrxs.push(trade) + + if (lastSymb === 'USD') { + trade.firstSymbPrice = trade.execPrice + trade.lastSymbPrice = 1 + + continue + } + + remappedTrxsForConvToUsd.push(trade) + } + + return params +} diff --git a/workers/loc.api/sync/transaction.tax.report/helpers/trx.tax.strategies.js b/workers/loc.api/sync/transaction.tax.report/helpers/trx.tax.strategies.js new file mode 100644 index 000000000..087b2bbf7 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/helpers/trx.tax.strategies.js @@ -0,0 +1,8 @@ +'use strict' + +const TRX_TAX_STRATEGIES = { + FIFO: 'FIFO', + LIFO: 'LIFO' +} + +module.exports = TRX_TAX_STRATEGIES diff --git a/workers/loc.api/sync/transaction.tax.report/index.js b/workers/loc.api/sync/transaction.tax.report/index.js new file mode 100644 index 000000000..225a961c6 --- /dev/null +++ b/workers/loc.api/sync/transaction.tax.report/index.js @@ -0,0 +1,291 @@ +'use strict' + +const { pushLargeArr } = require('../../helpers/utils') + +const { + lookUpTrades, + getTrxMapByCcy, + getPubTradeChunkPayloads, + TRX_TAX_STRATEGIES, + remapTrades, + remapMovements, + convertCurrencyBySymbol, + getPubTradeChunk +} = require('./helpers') + +const { decorateInjectable } = require('../../di/utils') + +const depsTypes = (TYPES) => [ + TYPES.DAO, + TYPES.Authenticator, + TYPES.SyncSchema, + TYPES.ALLOWED_COLLS, + TYPES.SYNC_API_METHODS, + TYPES.Movements, + TYPES.RService, + TYPES.GetDataFromApi, + TYPES.WSEventEmitterFactory, + TYPES.Logger +] +class TransactionTaxReport { + constructor ( + dao, + authenticator, + syncSchema, + ALLOWED_COLLS, + SYNC_API_METHODS, + movements, + rService, + getDataFromApi, + wsEventEmitterFactory, + logger + ) { + this.dao = dao + this.authenticator = authenticator + this.syncSchema = syncSchema + this.ALLOWED_COLLS = ALLOWED_COLLS + this.SYNC_API_METHODS = SYNC_API_METHODS + this.movements = movements + this.rService = rService + this.getDataFromApi = getDataFromApi + this.wsEventEmitterFactory = wsEventEmitterFactory + this.logger = logger + + this.tradesModel = this.syncSchema.getModelsMap() + .get(this.ALLOWED_COLLS.TRADES) + } + + async makeTrxTaxReportInBackground (args = {}) { + const { auth, params } = args ?? {} + const user = await this.authenticator + .verifyRequestUser({ auth }) + const _args = { auth: user, params } + + this.wsEventEmitterFactory() + .emitTrxTaxReportGenerationInBackgroundToOne(() => { + return this.getTransactionTaxReport(_args) + }, user) + .then(() => {}, (err) => { + this.logger.error(`TRX_TAX_REPORT_GEN_FAILED: ${err.stack || err}`) + }) + + return true + } + + async getTransactionTaxReport (args = {}) { + const { auth, params } = args ?? {} + const start = params.start ?? 0 + const end = params.end ?? Date.now() + const strategy = params.strategy ?? TRX_TAX_STRATEGIES.LIFO + const user = await this.authenticator + .verifyRequestUser({ auth }) + + const isFIFO = strategy === TRX_TAX_STRATEGIES.FIFO + const isLIFO = strategy === TRX_TAX_STRATEGIES.LIFO + + const { + trxs: trxsForCurrPeriod, + trxsForConvToUsd + } = await this.#getTrxs({ + user, + start, + end + }) + + if ( + !Array.isArray(trxsForCurrPeriod) || + trxsForCurrPeriod.length === 0 + ) { + return [] + } + + const { + trxs: trxsForPrevPeriod + } = start > 0 + ? await this.#getTrxs({ + user, + start: 0, + end: start - 1 + }) + : { trxs: [] } + + const isBackIterativeSaleLookUp = isFIFO && !isLIFO + const isBackIterativeBuyLookUp = isFIFO && !isLIFO + + const { buyTradesWithUnrealizedProfit } = await lookUpTrades( + trxsForPrevPeriod, + { + isBackIterativeSaleLookUp, + isBackIterativeBuyLookUp, + isBuyTradesWithUnrealizedProfitRequired: true, + isNotGainOrLossRequired: true + } + ) + + pushLargeArr(trxsForCurrPeriod, buyTradesWithUnrealizedProfit) + pushLargeArr( + trxsForConvToUsd, + buyTradesWithUnrealizedProfit + .filter((trx) => trx?.isMovements || trx?.lastSymb !== 'USD') + ) + await this.#convertCurrencies(trxsForConvToUsd) + + const { saleTradesWithRealizedProfit } = await lookUpTrades( + trxsForCurrPeriod, + { + isBackIterativeSaleLookUp, + isBackIterativeBuyLookUp + } + ) + + return saleTradesWithRealizedProfit + } + + async #getTrxs (params) { + const { + user, + start, + end + } = params ?? {} + + const tradesPromise = this.#getTrades(params) + const withdrawalsPromise = this.movements.getMovements({ + auth: user, + start, + end, + isWithdrawals: true + }) + const depositsPromise = this.movements.getMovements({ + auth: user, + start, + end, + isDeposits: true + }) + + const [ + trades, + withdrawals, + deposits + ] = await Promise.all([ + tradesPromise, + withdrawalsPromise, + depositsPromise + ]) + + const movements = [...withdrawals, ...deposits] + const remappedTrxs = [] + const remappedTrxsForConvToUsd = [] + + remapTrades( + trades, + { remappedTrxs, remappedTrxsForConvToUsd } + ) + remapMovements( + movements, + { remappedTrxs, remappedTrxsForConvToUsd } + ) + + const trxs = remappedTrxs + .sort((a, b) => b?.mtsCreate - a?.mtsCreate) + const trxsForConvToUsd = remappedTrxsForConvToUsd + .sort((a, b) => b?.mtsCreate - a?.mtsCreate) + + return { + trxs, + trxsForConvToUsd + } + } + + async #convertCurrencies (trxs) { + const trxMapByCcy = getTrxMapByCcy(trxs) + + for (const [symbol, trxData] of trxMapByCcy.entries()) { + const pubTrades = [] + const pubTradeChunkPayloads = getPubTradeChunkPayloads( + symbol, + trxData + ) + + for (const chunkPayload of pubTradeChunkPayloads) { + const chunk = await getPubTradeChunk( + chunkPayload, + (...args) => this.#getPublicTrades(...args) + ) + + pushLargeArr(pubTrades, chunk) + } + + convertCurrencyBySymbol(trxData, pubTrades) + } + } + + async #getTrades ({ + user, + start, + end, + symbol + }) { + const symbFilter = ( + Array.isArray(symbol) && + symbol.length !== 0 + ) + ? { $in: { symbol } } + : {} + + return this.dao.getElemsInCollBy( + this.ALLOWED_COLLS.TRADES, + { + filter: { + user_id: user._id, + $lte: { mtsCreate: end }, + $gte: { mtsCreate: start }, + ...symbFilter + }, + sort: [['mtsCreate', -1]], + projection: this.tradesModel, + exclude: ['user_id'], + isExcludePrivate: true + } + ) + } + + async #getPublicTrades (params) { + const { + symbol, + start = 0, + end = Date.now(), + sort = -1, + limit = 10000 + } = params ?? {} + const args = { + isNotMoreThanInnerMax: true, + params: { + symbol, + start, + end, + sort, + limit, + notCheckNextPage: true, + notThrowError: true + } + } + + const getDataFn = this.rService[this.SYNC_API_METHODS.PUBLIC_TRADES] + .bind(this.rService) + + const res = await this.getDataFromApi({ + getData: (s, args) => getDataFn(args), + args, + callerName: 'TRANSACTION_TAX_REPORT', + eNetErrorAttemptsTimeframeMin: 10, + eNetErrorAttemptsTimeoutMs: 10000, + shouldNotInterrupt: true + }) + + return res + } +} + +decorateInjectable(TransactionTaxReport, depsTypes) + +module.exports = TransactionTaxReport diff --git a/workers/loc.api/ws-transport/ws.event.emitter.js b/workers/loc.api/ws-transport/ws.event.emitter.js index c1ea2481a..4d0f2350c 100644 --- a/workers/loc.api/ws-transport/ws.event.emitter.js +++ b/workers/loc.api/ws-transport/ws.event.emitter.js @@ -138,6 +138,21 @@ class WSEventEmitter extends AbstractWSEventEmitter { }, 'emitBfxUnamePwdAuthRequired') } + emitTrxTaxReportGenerationInBackgroundToOne ( + handler = () => {}, + auth = {} + ) { + return this.emit(async (user, ...args) => { + if (this.isNotTargetUser(auth, user)) { + return { isNotEmitted: true } + } + + return typeof handler === 'function' + ? await handler(user, ...args) + : handler + }, 'emitTrxTaxReportGenerationInBackgroundToOne') + } + async emitRedirectingRequestsStatusToApi ( handler = () => {} ) {