@dhyasama/totem-models 12.26.0 → 12.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/Financials.js
CHANGED
|
@@ -76,12 +76,6 @@ module.exports = function(mongoose, config) {
|
|
|
76
76
|
|
|
77
77
|
uuid: { type: String },
|
|
78
78
|
|
|
79
|
-
// DEPRECATED: currency/scale now live per-metric (see metrics[].currency / metrics[].scale).
|
|
80
|
-
// Retained during the dual-write transition as the fallback default for un-migrated metrics;
|
|
81
|
-
// remove once all read paths resolve currency/scale per metric.
|
|
82
|
-
currency: { type: String, default: 'USD' },
|
|
83
|
-
scale: { type: Number, enum: [1, 1000, 1000000, 1000000000, 1000000000000], default: 1 },
|
|
84
|
-
|
|
85
79
|
approval:{ type: Boolean, default: false },
|
|
86
80
|
|
|
87
81
|
status: { type: String, enum: ['Scheduled', 'Delivered', 'Opened', 'Overdue', 'Skipped', 'Pending', 'Completed', 'Archived'] },
|
|
@@ -148,9 +142,7 @@ module.exports = function(mongoose, config) {
|
|
|
148
142
|
}
|
|
149
143
|
}],
|
|
150
144
|
verified: { type: Boolean, default: false },
|
|
151
|
-
//
|
|
152
|
-
// "not yet migrated" and read paths fall back to the (deprecated) snapshot-level
|
|
153
|
-
// currency/scale. See scripts/migrate-financials-schema.js for the backfill.
|
|
145
|
+
// Currency and scale live exclusively on each metric/scenario.
|
|
154
146
|
currency: { type: String, trim: true },
|
|
155
147
|
scale: { type: Number, enum: [1, 1000, 1000000, 1000000000, 1000000000000] },
|
|
156
148
|
history: [{
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const VALID_METRIC_SCALES = [1, 1000, 1000000, 1000000000, 1000000000000];
|
|
4
|
+
const UNITLESS_METRIC_NAMES = new Set(['Months of Runway']);
|
|
5
|
+
|
|
6
|
+
const sanitizeScale = (scale) => VALID_METRIC_SCALES.includes(scale) ? scale : 1;
|
|
7
|
+
const isUnitlessMetricName = (name) => UNITLESS_METRIC_NAMES.has(name);
|
|
8
|
+
|
|
9
|
+
const getMetricUnits = (metricName, snapshot = {}) => {
|
|
10
|
+
if (isUnitlessMetricName(metricName)) {
|
|
11
|
+
return { currency: undefined, scale: 1 };
|
|
12
|
+
}
|
|
13
|
+
return { currency: snapshot.currency || 'USD', scale: sanitizeScale(snapshot.scale) };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
getMetricUnits,
|
|
18
|
+
isUnitlessMetricName,
|
|
19
|
+
sanitizeScale
|
|
20
|
+
};
|
|
21
|
+
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* currency/scale. Idempotent: metrics that already carry their own are left untouched.
|
|
26
26
|
* Defaults to 'USD' / 1 when the snapshot has neither. Part of the snapshot→metric
|
|
27
27
|
* currency migration (dual-write transition; snapshot fields retained as fallback).
|
|
28
|
+
* Unitless metrics (currently Months of Runway) are normalized to scale 1 with no currency,
|
|
29
|
+
* including their optional previous/undo state.
|
|
28
30
|
*
|
|
29
31
|
* Usage:
|
|
30
32
|
* node scripts/migrate-financials-schema.js <customerId> [--config <configPath>] [--dry-run]
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
|
|
41
43
|
const mongoose = require('mongoose');
|
|
42
44
|
const path = require('path');
|
|
45
|
+
const { getMetricUnits, isUnitlessMetricName, sanitizeScale } = require('./libs/financial_metric_units');
|
|
43
46
|
|
|
44
47
|
// Parse command line arguments
|
|
45
48
|
const args = process.argv.slice(2);
|
|
@@ -85,14 +88,6 @@ if (configPath) {
|
|
|
85
88
|
dbUri = config.db.uri;
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
// Valid metric scales (must match the enum in lib/Financials.js). The backfill sanitizes the
|
|
89
|
-
// snapshot's stored scale against this set so a corrupt legacy value can't propagate into a
|
|
90
|
-
// metric and fail a future validated write. Unknown/absent values fall back to 1.
|
|
91
|
-
const VALID_METRIC_SCALES = [1, 1000, 1000000, 1000000000, 1000000000000];
|
|
92
|
-
function sanitizeScale(scale) {
|
|
93
|
-
return VALID_METRIC_SCALES.includes(scale) ? scale : 1;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
91
|
console.log('Migration Configuration:');
|
|
97
92
|
console.log(' Customer ID:', CUSTOMER_ID);
|
|
98
93
|
console.log(' Database URI:', dbUri.replace(/\/\/[^:]+:[^@]+@/, '//*****:*****@')); // Mask credentials
|
|
@@ -323,10 +318,8 @@ async function migrateFinancials() {
|
|
|
323
318
|
oldMetrics.forEach((metric) => {
|
|
324
319
|
const source = getSourceForMetric(metric.name);
|
|
325
320
|
|
|
326
|
-
// Create separate metric entries for each scenario that has a value
|
|
327
|
-
|
|
328
|
-
const metricCurrency = snapshot.currency || 'USD';
|
|
329
|
-
const metricScale = sanitizeScale(snapshot.scale);
|
|
321
|
+
// Create separate metric entries for each scenario that has a value.
|
|
322
|
+
const metricUnits = getMetricUnits(metric.name, snapshot);
|
|
330
323
|
|
|
331
324
|
if ('actual' in metric && metric.actual !== undefined && metric.actual !== null) {
|
|
332
325
|
const newMetric = {
|
|
@@ -334,11 +327,11 @@ async function migrateFinancials() {
|
|
|
334
327
|
scenario: 'actual',
|
|
335
328
|
value: metric.actual,
|
|
336
329
|
breakdown: metric.breakdown || {},
|
|
337
|
-
|
|
338
|
-
scale: metricScale,
|
|
330
|
+
scale: metricUnits.scale,
|
|
339
331
|
verified: true,
|
|
340
332
|
history: historyArrayForMetrics
|
|
341
333
|
};
|
|
334
|
+
if (metricUnits.currency) newMetric.currency = metricUnits.currency;
|
|
342
335
|
if (source) newMetric.sources = [source];
|
|
343
336
|
newMetrics.push(newMetric);
|
|
344
337
|
}
|
|
@@ -349,11 +342,11 @@ async function migrateFinancials() {
|
|
|
349
342
|
scenario: 'budget',
|
|
350
343
|
value: metric.budget,
|
|
351
344
|
breakdown: {},
|
|
352
|
-
|
|
353
|
-
scale: metricScale,
|
|
345
|
+
scale: metricUnits.scale,
|
|
354
346
|
verified: true,
|
|
355
347
|
history: historyArrayForMetrics
|
|
356
348
|
};
|
|
349
|
+
if (metricUnits.currency) newMetric.currency = metricUnits.currency;
|
|
357
350
|
if (source) newMetric.sources = [source];
|
|
358
351
|
newMetrics.push(newMetric);
|
|
359
352
|
}
|
|
@@ -364,11 +357,11 @@ async function migrateFinancials() {
|
|
|
364
357
|
scenario: 'forecast',
|
|
365
358
|
value: metric.forecast,
|
|
366
359
|
breakdown: {},
|
|
367
|
-
|
|
368
|
-
scale: metricScale,
|
|
360
|
+
scale: metricUnits.scale,
|
|
369
361
|
verified: true,
|
|
370
362
|
history: historyArrayForMetrics
|
|
371
363
|
};
|
|
364
|
+
if (metricUnits.currency) newMetric.currency = metricUnits.currency;
|
|
372
365
|
if (source) newMetric.sources = [source];
|
|
373
366
|
newMetrics.push(newMetric);
|
|
374
367
|
}
|
|
@@ -395,16 +388,48 @@ async function migrateFinancials() {
|
|
|
395
388
|
changes.push(` - Snapshot ${index}: Added sources to metric ${mIndex} (${metric.name})`);
|
|
396
389
|
}
|
|
397
390
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
391
|
+
const metricUnits = getMetricUnits(metric.name, snapshot);
|
|
392
|
+
if (isUnitlessMetricName(metric.name)) {
|
|
393
|
+
if ('currency' in metric) {
|
|
394
|
+
updates.$unset = updates.$unset || {};
|
|
395
|
+
updates.$unset[`snapshots.${index}.metrics.${mIndex}.currency`] = '';
|
|
396
|
+
changes.push(` - Snapshot ${index}: Removed currency from unitless metric ${mIndex} (${metric.name})`);
|
|
397
|
+
}
|
|
398
|
+
if (metric.scale !== 1) {
|
|
399
|
+
updates[`snapshots.${index}.metrics.${mIndex}.scale`] = 1;
|
|
400
|
+
changes.push(` - Snapshot ${index}: Set unitless scale (1) on metric ${mIndex} (${metric.name})`);
|
|
401
|
+
}
|
|
402
|
+
if (metric.previous && typeof metric.previous === 'object') {
|
|
403
|
+
if ('currency' in metric.previous) {
|
|
404
|
+
updates.$unset = updates.$unset || {};
|
|
405
|
+
updates.$unset[`snapshots.${index}.metrics.${mIndex}.previous.currency`] = '';
|
|
406
|
+
changes.push(` - Snapshot ${index}: Removed currency from previous state for unitless metric ${mIndex} (${metric.name})`);
|
|
407
|
+
}
|
|
408
|
+
if (metric.previous.scale !== 1) {
|
|
409
|
+
updates[`snapshots.${index}.metrics.${mIndex}.previous.scale`] = 1;
|
|
410
|
+
changes.push(` - Snapshot ${index}: Set unitless scale (1) on previous state for metric ${mIndex} (${metric.name})`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
// Idempotent: only set when the money metric doesn't already carry its own units.
|
|
415
|
+
if (!('currency' in metric)) {
|
|
416
|
+
updates[`snapshots.${index}.metrics.${mIndex}.currency`] = metricUnits.currency;
|
|
417
|
+
changes.push(` - Snapshot ${index}: Backfilled currency (${metricUnits.currency}) to metric ${mIndex} (${metric.name})`);
|
|
418
|
+
}
|
|
419
|
+
if (!('scale' in metric)) {
|
|
420
|
+
updates[`snapshots.${index}.metrics.${mIndex}.scale`] = metricUnits.scale;
|
|
421
|
+
changes.push(` - Snapshot ${index}: Backfilled scale (${metricUnits.scale}) to metric ${mIndex} (${metric.name})`);
|
|
422
|
+
}
|
|
423
|
+
if (metric.previous && typeof metric.previous === 'object') {
|
|
424
|
+
if (!('currency' in metric.previous)) {
|
|
425
|
+
updates[`snapshots.${index}.metrics.${mIndex}.previous.currency`] = metricUnits.currency;
|
|
426
|
+
changes.push(` - Snapshot ${index}: Backfilled previous currency (${metricUnits.currency}) on metric ${mIndex} (${metric.name})`);
|
|
427
|
+
}
|
|
428
|
+
if (!('scale' in metric.previous)) {
|
|
429
|
+
updates[`snapshots.${index}.metrics.${mIndex}.previous.scale`] = metricUnits.scale;
|
|
430
|
+
changes.push(` - Snapshot ${index}: Backfilled previous scale (${metricUnits.scale}) on metric ${mIndex} (${metric.name})`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
408
433
|
}
|
|
409
434
|
});
|
|
410
435
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const {
|
|
5
|
+
getMetricUnits,
|
|
6
|
+
isUnitlessMetricName,
|
|
7
|
+
sanitizeScale
|
|
8
|
+
} = require('../scripts/libs/financial_metric_units');
|
|
9
|
+
|
|
10
|
+
describe('financial metric migration units', function() {
|
|
11
|
+
it('stores Months of Runway as unscaled units without currency', function() {
|
|
12
|
+
assert.strictEqual(isUnitlessMetricName('Months of Runway'), true);
|
|
13
|
+
assert.deepStrictEqual(
|
|
14
|
+
getMetricUnits('Months of Runway', { currency: 'EUR', scale: 1000 }),
|
|
15
|
+
{ currency: undefined, scale: 1 }
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('inherits snapshot units for money metrics', function() {
|
|
20
|
+
assert.deepStrictEqual(
|
|
21
|
+
getMetricUnits('Revenue', { currency: 'EUR', scale: 1000 }),
|
|
22
|
+
{ currency: 'EUR', scale: 1000 }
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('sanitizes absent and invalid scales', function() {
|
|
27
|
+
assert.strictEqual(sanitizeScale(undefined), 1);
|
|
28
|
+
assert.strictEqual(sanitizeScale(123), 1);
|
|
29
|
+
assert.strictEqual(sanitizeScale(1000000), 1000000);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
package/test/Financials.js
CHANGED
|
@@ -15,6 +15,27 @@ var orgB = new mongoose.Types.ObjectId();
|
|
|
15
15
|
|
|
16
16
|
describe('Financials', function() {
|
|
17
17
|
|
|
18
|
+
it('does not define or persist snapshot-level currency and scale', function() {
|
|
19
|
+
var f = new Financials({
|
|
20
|
+
organization: orgA,
|
|
21
|
+
customer: customerA,
|
|
22
|
+
snapshots: [{
|
|
23
|
+
uuid: 'metric-units-only',
|
|
24
|
+
year: 2025,
|
|
25
|
+
period: 'FY',
|
|
26
|
+
currency: 'EUR',
|
|
27
|
+
scale: 1000,
|
|
28
|
+
metrics: [{ name: 'Revenue', scenario: 'actual', value: 10, currency: 'EUR', scale: 1000 }]
|
|
29
|
+
}]
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
var snapshot = f.snapshots[0].toObject();
|
|
33
|
+
should.not.exist(snapshot.currency);
|
|
34
|
+
should.not.exist(snapshot.scale);
|
|
35
|
+
snapshot.metrics[0].currency.should.equal('EUR');
|
|
36
|
+
snapshot.metrics[0].scale.should.equal(1000);
|
|
37
|
+
});
|
|
38
|
+
|
|
18
39
|
before(function(done) {
|
|
19
40
|
databaseHelpers.connect(mongoose, done);
|
|
20
41
|
});
|