diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js new file mode 100644 index 0000000..bb905c4 --- /dev/null +++ b/tests/unit/handlers/finance.handlers.test.js @@ -0,0 +1,302 @@ +'use strict' + +const test = require('brittle') +const { + getEnergyBalance, + processConsumptionData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processEnergyData, + extractNominalPower, + processCostsData, + calculateSummary +} = require('../../../workers/lib/server/handlers/finance.handlers') + +test('getEnergyBalance - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ type: 'powermeter', data: [{ ts: dayTs, val: { site_power_w: 5000 } }], error: null }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return [{ ts: dayTs, transactions: [{ ts: dayTs, changed_balance: 0.5 }] }] + } + if (payload.query && payload.query.key === 'HISTORICAL_PRICES') { + return [{ ts: dayTs, priceUSD: 40000 }] + } + if (payload.query && payload.query.key === 'current_price') { + return [{ currentPrice: 40000 }] + } + if (payload.query && payload.query.key === 'stats-history') { + return [] + } + } + if (method === 'getGlobalConfig') { + return { nominalPowerAvailability_MW: 10 } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEnergyBalance(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getEnergyBalance - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [], site: 'test-site' }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { end: 1700100000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEnergyBalance - missing end throws', async (t) => { + const mockCtx = { + conf: { orks: [], site: 'test-site' }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { start: 1700000000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEnergyBalance - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [], site: 'test-site' }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { start: 1700100000000, end: 1700000000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getEnergyBalance - empty ork results', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => ({}) + }, + globalDataBee: { + sub: () => ({ + sub: () => ({ + createReadStream: () => (async function * () {})() + }) + }) + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEnergyBalance(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.is(result.log.length, 0, 'log should be empty with no data') + t.pass() +}) + +test('processConsumptionData - processes daily data from ORK', (t) => { + const results = [ + [{ type: 'powermeter', data: [{ ts: 1700006400000, val: { site_power_w: 5000 } }], error: null }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].powerW, 5000, 'should extract power from val') + t.pass() +}) + +test('processConsumptionData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 5000 } } }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processConsumptionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processTransactionData - processes F2Pool data', (t) => { + const results = [ + [{ ts: 1700006400000, transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] + ] + + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') + t.pass() +}) + +test('processTransactionData - processes Ocean data', (t) => { + const results = [ + [{ ts: 1700006400000, transactions: [{ ts: 1700006400, satoshis_net_earned: 50000000 }] }] + ] + + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') + t.pass() +}) + +test('processTransactionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processPriceData - processes mempool price data', (t) => { + const results = [ + [{ ts: 1700006400000, priceUSD: 40000 }] + ] + + const daily = processPriceData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 40000, 'should extract priceUSD') + t.pass() +}) + +test('extractCurrentPrice - extracts currentPrice from mempool data', (t) => { + const results = [ + [{ currentPrice: 42000, blockHeight: 900000 }] + ] + const price = extractCurrentPrice(results) + t.is(price, 42000, 'should extract currentPrice') + t.pass() +}) + +test('extractCurrentPrice - extracts priceUSD', (t) => { + const results = [ + [{ ts: 1700006400000, priceUSD: 42000 }] + ] + const price = extractCurrentPrice(results) + t.is(price, 42000, 'should extract priceUSD') + t.pass() +}) + +test('extractCurrentPrice - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const price = extractCurrentPrice(results) + t.is(price, 0, 'should return 0 for error results') + t.pass() +}) + +test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => { + const costs = [ + { region: 'site1', year: 2023, month: 11, energyCostsUSD: 30000, operationalCostsUSD: 6000 } + ] + + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') + t.pass() +}) + +test('processCostsData - processes app-node format (energyCost)', (t) => { + const costs = [ + { site: 'site1', year: 2023, month: 11, energyCost: 30000, operationalCost: 6000 } + ] + + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') + t.pass() +}) + +test('processCostsData - handles non-array input', (t) => { + const result = processCostsData(null) + t.ok(typeof result === 'object', 'should return object') + t.is(Object.keys(result).length, 0, 'should be empty') + t.pass() +}) + +test('calculateSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, revenueUSD: 20000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100 }, + { revenueBTC: 0.3, revenueUSD: 12000, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60 } + ] + + const summary = calculateSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32000, 'should sum USD revenue') + t.is(summary.totalCostUSD, 8000, 'should sum costs') + t.is(summary.totalProfitUSD, 24000, 'should sum profit') + t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh') + t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh') + t.pass() +}) + +test('calculateSummary - handles empty log', (t) => { + const summary = calculateSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalRevenueUSD, 0, 'should be zero') + t.is(summary.avgCostPerMWh, null, 'should be null') + t.pass() +}) diff --git a/tests/unit/lib/period.utils.test.js b/tests/unit/lib/period.utils.test.js new file mode 100644 index 0000000..46ebaf4 --- /dev/null +++ b/tests/unit/lib/period.utils.test.js @@ -0,0 +1,170 @@ +'use strict' + +const test = require('brittle') +const { + getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} = require('../../../workers/lib/period.utils') + +test('getStartOfDay - returns start of day timestamp', (t) => { + const ts = 1700050000000 + const result = getStartOfDay(ts) + t.ok(result <= ts, 'should be less than or equal to input') + t.is(result % 86400000, 0, 'should be divisible by 86400000') + t.pass() +}) + +test('getStartOfDay - already at start of day', (t) => { + const ts = 1700006400000 + const result = getStartOfDay(ts) + t.is(result, ts, 'should return same timestamp if already start of day') + t.pass() +}) + +test('aggregateByPeriod - returns log unchanged for daily period', (t) => { + const log = [ + { ts: 1700006400000, value: 10 }, + { ts: 1700092800000, value: 20 } + ] + const result = aggregateByPeriod(log, 'daily') + t.is(result.length, 2, 'should return same length') + t.alike(result, log, 'should return same entries') + t.pass() +}) + +test('aggregateByPeriod - aggregates monthly', (t) => { + const log = [ + { ts: 1700006400000, value: 10, region: 'us' }, + { ts: 1700092800000, value: 20, region: 'us' } + ] + const result = aggregateByPeriod(log, 'monthly') + t.ok(result.length >= 1, 'should have at least one aggregated entry') + t.ok(result[0].month, 'should have month field') + t.ok(result[0].year, 'should have year field') + t.pass() +}) + +test('aggregateByPeriod - aggregates yearly', (t) => { + const log = [ + { ts: 1700006400000, value: 10, region: 'us' }, + { ts: 1700092800000, value: 20, region: 'us' } + ] + const result = aggregateByPeriod(log, 'yearly') + t.ok(result.length >= 1, 'should have at least one aggregated entry') + t.ok(result[0].year, 'should have year field') + t.pass() +}) + +test('aggregateByPeriod - handles empty log', (t) => { + const result = aggregateByPeriod([], 'monthly') + t.is(result.length, 0, 'should return empty array') + t.pass() +}) + +test('aggregateByPeriod - handles invalid timestamps', (t) => { + const log = [ + { ts: 'invalid', value: 10 }, + { ts: 1700006400000, value: 20 } + ] + const result = aggregateByPeriod(log, 'monthly') + t.ok(result.length >= 1, 'should skip invalid entries') + t.pass() +}) + +test('getPeriodKey - daily returns start of day', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'daily') + t.is(result % 86400000, 0, 'should be start of day') + t.pass() +}) + +test('getPeriodKey - monthly returns start of month', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'monthly') + const date = new Date(result) + t.is(date.getDate(), 1, 'should be first day of month') + t.pass() +}) + +test('getPeriodKey - yearly returns start of year', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'yearly') + const date = new Date(result) + t.is(date.getMonth(), 0, 'should be January') + t.is(date.getDate(), 1, 'should be first day') + t.pass() +}) + +test('isTimestampInPeriod - daily exact match', (t) => { + const ts = 1700006400000 + t.ok(isTimestampInPeriod(ts, ts, 'daily'), 'should match exact timestamp') + t.ok(!isTimestampInPeriod(ts + 86400000, ts, 'daily'), 'should not match different day') + t.pass() +}) + +test('isTimestampInPeriod - monthly range', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const midMonth = new Date(2023, 10, 15).getTime() + const nextMonth = new Date(2023, 11, 1).getTime() + + t.ok(isTimestampInPeriod(midMonth, monthStart, 'monthly'), 'mid-month should be in period') + t.ok(!isTimestampInPeriod(nextMonth, monthStart, 'monthly'), 'next month should not be in period') + t.pass() +}) + +test('getFilteredPeriodData - daily returns direct lookup', (t) => { + const data = { 1700006400000: { value: 42 } } + const result = getFilteredPeriodData(data, 1700006400000, 'daily', () => null) + t.alike(result, { value: 42 }, 'should return data for timestamp') + t.pass() +}) + +test('getFilteredPeriodData - daily returns empty object for missing with default filterFn', (t) => { + const data = {} + const result = getFilteredPeriodData(data, 1700006400000, 'daily') + t.alike(result, {}, 'should return empty object for missing data with default filterFn') + t.pass() +}) + +test('getFilteredPeriodData - monthly filters with callback', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const day1 = new Date(2023, 10, 5).getTime() + const day2 = new Date(2023, 10, 15).getTime() + const data = { + [day1]: { value: 10 }, + [day2]: { value: 20 } + } + + const result = getFilteredPeriodData(data, monthStart, 'monthly', (entries) => { + return entries.reduce((sum, [, val]) => sum + val.value, 0) + }) + + t.is(result, 30, 'should sum values in period') + t.pass() +}) + +test('convertMsToSeconds - converts milliseconds to seconds', (t) => { + t.is(convertMsToSeconds(1700006400000), 1700006400, 'should convert ms to seconds') + t.is(convertMsToSeconds(1700006400500), 1700006400, 'should floor fractional seconds') + t.pass() +}) + +test('getPeriodEndDate - monthly returns next month', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const result = getPeriodEndDate(monthStart, 'monthly') + t.is(result.getMonth(), 11, 'should be next month') + t.is(result.getFullYear(), 2023, 'should be same year') + t.pass() +}) + +test('getPeriodEndDate - yearly returns next year', (t) => { + const yearStart = new Date(2023, 0, 1).getTime() + const result = getPeriodEndDate(yearStart, 'yearly') + t.is(result.getFullYear(), 2024, 'should be next year') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js new file mode 100644 index 0000000..f87c47f --- /dev/null +++ b/tests/unit/routes/finance.routes.test.js @@ -0,0 +1,57 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/finance.routes.js' + +test('finance routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'finance') + t.pass() +}) + +test('finance routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/finance/energy-balance'), 'should have energy-balance route') + + t.pass() +}) + +test('finance routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.is(route.method, 'GET', `route ${route.url} should be GET`) + }) + + t.pass() +}) + +test('finance routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routesWithSchemas = routes.filter(route => route.schema) + routesWithSchemas.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + if (route.schema.querystring) { + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + } + }) + + t.pass() +}) + +test('finance routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'finance') + t.pass() +}) + +test('finance routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'finance') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f1f407e..622b8df 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -108,7 +108,10 @@ const ENDPOINTS = { THING_CONFIG: '/auth/thing-config', // WebSocket endpoint - WEBSOCKET: '/ws' + WEBSOCKET: '/ws', + + // Finance endpoints + FINANCE_ENERGY_BALANCE: '/auth/finance/energy-balance' } const HTTP_METHODS = { @@ -183,6 +186,53 @@ const STATUS_CODES = { INTERNAL_SERVER_ERROR: 500 } +const RPC_METHODS = { + TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', + GET_WRK_EXT_DATA: 'getWrkExtData', + LIST_THINGS: 'listThings', + TAIL_LOG: 'tailLog', + GLOBAL_CONFIG: 'getGlobalConfig' +} + +const WORKER_TYPES = { + MINER: 'miner', + POWERMETER: 'powermeter', + MINERPOOL: 'minerpool', + MEMPOOL: 'mempool', + ELECTRICITY: 'electricity' +} + +const AGGR_FIELDS = { + HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', + SITE_POWER: 'site_power_w', + ENERGY_AGGR: 'energy_aggr', + ACTIVE_ENERGY_IN: 'active_energy_in_aggr', + UTE_ENERGY: 'ute_energy_aggr' +} + +const PERIOD_TYPES = { + DAILY: 'daily', + WEEKLY: 'weekly', + MONTHLY: 'monthly', + YEARLY: 'yearly' +} + +const MINERPOOL_EXT_DATA_KEYS = { + TRANSACTIONS: 'transactions', + STATS: 'stats' +} + +const NON_METRIC_KEYS = [ + 'ts', + 'site', + 'year', + 'monthName', + 'month', + 'period' +] + +const BTC_SATS = 100000000 + const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 @@ -202,5 +252,12 @@ module.exports = { STATUS_CODES, RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, - USER_SETTINGS_TYPE + USER_SETTINGS_TYPE, + RPC_METHODS, + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + NON_METRIC_KEYS, + BTC_SATS } diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js new file mode 100644 index 0000000..63a1144 --- /dev/null +++ b/workers/lib/period.utils.js @@ -0,0 +1,176 @@ +'use strict' + +const { PERIOD_TYPES, NON_METRIC_KEYS } = require('./constants') + +const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 + +const convertMsToSeconds = (timestampMs) => { + return Math.floor(timestampMs / 1000) +} + +const PERIOD_CALCULATORS = { + daily: (timestamp) => getStartOfDay(timestamp), + monthly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), date.getMonth(), 1).getTime() + }, + yearly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), 0, 1).getTime() + } +} + +const aggregateByPeriod = (log, period, nonMetricKeys = []) => { + if (period === PERIOD_TYPES.DAILY) { + return log + } + + const allNonMetricKeys = new Set([...NON_METRIC_KEYS, ...nonMetricKeys]) + + const grouped = log.reduce((acc, entry) => { + let date + try { + date = new Date(Number(entry.ts)) + + if (isNaN(date.getTime())) { + return acc + } + } catch (error) { + return acc + } + + let groupKey + + if (period === PERIOD_TYPES.MONTHLY) { + groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + } else if (period === PERIOD_TYPES.YEARLY) { + groupKey = `${date.getFullYear()}` + } else { + groupKey = `${entry.ts}` + } + + if (!acc[groupKey]) { + acc[groupKey] = [] + } + acc[groupKey].push(entry) + return acc + }, {}) + + const aggregatedResults = Object.entries(grouped).map(([groupKey, entries]) => { + const aggregated = entries.reduce((acc, entry) => { + Object.entries(entry).forEach(([key, val]) => { + if (allNonMetricKeys.has(key)) { + if (!acc[key] || acc[key] === null || acc[key] === undefined) { + acc[key] = val + } + } else { + const numVal = Number(val) || 0 + acc[key] = (acc[key] || 0) + numVal + } + }) + return acc + }, {}) + + try { + if (period === PERIOD_TYPES.MONTHLY) { + const [year, month] = groupKey.split('-').map(Number) + + const newDate = new Date(year, month - 1, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for monthly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.month = month + aggregated.year = year + aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + const year = parseInt(groupKey) + + const newDate = new Date(year, 0, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for yearly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.year = year + } + } catch (error) { + aggregated.ts = entries[0].ts + + try { + const fallbackDate = new Date(Number(entries[0].ts)) + if (!isNaN(fallbackDate.getTime())) { + if (period === PERIOD_TYPES.MONTHLY) { + aggregated.month = fallbackDate.getMonth() + 1 + aggregated.year = fallbackDate.getFullYear() + aggregated.monthName = fallbackDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + aggregated.year = fallbackDate.getFullYear() + } + } + } catch (fallbackError) { + console.warn('Could not extract date info from fallback timestamp', fallbackError) + } + } + + return aggregated + }) + + return aggregatedResults.sort((a, b) => Number(b.ts) - Number(a.ts)) +} + +const getPeriodKey = (timestamp, period) => { + const calculator = PERIOD_CALCULATORS[period] || PERIOD_CALCULATORS.daily + return calculator(timestamp) +} + +const getPeriodEndDate = (periodTs, period) => { + const periodEnd = new Date(periodTs) + + switch (period) { + case PERIOD_TYPES.MONTHLY: + periodEnd.setMonth(periodEnd.getMonth() + 1) + break + case PERIOD_TYPES.YEARLY: + periodEnd.setFullYear(periodEnd.getFullYear() + 1) + break + } + + return periodEnd +} + +const isTimestampInPeriod = (timestamp, periodTs, period) => { + if (period === PERIOD_TYPES.DAILY) return timestamp === periodTs + + const periodEnd = getPeriodEndDate(periodTs, period) + return timestamp >= periodTs && timestamp < periodEnd.getTime() +} + +const getFilteredPeriodData = ( + sourceData, + periodTs, + period, + filterFn = (entries) => entries +) => { + if (period === PERIOD_TYPES.DAILY) { + return sourceData[periodTs] || (typeof filterFn === 'function' ? {} : 0) + } + + const entriesInPeriod = Object.entries(sourceData).filter(([tsStr]) => { + const timestamp = Number(tsStr) + return isTimestampInPeriod(timestamp, periodTs, period) + }) + + return filterFn(entriesInPeriod, sourceData) +} + +module.exports = { + getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js new file mode 100644 index 0000000..a5abcc1 --- /dev/null +++ b/workers/lib/server/handlers/finance.handlers.js @@ -0,0 +1,403 @@ +'use strict' + +const { + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + RPC_METHODS, + BTC_SATS, + GLOBAL_DATA_TYPES +} = require('../../constants') +const { + requestRpcEachLimit, + getStartOfDay, + safeDiv, + runParallel +} = require('../../utils') +const { aggregateByPeriod } = require('../../period.utils') + +async function getEnergyBalance (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.DAILY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [ + consumptionResults, + transactionResults, + priceResults, + currentPriceResults, + productionCosts, + activeEnergyInResults, + uteEnergyResults, + globalConfigResults + ] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }] + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_PRICES', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => getProductionCosts(ctx, req.query.site, start, end) + .then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) + .then(r => cb(null, r)).catch(cb) + ]) + + const dailyConsumption = processConsumptionData(consumptionResults) + const dailyTransactions = processTransactionData(transactionResults) + const dailyPrices = processPriceData(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const costsByMonth = processCostsData(productionCosts) + const dailyActiveEnergyIn = processEnergyData(activeEnergyInResults, AGGR_FIELDS.ACTIVE_ENERGY_IN) + const dailyUteEnergy = processEnergyData(uteEnergyResults, AGGR_FIELDS.UTE_ENERGY) + const nominalPowerMW = extractNominalPower(globalConfigResults) + + const allDays = new Set([ + ...Object.keys(dailyConsumption), + ...Object.keys(dailyTransactions) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const consumption = dailyConsumption[dayTs] || {} + const transactions = dailyTransactions[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + + const powerW = consumption.powerW || 0 + const powerMWh = (powerW * 24) / 1000000 + const revenueBTC = transactions.revenueBTC || 0 + const revenueUSD = revenueBTC * btcPrice + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostUSD = costs.energyCostPerDay || 0 + const totalCostUSD = energyCostUSD + (costs.operationalCostPerDay || 0) + + const activeEnergyIn = dailyActiveEnergyIn[dayTs] || 0 + const uteEnergy = dailyUteEnergy[dayTs] || 0 + const consumptionMWh = powerMWh + + const curtailmentMWh = activeEnergyIn > 0 + ? activeEnergyIn - consumptionMWh + : null + const curtailmentRate = curtailmentMWh !== null + ? safeDiv(curtailmentMWh, consumptionMWh) + : null + + const operationalIssuesRate = uteEnergy > 0 + ? safeDiv(uteEnergy - consumptionMWh, uteEnergy) + : null + + const actualPowerMW = powerW / 1000000 + const powerUtilization = nominalPowerMW > 0 + ? safeDiv(actualPowerMW, nominalPowerMW) + : null + + log.push({ + ts, + powerW, + consumptionMWh, + revenueBTC, + revenueUSD, + btcPrice, + energyCostUSD, + totalCostUSD, + energyRevenuePerMWh: safeDiv(revenueUSD, powerMWh), + allInCostPerMWh: safeDiv(totalCostUSD, powerMWh), + profitUSD: revenueUSD - totalCostUSD, + curtailmentMWh, + curtailmentRate, + operationalIssuesRate, + powerUtilization + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateSummary(aggregated) + + return { log: aggregated, summary } +} + +function processConsumptionData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry || entry.error) continue + const items = entry.data || entry.items || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue + if (!daily[ts]) daily[ts] = { powerW: 0 } + const val = item.val || item + daily[ts].powerW += (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) + } + } else if (typeof items === 'object') { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { powerW: 0 } + const power = typeof val === 'object' ? (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) : (Number(val) || 0) + daily[ts].powerW += power + } + } + } + } + return daily +} + +function normalizeTimestampMs (ts) { + if (!ts) return 0 + return ts < 1e12 ? ts * 1000 : ts +} + +function processTransactionData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const tx of data) { + if (!tx) continue + const txList = tx.data || tx.transactions || tx + if (!Array.isArray(txList)) continue + for (const t of txList) { + if (!t) continue + const rawTs = t.ts || t.created_at || t.timestamp || t.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + const day = daily[ts] ??= { revenueBTC: 0 } + if (t.satoshis_net_earned) { + day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS + } else { + day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) + } + } + } + } + return daily +} + +function processPriceData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const rawTs = entry.ts || entry.timestamp || entry.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + const price = entry.priceUSD || entry.price + if (ts && price) { + daily[ts] = price + } + } + } + return daily +} + +function extractCurrentPrice (results) { + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.currentPrice) return entry.currentPrice + if (entry.priceUSD) return entry.priceUSD + if (entry.price) return entry.price + } + } + return 0 +} + +function processEnergyData (results, aggrField) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = Array.isArray(entry) ? entry : (entry.data || entry) + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue + const energyAggr = item[AGGR_FIELDS.ENERGY_AGGR] + if (energyAggr && energyAggr[aggrField]) { + daily[ts] = (daily[ts] || 0) + Number(energyAggr[aggrField]) + } + } + } + } + } + return daily +} + +function extractNominalPower (results) { + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.nominalPowerAvailability_MW) return entry.nominalPowerAvailability_MW + } + } + return 0 +} + +async function getProductionCosts (ctx, site, start, end) { + if (!ctx.globalDataLib) return [] + const costs = await ctx.globalDataLib.getGlobalData({ + type: GLOBAL_DATA_TYPES.PRODUCTION_COSTS + }) + if (!Array.isArray(costs)) return [] + + const startDate = new Date(start) + const endDate = new Date(end) + return costs.filter(entry => { + if (!entry || !entry.year || !entry.month) return false + if (site && entry.site !== site) return false + const entryDate = new Date(entry.year, entry.month - 1, 1) + return entryDate >= startDate && entryDate <= endDate + }) +} + +function processCostsData (costs) { + const byMonth = {} + if (!Array.isArray(costs)) return byMonth + for (const entry of costs) { + if (!entry || !entry.year || !entry.month) continue + const key = `${entry.year}-${String(entry.month).padStart(2, '0')}` + const daysInMonth = new Date(entry.year, entry.month, 0).getDate() + byMonth[key] = { + energyCostPerDay: (entry.energyCost || entry.energyCostsUSD || 0) / daysInMonth, + operationalCostPerDay: (entry.operationalCost || entry.operationalCostsUSD || 0) / daysInMonth + } + } + return byMonth +} + +function calculateSummary (log) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalCostUSD: 0, + totalProfitUSD: 0, + avgCostPerMWh: null, + avgRevenuePerMWh: null, + totalConsumptionMWh: 0, + avgCurtailmentRate: null, + avgOperationalIssuesRate: null, + avgPowerUtilization: null + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.costUSD += entry.totalCostUSD || 0 + acc.profitUSD += entry.profitUSD || 0 + acc.consumptionMWh += entry.consumptionMWh || 0 + if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) { + acc.curtailmentRateSum += entry.curtailmentRate + acc.curtailmentRateCount++ + } + if (entry.operationalIssuesRate !== null && entry.operationalIssuesRate !== undefined) { + acc.operationalIssuesRateSum += entry.operationalIssuesRate + acc.operationalIssuesRateCount++ + } + if (entry.powerUtilization !== null && entry.powerUtilization !== undefined) { + acc.powerUtilizationSum += entry.powerUtilization + acc.powerUtilizationCount++ + } + return acc + }, { + revenueBTC: 0, + revenueUSD: 0, + costUSD: 0, + profitUSD: 0, + consumptionMWh: 0, + curtailmentRateSum: 0, + curtailmentRateCount: 0, + operationalIssuesRateSum: 0, + operationalIssuesRateCount: 0, + powerUtilizationSum: 0, + powerUtilizationCount: 0 + }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalCostUSD: totals.costUSD, + totalProfitUSD: totals.profitUSD, + avgCostPerMWh: safeDiv(totals.costUSD, totals.consumptionMWh), + avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), + totalConsumptionMWh: totals.consumptionMWh, + avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount), + avgOperationalIssuesRate: safeDiv(totals.operationalIssuesRateSum, totals.operationalIssuesRateCount), + avgPowerUtilization: safeDiv(totals.powerUtilizationSum, totals.powerUtilizationCount) + } +} + +module.exports = { + getEnergyBalance, + getProductionCosts, + processConsumptionData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processEnergyData, + extractNominalPower, + processCostsData, + calculateSummary +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index ac884da..eb67811 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -8,6 +8,7 @@ const globalRoutes = require('./routes/global.routes') const thingsRoutes = require('./routes/things.routes') const settingsRoutes = require('./routes/settings.routes') const wsRoutes = require('./routes/ws.routes') +const financeRoutes = require('./routes/finance.routes') /** * Collect all routes into a flat array for server injection. @@ -22,7 +23,8 @@ function routes (ctx) { ...thingsRoutes(ctx), ...usersRoutes(ctx), ...settingsRoutes(ctx), - ...wsRoutes(ctx) + ...wsRoutes(ctx), + ...financeRoutes(ctx) ] } diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js new file mode 100644 index 0000000..4efc740 --- /dev/null +++ b/workers/lib/server/routes/finance.routes.js @@ -0,0 +1,36 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getEnergyBalance +} = require('../handlers/finance.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/finance.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_ENERGY_BALANCE, + schema: { + querystring: schemas.query.energyBalance + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/energy-balance', + req.query.start, + req.query.end, + req.query.period, + req.query.site + ], + ENDPOINTS.FINANCE_ENERGY_BALANCE, + getEnergyBalance + ) + } + ] +} diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js new file mode 100644 index 0000000..3d2ff42 --- /dev/null +++ b/workers/lib/server/schemas/finance.schemas.js @@ -0,0 +1,19 @@ +'use strict' + +const schemas = { + query: { + energyBalance: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + site: { type: 'string' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 886ae5f..6649b46 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -2,6 +2,7 @@ const async = require('async') const { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT } = require('./constants') +const { getStartOfDay } = require('./period.utils') const dateNowSec = () => Math.floor(Date.now() / 1000) @@ -128,6 +129,21 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } +const runParallel = (tasks) => + new Promise((resolve, reject) => { + async.parallel(tasks, (err, results) => { + if (err) reject(err) + else resolve(results) + }) + }) + +const safeDiv = (numerator, denominator) => + typeof numerator === 'number' && + typeof denominator === 'number' && + denominator !== 0 + ? numerator / denominator + : null + module.exports = { dateNowSec, extractIps, @@ -137,5 +153,8 @@ module.exports = { getAuthTokenFromHeaders, parseJsonQueryParam, requestRpcEachLimit, - requestRpcMapLimit + requestRpcMapLimit, + getStartOfDay, + safeDiv, + runParallel }