diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index 4252943..4826811 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -3876,3 +3876,69 @@ test('getInventoryMinerDistribution - empty fleet', async (t) => { t.alike(result, { rows: [], totalMiners: 0 }) t.pass() }) + +// ==================== Hashrate Pagination Tests ==================== + +const HOUR_MS = 3600000 +const PAGING_START = Date.UTC(2026, 7, 1) + +function pagingCtx (buckets = 5) { + return withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => Array.from({ length: buckets }, (_, i) => ({ + ts: PAGING_START + i * HOUR_MS, + hashrate_mhs_5m_sum_aggr: 1e11, + nominal_hashrate_mhs_sum_aggr: 1.25e11 + })) + } + }) +} + +const pagingQuery = { + start: PAGING_START, + end: PAGING_START + 5 * HOUR_MS, + interval: '1h', + nominal: true +} + +test('getHashrate - totalCount spans the range, summary is not paged', async (t) => { + const ctx = pagingCtx() + + const full = await getHashrate(ctx, { query: pagingQuery }) + const page = await getHashrate(ctx, { query: { ...pagingQuery, offset: 1, limit: 2 } }) + + t.is(full.totalCount, 5, 'unpaged calls report the total too') + t.is(page.totalCount, 5, 'total counts every bucket, not the page') + t.is(page.log.length, 2, 'page holds only the requested slice') + t.alike(page.log.map(e => e.ts), [PAGING_START + HOUR_MS, PAGING_START + 2 * HOUR_MS]) + t.alike(page.summary, full.summary, 'summary stays computed over the full range') + t.is(page.summary.avgPctOfNominal, 80, 'nominal summary survives paging') + t.pass() +}) + +test('getHashrate - reverse pages newest first', async (t) => { + const result = await getHashrate(pagingCtx(), { query: { ...pagingQuery, reverse: true, limit: 2 } }) + + t.alike(result.log.map(e => e.ts), [PAGING_START + 4 * HOUR_MS, PAGING_START + 3 * HOUR_MS]) + t.is(result.totalCount, 5) + t.pass() +}) + +test('getHashrate - offset past the end yields an empty page', async (t) => { + const result = await getHashrate(pagingCtx(), { query: { ...pagingQuery, offset: 99 } }) + + t.alike(result.log, []) + t.is(result.totalCount, 5, 'the count still describes the range') + t.pass() +}) + +test('getHashrate - grouped results are paged the same way', async (t) => { + const result = await getHashrate(pagingCtx(), { + query: { ...pagingQuery, groupBy: 'container', offset: 3 } + }) + + t.is(result.totalCount, 5) + t.is(result.log.length, 2, 'grouped log honours offset') + t.pass() +}) diff --git a/tests/unit/lib/invoicing.export.test.js b/tests/unit/lib/invoicing.export.test.js new file mode 100644 index 0000000..d988048 --- /dev/null +++ b/tests/unit/lib/invoicing.export.test.js @@ -0,0 +1,165 @@ +'use strict' + +const test = require('brittle') +const { getExportType, resolveExport, EXPORT_TYPES } = require('../../../workers/lib/server/lib/export/registry') +const { withDataProxy } = require('../helpers/mockHelpers') + +const HOUR_MS = 3600000 +const DAY_MS = 24 * HOUR_MS +const START = Date.UTC(2026, 7, 1) +const MHS = 1e11 +const NOMINAL_MHS = 1.25e11 + +function mockCtx ({ buckets = 3, interval = HOUR_MS, globalData = {}, hashrateMhs = MHS } = {}) { + return withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + globalDataLib: { getGlobalData: async ({ type }) => globalData[type] }, + net_r0: { + jRequest: async (key, method, params) => Array.from({ length: buckets }, (_, i) => ({ + ts: START + i * interval, + ...(params.type === 'powermeter' + ? { site_power_w: 10e6 } + : { hashrate_mhs_5m_sum_aggr: hashrateMhs, nominal_hashrate_mhs_sum_aggr: NOMINAL_MHS }) + })) + } + }) +} + +async function runExport (type, params, ctxOpts) { + const entry = getExportType(type) + entry.assertParams(params) + const { filename, stream } = await resolveExport(mockCtx(ctxOpts), entry, params) + let out = '' + for await (const chunk of stream) out += chunk + return { filename, out } +} + +test('invoicing exports are registered as reporting exports', async (t) => { + for (const type of ['invoicing-hourly-hashes', 'invoicing-daily-hashes', 'invoice-breakdown']) { + t.ok(EXPORT_TYPES.includes(type), `${type} is an accepted export type`) + t.alike(getExportType(type).perms, ['reporting:r'], `${type} is gated on reporting`) + } + t.pass() +}) + +test('invoicing exports require a valid range', async (t) => { + const entry = getExportType('invoice-breakdown') + + t.exception(() => entry.assertParams({}), /ERR_EXPORT_RANGE_REQUIRED/) + t.exception(() => entry.assertParams({ start: 2 }), /ERR_EXPORT_RANGE_REQUIRED/, 'an absent end is not a range') + t.exception(() => entry.assertParams({ start: 0, end: START }), /ERR_EXPORT_RANGE_REQUIRED/) + t.exception(() => entry.assertParams({ start: 2, end: 1 }), /ERR_EXPORT_RANGE_INVALID/) + t.exception(() => entry.assertParams({ start: START, end: START }), /ERR_EXPORT_RANGE_INVALID/, 'an empty range is rejected here, not deeper as a 500') + t.pass() +}) + +test('invoicing exports round every figure to three decimals', async (t) => { + const { out } = await runExport( + 'invoicing-hourly-hashes', + { start: START, end: START + HOUR_MS, timezone: 'UTC', format: 'csv' }, + { buckets: 1, hashrateMhs: 123456789012.3 } + ) + + t.is(out.split('\n')[1], '"01/08/2026","00:00","444.444","98.765","123.457"', 'matches the precision the UI exports') + t.pass() +}) + +test('invoicing-hourly-hashes - one row per hour, EH delivered over the hour', async (t) => { + const { filename, out } = await runExport( + 'invoicing-hourly-hashes', + { start: START, end: START + 3 * HOUR_MS, timezone: 'UTC', format: 'csv' }, + { buckets: 3 } + ) + const lines = out.split('\n') + + t.ok(filename.startsWith('invoicing_hourly_hashes_'), 'filename names the export') + t.is(lines[0], 'date,hour,hashesDeliveredEh,pctOfNominal,avgHashratePhs') + t.is(lines[1], '"01/08/2026","00:00","360","80","100"', '1e11 MH/s x 3600 / 1e12 = 360 EH') + t.is(lines[3], '"01/08/2026","02:00","360","80","100"') + t.is(lines.length, 4, 'header plus one row per bucket') + t.pass() +}) + +test('invoicing-daily-hashes - one row per day, EH delivered over the day', async (t) => { + const { out } = await runExport( + 'invoicing-daily-hashes', + { start: START, end: START + 2 * DAY_MS, timezone: 'UTC', format: 'csv' }, + { buckets: 2, interval: DAY_MS } + ) + const lines = out.split('\n') + + t.is(lines[0], 'month,day,hashesDeliveredEh,pctOfNominal,avgHashratePhs') + t.is(lines[1], '"August","01","8640","80","100"', '1e11 MH/s x 86400 / 1e12 = 8640 EH') + t.is(lines[2], '"August","02","8640","80","100"') + t.pass() +}) + +test('invoice-breakdown - one row, margin applied over energy, ops and payable amortization', async (t) => { + const { out } = await runExport( + 'invoice-breakdown', + { start: START, end: START + 2 * DAY_MS, timezone: 'UTC', format: 'json' }, + { + buckets: 2, + interval: DAY_MS, + globalData: { + costParameters: { + lcoe: { effectiveUsdPerMwh: 50 }, + minerAmortizationUsd: 100000, + infraAmortizationUsd: 50000, + marginPct: 10 + }, + productionCosts: [{ site: 'site', year: 2026, month: 8, operationalCost: 5000 }] + } + } + ) + const row = JSON.parse(out).breakdown[0] + + t.is(row.year, 2026) + t.is(row.month, 8) + t.is(row.energyConsumedMwh, 480, '10 MW over two daily buckets') + t.is(row.energyCostsUsd, 24000, '480 MWh x 50 USD/MWh') + t.is(row.operationalCostUsd, 5000, 'read from the month production costs') + t.is(row.pctOfNominal, 80, 'range-wide delivered percentage') + t.is(row.amortizationUsd, 150000) + t.is(row.amortizationPayableUsd, 120000, '80% of the amortization is payable') + t.is(row.marginUsd, 14900, '10% of energy + ops + payable amortization') + t.is(row.monthlyInvoiceUsd, 163900) + t.pass() +}) + +test('invoice-breakdown - the month is read in UTC, not in the label timezone', async (t) => { + const globalData = { + costParameters: { overrides: { '2026-07': { lcoe: { effectiveUsdPerMwh: 42 } }, '2026-08': { lcoe: { effectiveUsdPerMwh: 50 } } } }, + productionCosts: [{ site: 'site', year: 2026, month: 8, operationalCost: 5000 }] + } + const params = { start: START, end: START + 2 * DAY_MS, format: 'json' } + const ctxOpts = { buckets: 2, interval: DAY_MS, globalData } + + const utc = JSON.parse((await runExport('invoice-breakdown', { ...params, timezone: 'UTC' }, ctxOpts)).out) + const local = JSON.parse((await runExport('invoice-breakdown', { ...params, timezone: 'America/Sao_Paulo' }, ctxOpts)).out) + + t.alike(local.breakdown[0], utc.breakdown[0], 'a west-of-UTC label timezone bills the same month') + t.is(local.breakdown[0].month, 8, 'the UTC month start belongs to August') + t.is(local.breakdown[0].lcoeUsdPerMwh, 50, 'August override, not July') + t.is(local.breakdown[0].operationalCostUsd, 5000) + t.pass() +}) + +test('invoice-breakdown - a missing input nulls its dependents, never zeroes them', async (t) => { + const { out } = await runExport( + 'invoice-breakdown', + { start: START, end: START + 2 * DAY_MS, timezone: 'UTC', format: 'json' }, + { buckets: 2, interval: DAY_MS, globalData: { costParameters: { marginPct: 10 } } } + ) + const row = JSON.parse(out).breakdown[0] + + t.is(row.energyConsumedMwh, 480, 'measured inputs still report') + t.is(row.lcoeUsdPerMwh, null) + t.is(row.energyCostsUsd, null, 'no LCOE means no energy cost, not a free month') + t.is(row.operationalCostUsd, null, 'no saved production costs for the month') + t.is(row.amortizationUsd, null) + t.is(row.amortizationPayableUsd, null) + t.is(row.marginUsd, null) + t.is(row.monthlyInvoiceUsd, null) + t.pass() +}) diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index 7ffb545..f1cc51c 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -82,7 +82,24 @@ async function getCurrentHashrate (ctx, aggrField, container) { return entry ? readHashrate(entry[aggrField], container) : null } +function pageHashrate (req, { log, summary }) { + const offset = Number(req.query.offset) || 0 + const limit = Number(req.query.limit) || undefined + const reverse = req.query.reverse === true || req.query.reverse === 'true' + const sorted = log.slice().sort((a, b) => reverse ? b.ts - a.ts : a.ts - b.ts) + + return { + log: limit ? sorted.slice(offset, offset + limit) : sorted.slice(offset), + totalCount: log.length, + summary + } +} + async function getHashrate (ctx, req) { + return pageHashrate(req, await resolveHashrate(ctx, req)) +} + +async function resolveHashrate (ctx, req) { const { start, end } = validateStartEnd(req) if (req.query.groupBy) return getGoupedHashrate(ctx, req) diff --git a/workers/lib/server/lib/export/registry.js b/workers/lib/server/lib/export/registry.js index 81a996b..8c7cff3 100644 --- a/workers/lib/server/lib/export/registry.js +++ b/workers/lib/server/lib/export/registry.js @@ -6,13 +6,17 @@ const minerStats = require('./types/minerStats.export') const containerMinerStats = require('./types/containerMinerStats.export') const { forecastOverview, historicalForecast } = require('./types/forecast.export') const historicalMinerKpi = require('./types/historicalMinerKpi.export') +const { invoicingHourlyHashes, invoicingDailyHashes, invoiceBreakdown } = require('./types/invoicing.export') const TYPES = [ minerStats, containerMinerStats, forecastOverview, historicalForecast, - historicalMinerKpi + historicalMinerKpi, + invoicingHourlyHashes, + invoicingDailyHashes, + invoiceBreakdown ] const REGISTRY = new Map(TYPES.map((entry) => [entry.type, entry])) diff --git a/workers/lib/server/lib/export/types/invoicing.export.js b/workers/lib/server/lib/export/types/invoicing.export.js new file mode 100644 index 0000000..c7bbe84 --- /dev/null +++ b/workers/lib/server/lib/export/types/invoicing.export.js @@ -0,0 +1,205 @@ +'use strict' + +const { METRICS_TIME } = require('../../../../constants') +const { getHashrate, getConsumption } = require('../../../handlers/metrics.handlers') +const { + getCostParameters, + getProductionCosts, + resolveCostParametersForMonth +} = require('../../../handlers/finance.handlers') +const { formatDateTime } = require('../mappers') + +const SECONDS = { hour: 3600, day: 86400 } +const EXPORT_PRECISION = 3 + +const BREAKDOWN_COLUMNS = [ + 'year', 'month', 'energyConsumedMwh', 'lcoeUsdPerMwh', 'energyCostsUsd', 'operationalCostUsd', + 'pctOfNominal', 'minerAmortizationUsd', 'infraAmortizationUsd', 'amortizationUsd', + 'amortizationPayableUsd', 'marginPct', 'marginUsd', 'monthlyInvoiceUsd' +] + +function isRangeTs (value) { + return Number.isFinite(value) && value > 0 +} + +// Same bounds as validateStartEnd, which every fetch below runs into: without +// them a degenerate range fails there instead, as a 500 rather than a 400. +function assertRange (params) { + if (!isRangeTs(params.start) || !isRangeTs(params.end)) { + throw new Error('ERR_EXPORT_RANGE_REQUIRED') + } + if (params.start >= params.end) throw new Error('ERR_EXPORT_RANGE_INVALID') +} + +function dateParts (ts, timezone) { + const parts = {} + const formatted = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit' + }).formatToParts(new Date(ts)) + for (const { type, value } of formatted) parts[type] = value + return parts +} + +function monthName (ts, timezone) { + return new Intl.DateTimeFormat('en-US', { timeZone: timezone, month: 'long' }).format(new Date(ts)) +} + +// The UI rounds every exported figure to 3 decimals; matching it keeps a CSV +// pulled from the API identical to one saved from the invoice screen. +function roundRow (row) { + return Object.fromEntries(Object.entries(row).map( + ([column, value]) => [column, typeof value === 'number' ? Number(value.toFixed(EXPORT_PRECISION)) : value] + )) +} + +function num (value) { + if (value === null || value === undefined || value === '') return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +// Any missing input propagates as null, never 0: an absent cost parameter must +// not read as a free month on the invoice. +function derive (inputs, fn) { + return inputs.some((value) => value === null) ? null : fn(...inputs) +} + +function buildHashesEntry ({ type, interval, seconds, filenamePrefix, periodColumns, mapPeriod }) { + return { + type, + perms: ['reporting:r'], + jsonRootKey: 'hashes', + columns: [...periodColumns, 'hashesDeliveredEh', 'pctOfNominal', 'avgHashratePhs'], + filenamePrefix () { + return filenamePrefix + }, + assertParams: assertRange, + async fetchExport (ctx, { params, now, timezone }) { + const { log } = await getHashrate(ctx, { + query: { start: params.start, end: params.end, interval, nominal: true } + }) + + async function * rows () { + for (const entry of log) { + const hashrateMhs = num(entry.hashrateMhs) + yield roundRow({ + ...mapPeriod(entry.ts, timezone), + hashesDeliveredEh: derive([hashrateMhs], (mhs) => (mhs * seconds) / 1e12), + pctOfNominal: num(entry.pctOfNominal), + avgHashratePhs: derive([hashrateMhs], (mhs) => mhs / 1e9) + }) + } + } + + return { + rows: rows(), + jsonMeta: { dateExported: formatDateTime(now, timezone) } + } + } + } +} + +const invoicingHourlyHashes = buildHashesEntry({ + type: 'invoicing-hourly-hashes', + interval: '1h', + seconds: SECONDS.hour, + filenamePrefix: 'invoicing_hourly_hashes_', + periodColumns: ['date', 'hour'], + mapPeriod (ts, timezone) { + const parts = dateParts(ts, timezone) + return { date: `${parts.day}/${parts.month}/${parts.year}`, hour: `${parts.hour}:00` } + } +}) + +const invoicingDailyHashes = buildHashesEntry({ + type: 'invoicing-daily-hashes', + interval: '1d', + seconds: SECONDS.day, + filenamePrefix: 'invoicing_daily_hashes_', + periodColumns: ['month', 'day'], + mapPeriod (ts, timezone) { + return { month: monthName(ts, timezone), day: dateParts(ts, timezone).day } + } +}) + +const invoiceBreakdown = { + type: 'invoice-breakdown', + perms: ['reporting:r'], + jsonRootKey: 'breakdown', + columns: BREAKDOWN_COLUMNS, + filenamePrefix () { + return 'invoice_breakdown_' + }, + assertParams: assertRange, + async fetchExport (ctx, { params, now, timezone }) { + const { start, end } = params + const [hashrate, consumption, costParameters, productionCosts] = await Promise.all([ + getHashrate(ctx, { query: { start, end, interval: '1d', nominal: true } }), + getConsumption(ctx, { query: { start, end, interval: '1d' } }), + getCostParameters(ctx), + // Widened by a day on each side because getProductionCosts compares month + // starts built in local time against the requested range. + getProductionCosts(ctx, start - METRICS_TIME.ONE_DAY_MS, end + METRICS_TIME.ONE_DAY_MS) + ]) + + // The invoice month is read in UTC, not in the requested timezone: the UI asks + // for exact UTC month bounds and only sends its own timezone to label rows, so + // resolving the month locally would bill a west-of-UTC site against the month before. + const { year, month } = dateParts(start, 'UTC') + const resolved = resolveCostParametersForMonth(costParameters, `${year}-${month}`) + const costs = productionCosts.find( + (entry) => Number(entry.year) === Number(year) && Number(entry.month) === Number(month) + ) + + const energyConsumedMwh = consumption.summary.avgPowerW === null + ? null + : num(consumption.summary.totalConsumptionMWh) + const lcoeUsdPerMwh = num(resolved.lcoe?.effectiveUsdPerMwh) + const energyCostsUsd = derive([energyConsumedMwh, lcoeUsdPerMwh], (mwh, lcoe) => mwh * lcoe) + const operationalCostUsd = num(costs?.operationalCost ?? costs?.operationalCostsUSD) + const pctOfNominal = num(hashrate.summary.avgPctOfNominal) + const minerAmortizationUsd = num(resolved.minerAmortizationUsd) + const infraAmortizationUsd = num(resolved.infraAmortizationUsd) + const amortizationUsd = derive([minerAmortizationUsd, infraAmortizationUsd], (miner, infra) => miner + infra) + const amortizationPayableUsd = derive([pctOfNominal, amortizationUsd], (pct, total) => (pct / 100) * total) + const marginPct = num(resolved.marginPct) + const baseUsd = derive( + [energyCostsUsd, operationalCostUsd, amortizationPayableUsd], + (energy, operational, payable) => energy + operational + payable + ) + const marginUsd = derive([marginPct, baseUsd], (pct, total) => (pct / 100) * total) + + const row = { + year: Number(year), + month: Number(month), + energyConsumedMwh, + lcoeUsdPerMwh, + energyCostsUsd, + operationalCostUsd, + pctOfNominal, + minerAmortizationUsd, + infraAmortizationUsd, + amortizationUsd, + amortizationPayableUsd, + marginPct, + marginUsd, + monthlyInvoiceUsd: derive([baseUsd, marginUsd], (total, margin) => total + margin) + } + + async function * rows () { + yield roundRow(row) + } + + return { + rows: rows(), + jsonMeta: { dateExported: formatDateTime(now, timezone) } + } + } +} + +module.exports = { invoicingHourlyHashes, invoicingDailyHashes, invoiceBreakdown } diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index a87f8ce..0b584b7 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -45,7 +45,10 @@ module.exports = (ctx) => { req.query.container, req.query.current, req.query.nominal, - req.query.racks + req.query.racks, + req.query.offset, + req.query.limit, + req.query.reverse ], ENDPOINTS.METRICS_HASHRATE, getHashrate diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index f326fbb..2146aef 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -17,6 +17,9 @@ const schemas = { current: { type: 'boolean' }, nominal: { type: 'boolean' }, racks: { type: 'string' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1 }, + reverse: { type: 'boolean' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end']