Skip to content

Commit c361aa1

Browse files
Merge pull request #134 from NessieCanCode/refactor-calculatecharges-into-helpers
Refactor cost calculator into helper helpers
2 parents 064364a + efe702b commit c361aa1

2 files changed

Lines changed: 150 additions & 48 deletions

File tree

src/cost-calculator.js

Lines changed: 101 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,84 @@ async function loadRatesConfig() {
3333
}
3434
}
3535

36+
/**
37+
* Normalize a usage record.
38+
*
39+
* @param {Object} record - Raw usage record
40+
* @returns {Object|null} normalized record or null if invalid
41+
*/
42+
function normalizeRecord(record) {
43+
if (!record) {
44+
return null;
45+
}
46+
const account = record.account || 'unknown';
47+
const month = (record.date || '').slice(0, 7); // YYYY-MM
48+
const coreHours =
49+
typeof record.core_hours === 'number' && record.core_hours > 0
50+
? record.core_hours
51+
: 0;
52+
const gpuHours =
53+
typeof record.gpu_hours === 'number' && record.gpu_hours > 0
54+
? record.gpu_hours
55+
: 0;
56+
if (coreHours <= 0 && gpuHours <= 0) {
57+
return null;
58+
}
59+
return { account, month, coreHours, gpuHours };
60+
}
61+
62+
/**
63+
* Apply rates, overrides, and discounts to a normalized record.
64+
*
65+
* @param {Object} record - Normalized record
66+
* @param {Object} ctx - Rate context
67+
* @returns {Object} record with cost
68+
*/
69+
function applyRates(record, ctx) {
70+
const { account, month, coreHours, gpuHours } = record;
71+
const ovr = ctx.overrides[account] || {};
72+
const rate =
73+
typeof ovr.rate === 'number'
74+
? ovr.rate
75+
: typeof ctx.historicalRates[month] === 'number'
76+
? ctx.historicalRates[month]
77+
: ctx.defaultRate;
78+
const gpuRate =
79+
typeof ovr.gpuRate === 'number'
80+
? ovr.gpuRate
81+
: typeof ctx.historicalGpuRates[month] === 'number'
82+
? ctx.historicalGpuRates[month]
83+
: ctx.defaultGpuRate;
84+
const validRate = rate > 0 ? rate : 0;
85+
const validGpuRate = gpuRate > 0 ? gpuRate : 0;
86+
let cost = coreHours * validRate + gpuHours * validGpuRate;
87+
const rawDiscount = typeof ovr.discount === 'number' ? ovr.discount : 0;
88+
const discount = Math.min(1, Math.max(0, rawDiscount));
89+
if (discount > 0) {
90+
cost *= 1 - discount;
91+
}
92+
return { account, month, coreHours, gpuHours, cost };
93+
}
94+
95+
/**
96+
* Accumulate a record's cost into the charges object.
97+
*
98+
* @param {Object} charges - Accumulator
99+
* @param {Object} record - Record with cost
100+
* @returns {Object} updated charges
101+
*/
102+
function accumulateCharge(charges, record) {
103+
const { account, month, coreHours, gpuHours, cost } = record;
104+
if (!charges[month]) charges[month] = {};
105+
if (!charges[month][account]) {
106+
charges[month][account] = { core_hours: 0, gpu_hours: 0, cost: 0 };
107+
}
108+
charges[month][account].core_hours += coreHours;
109+
charges[month][account].gpu_hours += gpuHours;
110+
charges[month][account].cost += cost;
111+
return charges;
112+
}
113+
36114
/**
37115
* Calculate charges from usage records applying rates and overrides.
38116
*
@@ -45,55 +123,28 @@ function calculateCharges(usage, config) {
45123
if (!config) {
46124
throw new Error('rate configuration required');
47125
}
48-
const defaultRate =
49-
typeof config.defaultRate === 'number' && config.defaultRate > 0
50-
? config.defaultRate
51-
: 0;
52-
const defaultGpuRate =
53-
typeof config.defaultGpuRate === 'number' && config.defaultGpuRate > 0
54-
? config.defaultGpuRate
55-
: 0;
56-
const historical = config.historicalRates || {};
57-
const gpuHistorical = config.historicalGpuRates || {};
58-
const overrides = config.overrides || {};
126+
const ctx = {
127+
defaultRate:
128+
typeof config.defaultRate === 'number' && config.defaultRate > 0
129+
? config.defaultRate
130+
: 0,
131+
defaultGpuRate:
132+
typeof config.defaultGpuRate === 'number' && config.defaultGpuRate > 0
133+
? config.defaultGpuRate
134+
: 0,
135+
historicalRates: config.historicalRates || {},
136+
historicalGpuRates: config.historicalGpuRates || {},
137+
overrides: config.overrides || {},
138+
};
59139

60-
const charges = {};
61-
62-
for (const record of usage) {
63-
if (!record) {
64-
continue;
65-
}
66-
const account = record.account || 'unknown';
67-
const month = (record.date || '').slice(0, 7); // YYYY-MM
68-
const ovr = overrides[account] || {};
69-
const coreHours = typeof record.core_hours === 'number' && record.core_hours > 0 ? record.core_hours : 0;
70-
const gpuHours = typeof record.gpu_hours === 'number' && record.gpu_hours > 0 ? record.gpu_hours : 0;
71-
if (coreHours <= 0 && gpuHours <= 0) {
72-
continue;
73-
}
74-
const rate = typeof ovr.rate === 'number'
75-
? ovr.rate
76-
: (typeof historical[month] === 'number' ? historical[month] : defaultRate);
77-
const gpuRate = typeof ovr.gpuRate === 'number'
78-
? ovr.gpuRate
79-
: (typeof gpuHistorical[month] === 'number' ? gpuHistorical[month] : defaultGpuRate);
80-
const validRate = rate > 0 ? rate : 0;
81-
const validGpuRate = gpuRate > 0 ? gpuRate : 0;
82-
let cost = coreHours * validRate + gpuHours * validGpuRate;
83-
const rawDiscount = typeof ovr.discount === 'number' ? ovr.discount : 0;
84-
const discount = Math.min(1, Math.max(0, rawDiscount));
85-
if (discount > 0) {
86-
cost *= (1 - discount);
87-
}
88-
89-
if (!charges[month]) charges[month] = {};
90-
if (!charges[month][account]) {
91-
charges[month][account] = { core_hours: 0, gpu_hours: 0, cost: 0 };
140+
const charges = usage.reduce((acc, rec) => {
141+
const normalized = normalizeRecord(rec);
142+
if (!normalized) {
143+
return acc;
92144
}
93-
charges[month][account].core_hours += coreHours;
94-
charges[month][account].gpu_hours += gpuHours;
95-
charges[month][account].cost += cost;
96-
}
145+
const costed = applyRates(normalized, ctx);
146+
return accumulateCharge(acc, costed);
147+
}, {});
97148

98149
for (const month of Object.keys(charges)) {
99150
for (const account of Object.keys(charges[month])) {
@@ -110,4 +161,7 @@ function calculateCharges(usage, config) {
110161
module.exports = {
111162
calculateCharges,
112163
loadRatesConfig,
164+
normalizeRecord,
165+
applyRates,
166+
accumulateCharge,
113167
};

test/unit/calculator.test.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
const assert = require('assert');
22
const fs = require('fs');
33
const path = require('path');
4-
const { calculateCharges, loadRatesConfig } = require('../../src/cost-calculator');
4+
const {
5+
calculateCharges,
6+
loadRatesConfig,
7+
normalizeRecord,
8+
applyRates,
9+
accumulateCharge,
10+
} = require('../../src/cost-calculator');
511

612
async function testFileConfig() {
713
const usage = [
@@ -19,6 +25,45 @@ async function testFileConfig() {
1925
assert.strictEqual(charges['2024-01'].education.gpu_hours, 10);
2026
}
2127

28+
function testNormalizeRecord() {
29+
const rec = normalizeRecord({
30+
account: 'acct',
31+
date: '2024-03-15',
32+
core_hours: 5,
33+
gpu_hours: 2,
34+
});
35+
assert.deepStrictEqual(rec, {
36+
account: 'acct',
37+
month: '2024-03',
38+
coreHours: 5,
39+
gpuHours: 2,
40+
});
41+
assert.strictEqual(normalizeRecord({ core_hours: -1, gpu_hours: 0 }), null);
42+
}
43+
44+
function testApplyRates() {
45+
const ctx = {
46+
defaultRate: 0.02,
47+
defaultGpuRate: 0.2,
48+
historicalRates: { '2024-03': 0.03 },
49+
historicalGpuRates: { '2024-03': 0.3 },
50+
overrides: { acct: { discount: 0.25 } },
51+
};
52+
const rec = { account: 'acct', month: '2024-03', coreHours: 100, gpuHours: 10 };
53+
const res = applyRates(rec, ctx);
54+
assert.strictEqual(res.cost, (100 * 0.03 + 10 * 0.3) * 0.75);
55+
}
56+
57+
function testAccumulateCharge() {
58+
const charges = {};
59+
const rec = { account: 'acct', month: '2024-03', coreHours: 1, gpuHours: 2, cost: 3 };
60+
accumulateCharge(charges, rec);
61+
accumulateCharge(charges, rec);
62+
assert.deepStrictEqual(charges, {
63+
'2024-03': { acct: { core_hours: 2, gpu_hours: 4, cost: 6 } },
64+
});
65+
}
66+
2267
function testPassedConfig() {
2368
const usage = [
2469
{ account: 'acct', date: '2024-03-01', core_hours: 100, gpu_hours: 10 }
@@ -103,6 +148,9 @@ function testRoundingTotals() {
103148
}
104149

105150
async function run() {
151+
testNormalizeRecord();
152+
testApplyRates();
153+
testAccumulateCharge();
106154
await testFileConfig();
107155
testPassedConfig();
108156
testInvalidUsageIgnored();

0 commit comments

Comments
 (0)