From efe702bd3ea8b6bd84d21ecea2f15f00c400f5cf Mon Sep 17 00:00:00 2001 From: Robert Romero Date: Tue, 9 Sep 2025 16:06:34 -0700 Subject: [PATCH] refactor cost calculator helpers --- src/cost-calculator.js | 148 ++++++++++++++++++++++++----------- test/unit/calculator.test.js | 50 +++++++++++- 2 files changed, 150 insertions(+), 48 deletions(-) diff --git a/src/cost-calculator.js b/src/cost-calculator.js index f4e3cec..205b428 100644 --- a/src/cost-calculator.js +++ b/src/cost-calculator.js @@ -33,6 +33,84 @@ async function loadRatesConfig() { } } +/** + * Normalize a usage record. + * + * @param {Object} record - Raw usage record + * @returns {Object|null} normalized record or null if invalid + */ +function normalizeRecord(record) { + if (!record) { + return null; + } + const account = record.account || 'unknown'; + const month = (record.date || '').slice(0, 7); // YYYY-MM + const coreHours = + typeof record.core_hours === 'number' && record.core_hours > 0 + ? record.core_hours + : 0; + const gpuHours = + typeof record.gpu_hours === 'number' && record.gpu_hours > 0 + ? record.gpu_hours + : 0; + if (coreHours <= 0 && gpuHours <= 0) { + return null; + } + return { account, month, coreHours, gpuHours }; +} + +/** + * Apply rates, overrides, and discounts to a normalized record. + * + * @param {Object} record - Normalized record + * @param {Object} ctx - Rate context + * @returns {Object} record with cost + */ +function applyRates(record, ctx) { + const { account, month, coreHours, gpuHours } = record; + const ovr = ctx.overrides[account] || {}; + const rate = + typeof ovr.rate === 'number' + ? ovr.rate + : typeof ctx.historicalRates[month] === 'number' + ? ctx.historicalRates[month] + : ctx.defaultRate; + const gpuRate = + typeof ovr.gpuRate === 'number' + ? ovr.gpuRate + : typeof ctx.historicalGpuRates[month] === 'number' + ? ctx.historicalGpuRates[month] + : ctx.defaultGpuRate; + const validRate = rate > 0 ? rate : 0; + const validGpuRate = gpuRate > 0 ? gpuRate : 0; + let cost = coreHours * validRate + gpuHours * validGpuRate; + const rawDiscount = typeof ovr.discount === 'number' ? ovr.discount : 0; + const discount = Math.min(1, Math.max(0, rawDiscount)); + if (discount > 0) { + cost *= 1 - discount; + } + return { account, month, coreHours, gpuHours, cost }; +} + +/** + * Accumulate a record's cost into the charges object. + * + * @param {Object} charges - Accumulator + * @param {Object} record - Record with cost + * @returns {Object} updated charges + */ +function accumulateCharge(charges, record) { + const { account, month, coreHours, gpuHours, cost } = record; + if (!charges[month]) charges[month] = {}; + if (!charges[month][account]) { + charges[month][account] = { core_hours: 0, gpu_hours: 0, cost: 0 }; + } + charges[month][account].core_hours += coreHours; + charges[month][account].gpu_hours += gpuHours; + charges[month][account].cost += cost; + return charges; +} + /** * Calculate charges from usage records applying rates and overrides. * @@ -45,55 +123,28 @@ function calculateCharges(usage, config) { if (!config) { throw new Error('rate configuration required'); } - const defaultRate = - typeof config.defaultRate === 'number' && config.defaultRate > 0 - ? config.defaultRate - : 0; - const defaultGpuRate = - typeof config.defaultGpuRate === 'number' && config.defaultGpuRate > 0 - ? config.defaultGpuRate - : 0; - const historical = config.historicalRates || {}; - const gpuHistorical = config.historicalGpuRates || {}; - const overrides = config.overrides || {}; + const ctx = { + defaultRate: + typeof config.defaultRate === 'number' && config.defaultRate > 0 + ? config.defaultRate + : 0, + defaultGpuRate: + typeof config.defaultGpuRate === 'number' && config.defaultGpuRate > 0 + ? config.defaultGpuRate + : 0, + historicalRates: config.historicalRates || {}, + historicalGpuRates: config.historicalGpuRates || {}, + overrides: config.overrides || {}, + }; - const charges = {}; - - for (const record of usage) { - if (!record) { - continue; - } - const account = record.account || 'unknown'; - const month = (record.date || '').slice(0, 7); // YYYY-MM - const ovr = overrides[account] || {}; - const coreHours = typeof record.core_hours === 'number' && record.core_hours > 0 ? record.core_hours : 0; - const gpuHours = typeof record.gpu_hours === 'number' && record.gpu_hours > 0 ? record.gpu_hours : 0; - if (coreHours <= 0 && gpuHours <= 0) { - continue; - } - const rate = typeof ovr.rate === 'number' - ? ovr.rate - : (typeof historical[month] === 'number' ? historical[month] : defaultRate); - const gpuRate = typeof ovr.gpuRate === 'number' - ? ovr.gpuRate - : (typeof gpuHistorical[month] === 'number' ? gpuHistorical[month] : defaultGpuRate); - const validRate = rate > 0 ? rate : 0; - const validGpuRate = gpuRate > 0 ? gpuRate : 0; - let cost = coreHours * validRate + gpuHours * validGpuRate; - const rawDiscount = typeof ovr.discount === 'number' ? ovr.discount : 0; - const discount = Math.min(1, Math.max(0, rawDiscount)); - if (discount > 0) { - cost *= (1 - discount); - } - - if (!charges[month]) charges[month] = {}; - if (!charges[month][account]) { - charges[month][account] = { core_hours: 0, gpu_hours: 0, cost: 0 }; + const charges = usage.reduce((acc, rec) => { + const normalized = normalizeRecord(rec); + if (!normalized) { + return acc; } - charges[month][account].core_hours += coreHours; - charges[month][account].gpu_hours += gpuHours; - charges[month][account].cost += cost; - } + const costed = applyRates(normalized, ctx); + return accumulateCharge(acc, costed); + }, {}); for (const month of Object.keys(charges)) { for (const account of Object.keys(charges[month])) { @@ -110,4 +161,7 @@ function calculateCharges(usage, config) { module.exports = { calculateCharges, loadRatesConfig, + normalizeRecord, + applyRates, + accumulateCharge, }; diff --git a/test/unit/calculator.test.js b/test/unit/calculator.test.js index e277637..3428188 100644 --- a/test/unit/calculator.test.js +++ b/test/unit/calculator.test.js @@ -1,7 +1,13 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const { calculateCharges, loadRatesConfig } = require('../../src/cost-calculator'); +const { + calculateCharges, + loadRatesConfig, + normalizeRecord, + applyRates, + accumulateCharge, +} = require('../../src/cost-calculator'); async function testFileConfig() { const usage = [ @@ -19,6 +25,45 @@ async function testFileConfig() { assert.strictEqual(charges['2024-01'].education.gpu_hours, 10); } +function testNormalizeRecord() { + const rec = normalizeRecord({ + account: 'acct', + date: '2024-03-15', + core_hours: 5, + gpu_hours: 2, + }); + assert.deepStrictEqual(rec, { + account: 'acct', + month: '2024-03', + coreHours: 5, + gpuHours: 2, + }); + assert.strictEqual(normalizeRecord({ core_hours: -1, gpu_hours: 0 }), null); +} + +function testApplyRates() { + const ctx = { + defaultRate: 0.02, + defaultGpuRate: 0.2, + historicalRates: { '2024-03': 0.03 }, + historicalGpuRates: { '2024-03': 0.3 }, + overrides: { acct: { discount: 0.25 } }, + }; + const rec = { account: 'acct', month: '2024-03', coreHours: 100, gpuHours: 10 }; + const res = applyRates(rec, ctx); + assert.strictEqual(res.cost, (100 * 0.03 + 10 * 0.3) * 0.75); +} + +function testAccumulateCharge() { + const charges = {}; + const rec = { account: 'acct', month: '2024-03', coreHours: 1, gpuHours: 2, cost: 3 }; + accumulateCharge(charges, rec); + accumulateCharge(charges, rec); + assert.deepStrictEqual(charges, { + '2024-03': { acct: { core_hours: 2, gpu_hours: 4, cost: 6 } }, + }); +} + function testPassedConfig() { const usage = [ { account: 'acct', date: '2024-03-01', core_hours: 100, gpu_hours: 10 } @@ -103,6 +148,9 @@ function testRoundingTotals() { } async function run() { + testNormalizeRecord(); + testApplyRates(); + testAccumulateCharge(); await testFileConfig(); testPassedConfig(); testInvalidUsageIgnored();