@spottoai/types-package 1.0.2-dev1138.1 → 1.0.2-dev1138.3
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/dist/azure/financialAnalytics.d.ts +80 -8
- package/dist/azure/financialAnalytics.d.ts.map +1 -1
- package/dist/azure/financialAnalyticsDelivery.d.ts +72 -3
- package/dist/azure/financialAnalyticsDelivery.d.ts.map +1 -1
- package/dist/azure/financialAnalyticsDelivery.js +7 -1
- package/dist/azure/financialAnalyticsDelivery.js.map +1 -1
- package/dist/azure/financialAnalyticsDeliveryValidation.d.ts +9 -1
- package/dist/azure/financialAnalyticsDeliveryValidation.d.ts.map +1 -1
- package/dist/azure/financialAnalyticsDeliveryValidation.js +190 -10
- package/dist/azure/financialAnalyticsDeliveryValidation.js.map +1 -1
- package/dist/azure/financialAnalyticsDisplayKernel.d.ts +12 -0
- package/dist/azure/financialAnalyticsDisplayKernel.d.ts.map +1 -0
- package/dist/azure/financialAnalyticsDisplayKernel.js +144 -0
- package/dist/azure/financialAnalyticsDisplayKernel.js.map +1 -0
- package/dist/azure/financialAnalyticsValidation.d.ts.map +1 -1
- package/dist/azure/financialAnalyticsValidation.js +101 -19
- package/dist/azure/financialAnalyticsValidation.js.map +1 -1
- package/dist/azure/financialAuthorityView.d.ts +4 -0
- package/dist/azure/financialAuthorityView.d.ts.map +1 -1
- package/dist/azure/financialAuthorityView.js.map +1 -1
- package/dist/azure/financialAuthorityViewValidation.d.ts.map +1 -1
- package/dist/azure/financialAuthorityViewValidation.js +39 -0
- package/dist/azure/financialAuthorityViewValidation.js.map +1 -1
- package/dist/azure/financialChargeComposition.d.ts +81 -0
- package/dist/azure/financialChargeComposition.d.ts.map +1 -0
- package/dist/azure/financialChargeComposition.js +41 -0
- package/dist/azure/financialChargeComposition.js.map +1 -0
- package/dist/azure/financialChargeCompositionKernel.d.ts +3 -0
- package/dist/azure/financialChargeCompositionKernel.d.ts.map +1 -0
- package/dist/azure/financialChargeCompositionKernel.js +49 -0
- package/dist/azure/financialChargeCompositionKernel.js.map +1 -0
- package/dist/azure/financialChargeCompositionValidation.d.ts +8 -0
- package/dist/azure/financialChargeCompositionValidation.d.ts.map +1 -0
- package/dist/azure/financialChargeCompositionValidation.js +164 -0
- package/dist/azure/financialChargeCompositionValidation.js.map +1 -0
- package/dist/azure/financialDataflow.d.ts +45 -2
- package/dist/azure/financialDataflow.d.ts.map +1 -1
- package/dist/azure/financialDataflow.js.map +1 -1
- package/dist/azure/financialDataflowKernel.d.ts +31 -0
- package/dist/azure/financialDataflowKernel.d.ts.map +1 -0
- package/dist/azure/financialDataflowKernel.js +169 -0
- package/dist/azure/financialDataflowKernel.js.map +1 -0
- package/dist/azure/financialDataflowValidation.d.ts +5 -2
- package/dist/azure/financialDataflowValidation.d.ts.map +1 -1
- package/dist/azure/financialDataflowValidation.js +203 -30
- package/dist/azure/financialDataflowValidation.js.map +1 -1
- package/dist/azure/financialEvidenceAssessmentValidation.d.ts.map +1 -1
- package/dist/azure/financialEvidenceAssessmentValidation.js +7 -0
- package/dist/azure/financialEvidenceAssessmentValidation.js.map +1 -1
- package/dist/azure/financialPolicy.d.ts +19 -3
- package/dist/azure/financialPolicy.d.ts.map +1 -1
- package/dist/azure/financialPolicy.js.map +1 -1
- package/dist/azure/financialPolicyDelivery.d.ts +75 -1
- package/dist/azure/financialPolicyDelivery.d.ts.map +1 -1
- package/dist/azure/financialPolicyDelivery.js +5 -1
- package/dist/azure/financialPolicyDelivery.js.map +1 -1
- package/dist/azure/financialPolicyDeliveryValidation.d.ts +16 -2
- package/dist/azure/financialPolicyDeliveryValidation.d.ts.map +1 -1
- package/dist/azure/financialPolicyDeliveryValidation.js +238 -5
- package/dist/azure/financialPolicyDeliveryValidation.js.map +1 -1
- package/dist/azure/financialPolicyKernel.d.ts +38 -0
- package/dist/azure/financialPolicyKernel.d.ts.map +1 -0
- package/dist/azure/financialPolicyKernel.js +232 -0
- package/dist/azure/financialPolicyKernel.js.map +1 -0
- package/dist/azure/financialPolicyValidation.d.ts +1 -0
- package/dist/azure/financialPolicyValidation.d.ts.map +1 -1
- package/dist/azure/financialPolicyValidation.js +111 -16
- package/dist/azure/financialPolicyValidation.js.map +1 -1
- package/dist/azure/financialSavingsAuthority.d.ts +29 -0
- package/dist/azure/financialSavingsAuthority.d.ts.map +1 -1
- package/dist/azure/financialSavingsAuthority.js +2 -1
- package/dist/azure/financialSavingsAuthority.js.map +1 -1
- package/dist/azure/financialSavingsAuthorityValidation.d.ts.map +1 -1
- package/dist/azure/financialSavingsAuthorityValidation.js +3 -0
- package/dist/azure/financialSavingsAuthorityValidation.js.map +1 -1
- package/dist/azure/financialSavingsCoordinateValidation.d.ts.map +1 -1
- package/dist/azure/financialSavingsCoordinateValidation.js +36 -0
- package/dist/azure/financialSavingsCoordinateValidation.js.map +1 -1
- package/dist/azure/financialScope.d.ts +6 -0
- package/dist/azure/financialScope.d.ts.map +1 -1
- package/dist/azure/financialScope.js +6 -0
- package/dist/azure/financialScope.js.map +1 -1
- package/dist/azure/financialScopeBaseline.d.ts +8 -2
- package/dist/azure/financialScopeBaseline.d.ts.map +1 -1
- package/dist/azure/financialScopeBaseline.js +14 -1
- package/dist/azure/financialScopeBaseline.js.map +1 -1
- package/dist/azure/financialScopeBaselineValidation.d.ts.map +1 -1
- package/dist/azure/financialScopeBaselineValidation.js +15 -1
- package/dist/azure/financialScopeBaselineValidation.js.map +1 -1
- package/dist/azure/financialScopeEvidence.d.ts +1 -1
- package/dist/azure/financialScopeEvidence.d.ts.map +1 -1
- package/dist/azure/metrics.d.ts +2 -2
- package/dist/azure/metrics.d.ts.map +1 -1
- package/dist/azure/prices.d.ts +16 -3
- package/dist/azure/prices.d.ts.map +1 -1
- package/dist/azure/recommendations.d.ts +12 -4
- package/dist/azure/recommendations.d.ts.map +1 -1
- package/dist/azure/reports.d.ts +3 -2
- package/dist/azure/reports.d.ts.map +1 -1
- package/dist/azure/reports.js.map +1 -1
- package/dist/azure/resources.d.ts +12 -0
- package/dist/azure/resources.d.ts.map +1 -1
- package/dist/azure/subscriptions.d.ts +4 -2
- package/dist/azure/subscriptions.d.ts.map +1 -1
- package/dist/azure/views.d.ts +21 -5
- package/dist/azure/views.d.ts.map +1 -1
- package/dist/azure/views.js.map +1 -1
- package/dist/esm/azure/financialAnalyticsDelivery.js +5 -0
- package/dist/esm/azure/financialAnalyticsDeliveryValidation.js +184 -11
- package/dist/esm/azure/financialAnalyticsDisplayKernel.js +139 -0
- package/dist/esm/azure/financialAnalyticsValidation.js +101 -19
- package/dist/esm/azure/financialAuthorityViewValidation.js +39 -0
- package/dist/esm/azure/financialChargeComposition.js +36 -0
- package/dist/esm/azure/financialChargeCompositionKernel.js +44 -0
- package/dist/esm/azure/financialChargeCompositionValidation.js +155 -0
- package/dist/esm/azure/financialDataflowKernel.js +163 -0
- package/dist/esm/azure/financialDataflowValidation.js +199 -29
- package/dist/esm/azure/financialEvidenceAssessmentValidation.js +7 -0
- package/dist/esm/azure/financialPolicyDelivery.js +4 -0
- package/dist/esm/azure/financialPolicyDeliveryValidation.js +227 -7
- package/dist/esm/azure/financialPolicyKernel.js +225 -0
- package/dist/esm/azure/financialPolicyValidation.js +110 -16
- package/dist/esm/azure/financialSavingsAuthority.js +1 -0
- package/dist/esm/azure/financialSavingsAuthorityValidation.js +3 -0
- package/dist/esm/azure/financialSavingsCoordinateValidation.js +36 -0
- package/dist/esm/azure/financialScope.js +6 -0
- package/dist/esm/azure/financialScopeBaseline.js +12 -0
- package/dist/esm/azure/financialScopeBaselineValidation.js +15 -1
- package/dist/events/cost.d.ts +4 -0
- package/dist/events/cost.d.ts.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { formatExactDecimalValue, parseCanonicalDecimal, sumCanonicalDecimals } from '../common/exactDecimal.js';
|
|
2
|
+
import { isFinancialAnalyticsInputSeriesV1 } from './financialAnalyticsValidation.js';
|
|
3
|
+
const DAY_MS = 86400000;
|
|
4
|
+
const HISTORY_NOT_PRODUCED = 'history-not-produced';
|
|
5
|
+
const CALENDAR_PERIOD_INCOMPLETE = 'calendar-period-incomplete';
|
|
6
|
+
const addUtcDays = (date, days) => new Date(Date.parse(`${date}T00:00:00.000Z`) + days * DAY_MS).toISOString().slice(0, 10);
|
|
7
|
+
const shiftUtcMonths = (date, months) => {
|
|
8
|
+
const parsed = new Date(`${date}T00:00:00.000Z`);
|
|
9
|
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth() + months, 1)).toISOString().slice(0, 10);
|
|
10
|
+
};
|
|
11
|
+
const uniqueReasons = (values) => {
|
|
12
|
+
const reasons = [...new Set(values)].sort();
|
|
13
|
+
return (reasons.length > 0 ? reasons : [HISTORY_NOT_PRODUCED]);
|
|
14
|
+
};
|
|
15
|
+
/** Twelve-decimal half-away-from-zero display average; totals remain exact. */
|
|
16
|
+
const averageKnownPerDay = (amount, dayCount) => {
|
|
17
|
+
if (!Number.isSafeInteger(dayCount) || dayCount < 1)
|
|
18
|
+
throw new RangeError('Display average requires a positive day count.');
|
|
19
|
+
const value = parseCanonicalDecimal(amount);
|
|
20
|
+
const precision = 12;
|
|
21
|
+
const numerator = (value.coefficient < 0n ? -value.coefficient : value.coefficient) * 10n ** BigInt(precision);
|
|
22
|
+
const scaledDivisor = BigInt(dayCount) * 10n ** BigInt(value.scale);
|
|
23
|
+
let quotient = numerator / scaledDivisor;
|
|
24
|
+
const remainder = numerator % scaledDivisor;
|
|
25
|
+
if (remainder * 2n >= scaledDivisor)
|
|
26
|
+
quotient += 1n;
|
|
27
|
+
return formatExactDecimalValue({ coefficient: value.coefficient < 0n ? -quotient : quotient, scale: precision });
|
|
28
|
+
};
|
|
29
|
+
const requestedInterval = (input, periodKey) => {
|
|
30
|
+
const endDateExclusive = input.coordinate.period.requested.endDateExclusive;
|
|
31
|
+
const startDate = periodKey === 'last-7-days'
|
|
32
|
+
? addUtcDays(endDateExclusive, -7)
|
|
33
|
+
: periodKey === 'rolling-30-days'
|
|
34
|
+
? addUtcDays(endDateExclusive, -30)
|
|
35
|
+
: periodKey === 'rolling-90-days'
|
|
36
|
+
? addUtcDays(endDateExclusive, -90)
|
|
37
|
+
: shiftUtcMonths(addUtcDays(endDateExclusive, -1), -11);
|
|
38
|
+
return {
|
|
39
|
+
startDate,
|
|
40
|
+
endDateExclusive,
|
|
41
|
+
dateBasis: input.coordinate.period.requested.dateBasis,
|
|
42
|
+
...(input.coordinate.period.requested.timeZone === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { timeZone: input.coordinate.period.requested.timeZone }),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const displayDays = (input, startDate, endDateExclusive) => {
|
|
48
|
+
const pointByDate = new Map(input.points.map(point => [point.date, point]));
|
|
49
|
+
const gapReasons = (date) => input.gaps
|
|
50
|
+
.filter(gap => gap.startDate <= date && gap.endDateExclusive > date)
|
|
51
|
+
.flatMap(gap => gap.reasonCodes);
|
|
52
|
+
const days = [];
|
|
53
|
+
for (let date = startDate; date < endDateExclusive; date = addUtcDays(date, 1)) {
|
|
54
|
+
const point = pointByDate.get(date);
|
|
55
|
+
if (point?.status === 'available') {
|
|
56
|
+
days.push({ date, status: 'available', knownAmount: point.amount, reasonCodes: [] });
|
|
57
|
+
}
|
|
58
|
+
else if (point?.status === 'partial') {
|
|
59
|
+
days.push({ date, status: 'partial', knownAmount: point.knownAmount, reasonCodes: point.reasonCodes });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const reasons = gapReasons(date);
|
|
63
|
+
days.push({ date, status: 'unavailable', reasonCodes: reasons.length > 0 ? reasons : [HISTORY_NOT_PRODUCED] });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return days;
|
|
67
|
+
};
|
|
68
|
+
const toBucket = (bucketKey, startDate, endDateExclusive, days) => {
|
|
69
|
+
const knownAmounts = days.flatMap(day => (day.knownAmount === undefined ? [] : [day.knownAmount]));
|
|
70
|
+
const coversWholeBucket = days[0]?.date === startDate && days.length > 0 && addUtcDays(days[days.length - 1].date, 1) === endDateExclusive;
|
|
71
|
+
const reasonCodes = uniqueReasons([
|
|
72
|
+
...days.flatMap(day => day.reasonCodes),
|
|
73
|
+
...(coversWholeBucket ? [] : [CALENDAR_PERIOD_INCOMPLETE]),
|
|
74
|
+
]);
|
|
75
|
+
if (knownAmounts.length === 0) {
|
|
76
|
+
return { bucketKey, startDate, endDateExclusive, status: 'unavailable', reasonCodes };
|
|
77
|
+
}
|
|
78
|
+
const knownAmount = formatExactDecimalValue(sumCanonicalDecimals(knownAmounts));
|
|
79
|
+
const average = averageKnownPerDay(knownAmount, knownAmounts.length);
|
|
80
|
+
if (coversWholeBucket && days.every(day => day.status === 'available')) {
|
|
81
|
+
return { bucketKey, startDate, endDateExclusive, status: 'available', amount: knownAmount, averageKnownPerDay: average };
|
|
82
|
+
}
|
|
83
|
+
return { bucketKey, startDate, endDateExclusive, status: 'partial', knownAmount, averageKnownPerDay: average, reasonCodes };
|
|
84
|
+
};
|
|
85
|
+
const dailyBuckets = (days) => days.map(day => toBucket(day.date, day.date, addUtcDays(day.date, 1), [day]));
|
|
86
|
+
const monthlyBuckets = (days) => {
|
|
87
|
+
const byMonth = new Map();
|
|
88
|
+
days.forEach(day => {
|
|
89
|
+
const month = day.date.slice(0, 7);
|
|
90
|
+
byMonth.set(month, [...(byMonth.get(month) ?? []), day]);
|
|
91
|
+
});
|
|
92
|
+
return [...byMonth.entries()].map(([month, monthDays]) => toBucket(month, `${month}-01`, shiftUtcMonths(`${month}-01`, 1), monthDays));
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Exact, non-authoritative display rollup over one producer-owned analytics
|
|
96
|
+
* input. The function never changes charge inclusion, estimates, cost basis,
|
|
97
|
+
* currency, or source evidence; uncovered dates remain explicit.
|
|
98
|
+
*/
|
|
99
|
+
export const projectFinancialAnalyticsDisplayPeriodV1 = (request) => {
|
|
100
|
+
if (!isFinancialAnalyticsInputSeriesV1(request.input)) {
|
|
101
|
+
throw new TypeError('Financial analytics display projection requires a valid input series.');
|
|
102
|
+
}
|
|
103
|
+
const current = request.input.referenceCompositions.find(composition => composition.coordinate.periodRole === 'current-spend' && composition.compositionId === request.currentSpendCompositionId);
|
|
104
|
+
if (!current) {
|
|
105
|
+
throw new TypeError('Financial analytics input does not bind the requested current-spend composition.');
|
|
106
|
+
}
|
|
107
|
+
const accountingCurrency = request.input.coordinate.accountingCurrency;
|
|
108
|
+
if (accountingCurrency.status !== 'resolved') {
|
|
109
|
+
throw new TypeError('Financial analytics display projection requires a resolved accounting currency.');
|
|
110
|
+
}
|
|
111
|
+
const requested = requestedInterval(request.input, request.periodKey);
|
|
112
|
+
const days = displayDays(request.input, requested.startDate, requested.endDateExclusive);
|
|
113
|
+
const buckets = request.periodKey === 'trailing-12-calendar-months' ? monthlyBuckets(days) : dailyBuckets(days);
|
|
114
|
+
const common = {
|
|
115
|
+
analyticsInputId: request.input.analyticsInputId,
|
|
116
|
+
currentSpendCompositionId: current.compositionId,
|
|
117
|
+
chargeInclusionPolicyRef: current.coordinate.chargeInclusionPolicyRef,
|
|
118
|
+
periodKey: request.periodKey,
|
|
119
|
+
requested,
|
|
120
|
+
bucketGranularity: request.periodKey === 'trailing-12-calendar-months' ? 'calendar-month' : 'daily',
|
|
121
|
+
currencyCode: accountingCurrency.currencyCode,
|
|
122
|
+
buckets,
|
|
123
|
+
};
|
|
124
|
+
const knownAmounts = days.flatMap(day => (day.knownAmount === undefined ? [] : [day.knownAmount]));
|
|
125
|
+
const calendarPeriodIncomplete = request.periodKey === 'trailing-12-calendar-months' &&
|
|
126
|
+
requested.endDateExclusive !== shiftUtcMonths(addUtcDays(requested.endDateExclusive, -1), 1);
|
|
127
|
+
const reasonCodes = uniqueReasons([
|
|
128
|
+
...days.flatMap(day => day.reasonCodes),
|
|
129
|
+
...(calendarPeriodIncomplete ? [CALENDAR_PERIOD_INCOMPLETE] : []),
|
|
130
|
+
]);
|
|
131
|
+
if (knownAmounts.length === 0)
|
|
132
|
+
return { ...common, status: 'unavailable', reasonCodes };
|
|
133
|
+
const knownAmount = formatExactDecimalValue(sumCanonicalDecimals(knownAmounts));
|
|
134
|
+
const average = averageKnownPerDay(knownAmount, knownAmounts.length);
|
|
135
|
+
if (!calendarPeriodIncomplete && days.every(day => day.status === 'available')) {
|
|
136
|
+
return { ...common, status: 'available', amount: knownAmount, averageKnownPerDay: average };
|
|
137
|
+
}
|
|
138
|
+
return { ...common, status: 'partial', knownAmount, averageKnownPerDay: average, reasonCodes };
|
|
139
|
+
};
|
|
@@ -6,6 +6,7 @@ import { FINANCIAL_DATAFLOW_LIMITS_V1, canonicalizeFinancialDataflowJsonV1, hasF
|
|
|
6
6
|
import { isCurrentSpendCompositionV1 } from './financialDataflowValidation.js';
|
|
7
7
|
const MAX_POINTS = 3660;
|
|
8
8
|
const MAX_GAPS = 3660;
|
|
9
|
+
const MAX_REFERENCE_COMPOSITIONS = 2;
|
|
9
10
|
const isReasonCodes = (value) => Array.isArray(value) &&
|
|
10
11
|
value.length > 0 &&
|
|
11
12
|
value.length <= FINANCIAL_DATAFLOW_LIMITS_V1.maximumReasonCodes &&
|
|
@@ -29,6 +30,7 @@ const comparableCoordinate = (value) => ({
|
|
|
29
30
|
estimateLens: value.estimateLens,
|
|
30
31
|
requestedCurrencyCode: value.requestedCurrencyCode,
|
|
31
32
|
accountingCurrency: value.accountingCurrency,
|
|
33
|
+
chargeInclusionPolicyRef: value.chargeInclusionPolicyRef,
|
|
32
34
|
});
|
|
33
35
|
const hasSameFinancialDimensions = (left, right) => canonicalizeFinancialDataflowJsonV1(comparableCoordinate(left)) === canonicalizeFinancialDataflowJsonV1(comparableCoordinate(right));
|
|
34
36
|
const hasComparableTrendPeriods = (current, comparison) => {
|
|
@@ -42,6 +44,10 @@ const hasComparableTrendPeriods = (current, comparison) => {
|
|
|
42
44
|
return (intervalDayCount(current.period.requested.startDate, current.period.requested.endDateExclusive) ===
|
|
43
45
|
intervalDayCount(comparison.period.requested.startDate, comparison.period.requested.endDateExclusive));
|
|
44
46
|
};
|
|
47
|
+
const periodContains = (container, target) => container.period.requested.dateBasis === target.period.requested.dateBasis &&
|
|
48
|
+
container.period.requested.timeZone === target.period.requested.timeZone &&
|
|
49
|
+
container.period.requested.startDate <= target.period.requested.startDate &&
|
|
50
|
+
container.period.requested.endDateExclusive >= target.period.requested.endDateExclusive;
|
|
45
51
|
const isInputIdentity = (value) => {
|
|
46
52
|
if (!isFinancialDataflowRecordV1(value) ||
|
|
47
53
|
!hasFinancialDataflowExactFieldsV1(value, [
|
|
@@ -50,6 +56,7 @@ const isInputIdentity = (value) => {
|
|
|
50
56
|
'coordinate',
|
|
51
57
|
'granularity',
|
|
52
58
|
'producerGenerationId',
|
|
59
|
+
'referenceCompositions',
|
|
53
60
|
'points',
|
|
54
61
|
'gaps',
|
|
55
62
|
'coverage',
|
|
@@ -59,9 +66,12 @@ const isInputIdentity = (value) => {
|
|
|
59
66
|
value.contractVersion !== FINANCIAL_ANALYTICS_INPUT_CONTRACT_VERSION_V1 ||
|
|
60
67
|
!isFinancialDataflowCoordinateV1(value.coordinate) ||
|
|
61
68
|
value.coordinate.periodRole !== 'analytics-input' ||
|
|
69
|
+
value.coordinate.period.windowKind !== 'analytics-history' ||
|
|
62
70
|
value.coordinate.accountingCurrency.status !== 'resolved' ||
|
|
63
71
|
value.granularity !== 'daily' ||
|
|
64
72
|
!isFinancialDataflowIdentityV1(value.producerGenerationId) ||
|
|
73
|
+
!Array.isArray(value.referenceCompositions) ||
|
|
74
|
+
value.referenceCompositions.length > MAX_REFERENCE_COMPOSITIONS ||
|
|
65
75
|
!Array.isArray(value.points) ||
|
|
66
76
|
value.points.length > MAX_POINTS ||
|
|
67
77
|
!Array.isArray(value.gaps) ||
|
|
@@ -71,6 +81,16 @@ const isInputIdentity = (value) => {
|
|
|
71
81
|
!isFinancialDataflowIdentityV1(value.algorithmVersion)) {
|
|
72
82
|
return false;
|
|
73
83
|
}
|
|
84
|
+
const coordinate = value.coordinate;
|
|
85
|
+
const referenceCompositions = value.referenceCompositions;
|
|
86
|
+
if (!referenceCompositions.every(isCurrentSpendCompositionV1))
|
|
87
|
+
return false;
|
|
88
|
+
const referenceRoles = referenceCompositions.map(composition => composition.coordinate.periodRole);
|
|
89
|
+
if (new Set(referenceCompositions.map(composition => composition.compositionId)).size !== referenceCompositions.length ||
|
|
90
|
+
new Set(referenceRoles).size !== referenceRoles.length ||
|
|
91
|
+
referenceCompositions.some(composition => !hasSameFinancialDimensions(coordinate, composition.coordinate))) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
74
94
|
const gaps = value.gaps;
|
|
75
95
|
const requested = value.coordinate.period.requested;
|
|
76
96
|
const dates = new Set();
|
|
@@ -86,16 +106,47 @@ const isInputIdentity = (value) => {
|
|
|
86
106
|
return false;
|
|
87
107
|
}
|
|
88
108
|
dates.add(point.date);
|
|
109
|
+
const pointForecastFields = [
|
|
110
|
+
point.forecastEligibleAmount,
|
|
111
|
+
point.oneTimeAmount,
|
|
112
|
+
point.unknownRecurrenceAmount,
|
|
113
|
+
];
|
|
114
|
+
if (!pointForecastFields.every(amount => isCanonicalExactMoney({ amount, currencyCode: coordinate.accountingCurrency.currencyCode })) ||
|
|
115
|
+
(point.forecastStatus !== 'available' && point.forecastStatus !== 'partial') ||
|
|
116
|
+
(point.forecastStatus === 'available'
|
|
117
|
+
? point.forecastReasonCodes !== undefined || point.unknownRecurrenceAmount !== '0'
|
|
118
|
+
: !isReasonCodes(point.forecastReasonCodes))) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
89
121
|
if (point.status === 'available') {
|
|
90
|
-
if (!hasFinancialDataflowExactFieldsV1(point, [
|
|
91
|
-
|
|
122
|
+
if (!hasFinancialDataflowExactFieldsV1(point, [
|
|
123
|
+
'date',
|
|
124
|
+
'compositionId',
|
|
125
|
+
'status',
|
|
126
|
+
'amount',
|
|
127
|
+
'forecastEligibleAmount',
|
|
128
|
+
'oneTimeAmount',
|
|
129
|
+
'unknownRecurrenceAmount',
|
|
130
|
+
'forecastStatus',
|
|
131
|
+
], ['forecastReasonCodes']) ||
|
|
132
|
+
!isCanonicalExactMoney({ amount: point.amount, currencyCode: coordinate.accountingCurrency.currencyCode }))
|
|
92
133
|
return false;
|
|
93
134
|
availableDayCount += 1;
|
|
94
135
|
}
|
|
95
136
|
else {
|
|
96
137
|
if (point.status !== 'partial' ||
|
|
97
|
-
!hasFinancialDataflowExactFieldsV1(point, [
|
|
98
|
-
|
|
138
|
+
!hasFinancialDataflowExactFieldsV1(point, [
|
|
139
|
+
'date',
|
|
140
|
+
'compositionId',
|
|
141
|
+
'status',
|
|
142
|
+
'knownAmount',
|
|
143
|
+
'reasonCodes',
|
|
144
|
+
'forecastEligibleAmount',
|
|
145
|
+
'oneTimeAmount',
|
|
146
|
+
'unknownRecurrenceAmount',
|
|
147
|
+
'forecastStatus',
|
|
148
|
+
], ['forecastReasonCodes']) ||
|
|
149
|
+
!isCanonicalExactMoney({ amount: point.knownAmount, currencyCode: coordinate.accountingCurrency.currencyCode }) ||
|
|
99
150
|
!isReasonCodes(point.reasonCodes))
|
|
100
151
|
return false;
|
|
101
152
|
partialDayCount += 1;
|
|
@@ -143,9 +194,7 @@ const isInputIdentity = (value) => {
|
|
|
143
194
|
value.coverage.partialDayCount === partialDayCount &&
|
|
144
195
|
value.coverage.unavailableDayCount === unavailableDayCount &&
|
|
145
196
|
availableDayCount + partialDayCount + unavailableDayCount === expectedDays &&
|
|
146
|
-
cursor === requested.endDateExclusive
|
|
147
|
-
value.coordinate.period.gaps.length === gaps.length &&
|
|
148
|
-
value.coordinate.period.gaps.every(periodGap => gaps.some(gap => gap.startDate === periodGap.startDate && gap.endDateExclusive === periodGap.endDateExclusive)));
|
|
197
|
+
cursor === requested.endDateExclusive);
|
|
149
198
|
};
|
|
150
199
|
export const canonicalizeFinancialAnalyticsInputIdentityV1 = (value) => {
|
|
151
200
|
if (!isFinancialDataflowValueWithinLimitsV1(value) || !isInputIdentity(value)) {
|
|
@@ -154,9 +203,16 @@ export const canonicalizeFinancialAnalyticsInputIdentityV1 = (value) => {
|
|
|
154
203
|
return canonicalizeFinancialDataflowJsonV1({
|
|
155
204
|
...value,
|
|
156
205
|
coordinate: { ...value.coordinate, providerAccountRefs: [...value.coordinate.providerAccountRefs].sort() },
|
|
206
|
+
referenceCompositions: [...value.referenceCompositions].sort((left, right) => left.compositionId.localeCompare(right.compositionId)),
|
|
157
207
|
points: [...value.points]
|
|
158
208
|
.sort((left, right) => left.date.localeCompare(right.date))
|
|
159
|
-
.map(point => (
|
|
209
|
+
.map(point => ({
|
|
210
|
+
...point,
|
|
211
|
+
...(point.status === 'partial' ? { reasonCodes: [...point.reasonCodes].sort() } : {}),
|
|
212
|
+
...(point.forecastReasonCodes === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: { forecastReasonCodes: [...point.forecastReasonCodes].sort() }),
|
|
215
|
+
})),
|
|
160
216
|
gaps: [...value.gaps]
|
|
161
217
|
.sort((left, right) => `${left.startDate}\u0000${left.endDateExclusive}`.localeCompare(`${right.startDate}\u0000${right.endDateExclusive}`))
|
|
162
218
|
.map(gap => ({ ...gap, reasonCodes: [...gap.reasonCodes].sort() })),
|
|
@@ -173,6 +229,18 @@ export const isFinancialAnalyticsInputSeriesV1 = (value) => {
|
|
|
173
229
|
};
|
|
174
230
|
const nextCalendarDate = (value) => new Date(Date.parse(`${value}T00:00:00.000Z`) + 86400000).toISOString().slice(0, 10);
|
|
175
231
|
const hasSameReasonCodes = (left, right) => left.length === right.length && left.every(reasonCode => right.includes(reasonCode));
|
|
232
|
+
const hasSameForecastSlice = (point, composition) => {
|
|
233
|
+
const selection = composition.chargeSelection;
|
|
234
|
+
return selection !== undefined &&
|
|
235
|
+
point.forecastEligibleAmount === selection.forecastEligibleAmount &&
|
|
236
|
+
point.oneTimeAmount === selection.oneTimeAmount &&
|
|
237
|
+
point.unknownRecurrenceAmount === selection.unknownRecurrenceAmount &&
|
|
238
|
+
point.forecastStatus === selection.forecastStatus &&
|
|
239
|
+
(point.forecastReasonCodes === undefined
|
|
240
|
+
? selection.forecastReasonCodes === undefined
|
|
241
|
+
: selection.forecastReasonCodes !== undefined &&
|
|
242
|
+
hasSameReasonCodes(point.forecastReasonCodes, selection.forecastReasonCodes));
|
|
243
|
+
};
|
|
176
244
|
/** Proves that every analytics point is an exact projection of its referenced daily composition. */
|
|
177
245
|
export const isFinancialAnalyticsInputSeriesCompatibleV1 = (input, dailyCompositions) => {
|
|
178
246
|
if (!isFinancialAnalyticsInputSeriesV1(input) ||
|
|
@@ -188,6 +256,7 @@ export const isFinancialAnalyticsInputSeriesCompatibleV1 = (input, dailyComposit
|
|
|
188
256
|
return input.points.every(point => {
|
|
189
257
|
const composition = compositionById.get(point.compositionId);
|
|
190
258
|
if (composition === undefined ||
|
|
259
|
+
composition.amount.status === 'unavailable' ||
|
|
191
260
|
composition.coordinate.periodRole !== 'current-spend' ||
|
|
192
261
|
composition.coordinate.period.windowKind !== 'daily' ||
|
|
193
262
|
composition.coordinate.period.requested.startDate !== point.date ||
|
|
@@ -196,11 +265,14 @@ export const isFinancialAnalyticsInputSeriesCompatibleV1 = (input, dailyComposit
|
|
|
196
265
|
return false;
|
|
197
266
|
}
|
|
198
267
|
if (point.status === 'available') {
|
|
199
|
-
return composition.amount.status === 'available' &&
|
|
268
|
+
return (composition.amount.status === 'available' &&
|
|
269
|
+
point.amount === composition.amount.amount &&
|
|
270
|
+
hasSameForecastSlice(point, composition));
|
|
200
271
|
}
|
|
201
272
|
return (composition.amount.status === 'partial' &&
|
|
202
273
|
point.knownAmount === composition.amount.knownAmount &&
|
|
203
|
-
hasSameReasonCodes(point.reasonCodes, composition.amount.reasonCodes)
|
|
274
|
+
hasSameReasonCodes(point.reasonCodes, composition.amount.reasonCodes) &&
|
|
275
|
+
hasSameForecastSlice(point, composition));
|
|
204
276
|
});
|
|
205
277
|
};
|
|
206
278
|
const isResult = (value, currency) => {
|
|
@@ -234,7 +306,6 @@ const isResult = (value, currency) => {
|
|
|
234
306
|
return (value.kind === 'anomaly' &&
|
|
235
307
|
hasFinancialDataflowExactFieldsV1(value, ['kind', 'events']) &&
|
|
236
308
|
Array.isArray(value.events) &&
|
|
237
|
-
value.events.length > 0 &&
|
|
238
309
|
value.events.length <= MAX_POINTS &&
|
|
239
310
|
value.events.every(event => {
|
|
240
311
|
if (!isFinancialDataflowRecordV1(event) ||
|
|
@@ -283,9 +354,9 @@ const isProjectionIdentity = (value) => {
|
|
|
283
354
|
return false;
|
|
284
355
|
const currency = coordinateCurrency(value.coordinate);
|
|
285
356
|
return (isFinancialDataflowHashV1(value.analyticsInputId) &&
|
|
357
|
+
isFinancialDataflowHashV1(value.currentSpendCompositionId) &&
|
|
286
358
|
currency !== undefined &&
|
|
287
|
-
isResult(value.result, currency)
|
|
288
|
-
(value.result.kind === 'anomaly' || isFinancialDataflowHashV1(value.currentSpendCompositionId)));
|
|
359
|
+
isResult(value.result, currency));
|
|
289
360
|
};
|
|
290
361
|
export const canonicalizeFinancialAnalyticsProjectionIdentityV1 = (value) => {
|
|
291
362
|
if (!isFinancialDataflowValueWithinLimitsV1(value) || !isProjectionIdentity(value)) {
|
|
@@ -334,17 +405,24 @@ export const isFinancialAnalyticsProjectionCompatibleV1 = (projection, input, cu
|
|
|
334
405
|
hasSameFinancialDimensions(projection.coordinate, currentSpendComposition.coordinate)));
|
|
335
406
|
}
|
|
336
407
|
if (projection.result.kind === 'anomaly') {
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
408
|
+
if (!isCurrentSpendCompositionV1(currentSpendComposition) ||
|
|
409
|
+
projection.currentSpendCompositionId !== currentSpendComposition.compositionId ||
|
|
410
|
+
!hasSameFinancialDimensions(projection.coordinate, currentSpendComposition.coordinate) ||
|
|
411
|
+
canonicalizeFinancialDataflowCoordinateV1({ ...projection.coordinate, periodRole: 'current-spend' }) !==
|
|
412
|
+
canonicalizeFinancialDataflowCoordinateV1(currentSpendComposition.coordinate) ||
|
|
413
|
+
!periodContains(input.coordinate, projection.coordinate)) {
|
|
341
414
|
return false;
|
|
342
415
|
}
|
|
343
416
|
const pointsByDate = new Map(input.points.map(point => [point.date, point]));
|
|
344
417
|
const eventDates = new Set();
|
|
418
|
+
const targetPeriod = projection.coordinate.period.requested;
|
|
345
419
|
return projection.result.events.every(event => {
|
|
346
420
|
const point = pointsByDate.get(event.date);
|
|
347
|
-
if (
|
|
421
|
+
if (event.date < targetPeriod.startDate ||
|
|
422
|
+
event.date >= targetPeriod.endDateExclusive ||
|
|
423
|
+
point === undefined ||
|
|
424
|
+
eventDates.has(event.date) ||
|
|
425
|
+
(projection.status === 'available' && point.status !== 'available'))
|
|
348
426
|
return false;
|
|
349
427
|
eventDates.add(event.date);
|
|
350
428
|
const observedAmount = point.status === 'available' ? point.amount : point.knownAmount;
|
|
@@ -394,7 +472,11 @@ export const isFinancialAnalyticsProjectionCompatibleV1 = (projection, input, cu
|
|
|
394
472
|
canonicalizeFinancialDataflowCoordinateV1(currentSpendComposition.coordinate)) {
|
|
395
473
|
return false;
|
|
396
474
|
}
|
|
397
|
-
if (projection.status === 'available' &&
|
|
475
|
+
if (projection.status === 'available' &&
|
|
476
|
+
currentSpendComposition.amount.status !== 'available' &&
|
|
477
|
+
!(currentSpendComposition.amount.status === 'partial' &&
|
|
478
|
+
currentSpendComposition.amount.reasonCodes.length === 1 &&
|
|
479
|
+
currentSpendComposition.amount.reasonCodes[0] === 'coverage-incomplete'))
|
|
398
480
|
return false;
|
|
399
481
|
if (currentSpendComposition.amount.status === 'unavailable')
|
|
400
482
|
return false;
|
|
@@ -5,6 +5,8 @@ import { isFinancialEvidenceAssessmentV1 } from './financialEvidenceAssessmentVa
|
|
|
5
5
|
import { formatExactDecimalValue, multiplyExactDecimalValues, parseCanonicalDecimal, sumCanonicalDecimals } from '../common/exactDecimal.js';
|
|
6
6
|
import { isFinancialBaselinePeriodV2, isFinancialEvidenceBundleV1, isFinancialScopeBaselineEnvelopeV2 } from './financialScopeBaselineValidation.js';
|
|
7
7
|
import { isFinancialAuthorityComponentDescriptorV1, isFinancialDisplayRollupV1 } from './financialDisplayRollupValidation.js';
|
|
8
|
+
import { AZURE_BILLED_ALL_CHARGES_POLICY_V1 } from './financialChargeComposition.js';
|
|
9
|
+
import { isFinancialChargeCompositionV1 } from './financialChargeCompositionValidation.js';
|
|
8
10
|
const SHA256_ID = /^sha256:[0-9a-f]{64}$/;
|
|
9
11
|
const CURRENCY = /^[A-Z]{3}$/;
|
|
10
12
|
const AZURE_RESOURCE_TYPE = /^[a-z0-9.-]+\/[a-z0-9._/-]+$/;
|
|
@@ -55,6 +57,7 @@ const canonicalCoordinatePreimage = (value) => ({
|
|
|
55
57
|
ownerBaselines: [...value.ownerBaselines].sort((left, right) => left.scopeId.localeCompare(right.scopeId)),
|
|
56
58
|
residualBaseline: value.residualBaseline,
|
|
57
59
|
aggregateBaseline: value.aggregateBaseline,
|
|
60
|
+
chargeCompositions: [...value.chargeCompositions].sort((left, right) => left.baselineId.localeCompare(right.baselineId)),
|
|
58
61
|
componentDescriptors: [...value.componentDescriptors].sort((left, right) => `${left.baselineId}\u0000${left.componentId}`.localeCompare(`${right.baselineId}\u0000${right.componentId}`)),
|
|
59
62
|
displayRollups: [...value.displayRollups].sort((left, right) => left.displayRollupId.localeCompare(right.displayRollupId)),
|
|
60
63
|
projections: [...value.projections].sort((left, right) => left.scenarioId.localeCompare(right.scenarioId)),
|
|
@@ -95,6 +98,7 @@ const isCoordinate = (value, coveredScopeIds, financialRoleByScope, providerAcco
|
|
|
95
98
|
'ownerBaselines',
|
|
96
99
|
'residualBaseline',
|
|
97
100
|
'aggregateBaseline',
|
|
101
|
+
'chargeCompositions',
|
|
98
102
|
'componentDescriptors',
|
|
99
103
|
'displayRollups',
|
|
100
104
|
'projections',
|
|
@@ -113,6 +117,9 @@ const isCoordinate = (value, coveredScopeIds, financialRoleByScope, providerAcco
|
|
|
113
117
|
!value.ownerBaselines.every(isFinancialScopeBaselineEnvelopeV2) ||
|
|
114
118
|
!isFinancialScopeBaselineEnvelopeV2(value.residualBaseline) ||
|
|
115
119
|
!isFinancialScopeBaselineEnvelopeV2(value.aggregateBaseline) ||
|
|
120
|
+
!Array.isArray(value.chargeCompositions) ||
|
|
121
|
+
value.chargeCompositions.length > 20001 ||
|
|
122
|
+
!value.chargeCompositions.every(isFinancialChargeCompositionV1) ||
|
|
116
123
|
!Array.isArray(value.componentDescriptors) ||
|
|
117
124
|
value.componentDescriptors.length > 20000 ||
|
|
118
125
|
!value.componentDescriptors.every(isFinancialAuthorityComponentDescriptorV1) ||
|
|
@@ -173,6 +180,38 @@ const isCoordinate = (value, coveredScopeIds, financialRoleByScope, providerAcco
|
|
|
173
180
|
}
|
|
174
181
|
if (coordinate.residualBaseline.status === 'available' && !bundleIds.has(coordinate.residualBaseline.evidenceBundleId))
|
|
175
182
|
return false;
|
|
183
|
+
const composableBaselines = [...coordinate.ownerBaselines, coordinate.residualBaseline].filter((baseline) => baseline.status === 'available' && baseline.baselineKind === 'owner');
|
|
184
|
+
const chargeCompositionByBaselineId = new Map(coordinate.chargeCompositions.map(composition => [composition.baselineId, composition]));
|
|
185
|
+
if (chargeCompositionByBaselineId.size !== coordinate.chargeCompositions.length ||
|
|
186
|
+
coordinate.chargeCompositions.length !== composableBaselines.length)
|
|
187
|
+
return false;
|
|
188
|
+
for (const baseline of composableBaselines) {
|
|
189
|
+
const composition = chargeCompositionByBaselineId.get(baseline.baselineId);
|
|
190
|
+
if (composition === undefined ||
|
|
191
|
+
canonicalText(baseline.chargeInclusionPolicyRef) !== canonicalText(AZURE_BILLED_ALL_CHARGES_POLICY_V1.policyRef) ||
|
|
192
|
+
composition.ownerScopeId !== baseline.scopeId ||
|
|
193
|
+
periodText(composition.period) !== periodText(baseline.period) ||
|
|
194
|
+
composition.costBasis !== baseline.costBasis ||
|
|
195
|
+
composition.estimateLens !== baseline.estimateLens ||
|
|
196
|
+
composition.accountingCurrencyCode !== baseline.total.currencyCode ||
|
|
197
|
+
composition.reconciliation.sourceTotal !== baseline.total.amount)
|
|
198
|
+
return false;
|
|
199
|
+
const baselineComponentById = new Map(baseline.components.map(component => [component.componentId, component]));
|
|
200
|
+
const partitionedComponentIds = composition.buckets.flatMap(bucket => bucket.componentIds);
|
|
201
|
+
if (partitionedComponentIds.length !== baseline.components.length ||
|
|
202
|
+
new Set(partitionedComponentIds).size !== partitionedComponentIds.length ||
|
|
203
|
+
partitionedComponentIds.some(componentId => !baselineComponentById.has(componentId)))
|
|
204
|
+
return false;
|
|
205
|
+
for (const bucket of composition.buckets) {
|
|
206
|
+
try {
|
|
207
|
+
if (formatExactDecimalValue(sumCanonicalDecimals(bucket.componentIds.map(componentId => baselineComponentById.get(componentId).amount))) !== bucket.amount)
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
176
215
|
const financialOwnerBaselines = coordinate.ownerBaselines.filter(baseline => financialRoleByScope.get(baseline.scopeId) === 'owner');
|
|
177
216
|
const availableMembers = [...financialOwnerBaselines, coordinate.residualBaseline]
|
|
178
217
|
.filter(baseline => baseline.status === 'available')
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sha256Utf8 } from '../common/sha256.js';
|
|
2
|
+
export const FINANCIAL_CHARGE_COMPOSITION_SCHEMA_VERSION_V1 = 1;
|
|
3
|
+
export const FINANCIAL_CHARGE_COMPOSITION_CONTRACT_VERSION_V1 = 'financial-charge-composition/v1';
|
|
4
|
+
export const FINANCIAL_CHARGE_INCLUSION_POLICY_CONTRACT_VERSION_V1 = 'financial-charge-inclusion-policy/v1';
|
|
5
|
+
const createRegisteredPolicy = (definition) => {
|
|
6
|
+
const { policyId, ...policy } = definition;
|
|
7
|
+
const registered = {
|
|
8
|
+
...policy,
|
|
9
|
+
includeSources: Object.freeze([...policy.includeSources]),
|
|
10
|
+
excludeSources: Object.freeze([...policy.excludeSources]),
|
|
11
|
+
withholdSources: Object.freeze([...policy.withholdSources]),
|
|
12
|
+
policyRef: Object.freeze({ policyId, policyDigest: `sha256:${sha256Utf8(JSON.stringify(definition))}` }),
|
|
13
|
+
};
|
|
14
|
+
return Object.freeze(registered);
|
|
15
|
+
};
|
|
16
|
+
export const AZURE_BILLED_ALL_CHARGES_POLICY_V1 = createRegisteredPolicy({
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
contractVersion: FINANCIAL_CHARGE_INCLUSION_POLICY_CONTRACT_VERSION_V1,
|
|
19
|
+
policyId: 'azure-billed-all-charges/v1',
|
|
20
|
+
includeSources: ['azure-native', 'marketplace', 'unknown'],
|
|
21
|
+
excludeSources: [],
|
|
22
|
+
withholdSources: [],
|
|
23
|
+
});
|
|
24
|
+
export const AZURE_CLOUD_SERVICES_EXCLUDING_MARKETPLACE_POLICY_V1 = createRegisteredPolicy({
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
contractVersion: FINANCIAL_CHARGE_INCLUSION_POLICY_CONTRACT_VERSION_V1,
|
|
27
|
+
policyId: 'azure-cloud-services-excluding-marketplace/v1',
|
|
28
|
+
includeSources: ['azure-native'],
|
|
29
|
+
excludeSources: ['marketplace'],
|
|
30
|
+
withholdSources: ['unknown'],
|
|
31
|
+
});
|
|
32
|
+
export const FINANCIAL_CHARGE_INCLUSION_POLICIES_V1 = Object.freeze([
|
|
33
|
+
AZURE_BILLED_ALL_CHARGES_POLICY_V1,
|
|
34
|
+
AZURE_CLOUD_SERVICES_EXCLUDING_MARKETPLACE_POLICY_V1,
|
|
35
|
+
]);
|
|
36
|
+
export const resolveFinancialChargeInclusionPolicyV1 = (policyRef) => policyRef !== null && typeof policyRef === 'object' && FINANCIAL_CHARGE_INCLUSION_POLICIES_V1.find(policy => policy.policyRef.policyId === policyRef.policyId && policy.policyRef.policyDigest === policyRef.policyDigest) || undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { formatExactDecimalValue, sumCanonicalDecimals } from '../common/exactDecimal.js';
|
|
2
|
+
import { FINANCIAL_CHARGE_COMPOSITION_CONTRACT_VERSION_V1, FINANCIAL_CHARGE_COMPOSITION_SCHEMA_VERSION_V1, } from './financialChargeComposition.js';
|
|
3
|
+
import { createFinancialChargeCompositionIdV1 } from './financialChargeCompositionValidation.js';
|
|
4
|
+
const bucketKey = (component) => `${component.chargeSource}\u0000${component.chargeRecurrence}\u0000${component.chargeClassification}`;
|
|
5
|
+
export const createFinancialChargeCompositionV1 = (request) => {
|
|
6
|
+
if (!Array.isArray(request.components) || request.components.length === 0 || request.components.length > 20000) {
|
|
7
|
+
throw new TypeError('Financial charge composition requires a bounded non-empty component collection.');
|
|
8
|
+
}
|
|
9
|
+
if (new Set(request.components.map(component => component.componentId)).size !== request.components.length) {
|
|
10
|
+
throw new TypeError('Financial charge composition cannot classify one component more than once.');
|
|
11
|
+
}
|
|
12
|
+
const groups = new Map();
|
|
13
|
+
for (const component of request.components) {
|
|
14
|
+
const key = bucketKey(component);
|
|
15
|
+
groups.set(key, [...(groups.get(key) ?? []), component]);
|
|
16
|
+
}
|
|
17
|
+
const buckets = [...groups.values()]
|
|
18
|
+
.map(group => ({
|
|
19
|
+
chargeSource: group[0].chargeSource,
|
|
20
|
+
chargeRecurrence: group[0].chargeRecurrence,
|
|
21
|
+
chargeClassification: group[0].chargeClassification,
|
|
22
|
+
amount: formatExactDecimalValue(sumCanonicalDecimals(group.map(component => component.amount))),
|
|
23
|
+
componentIds: [...new Set(group.map(component => component.componentId))].sort(),
|
|
24
|
+
evidenceRefIds: [...new Set(group.flatMap(component => component.evidenceRefIds))].sort(),
|
|
25
|
+
}))
|
|
26
|
+
.sort((left, right) => `${left.chargeSource}\u0000${left.chargeRecurrence}\u0000${left.chargeClassification}`.localeCompare(`${right.chargeSource}\u0000${right.chargeRecurrence}\u0000${right.chargeClassification}`));
|
|
27
|
+
const bucketTotal = formatExactDecimalValue(sumCanonicalDecimals(buckets.map(bucket => bucket.amount)));
|
|
28
|
+
if (bucketTotal !== request.sourceTotal)
|
|
29
|
+
throw new TypeError('Financial charge composition does not reconcile to its source total.');
|
|
30
|
+
const identity = {
|
|
31
|
+
schemaVersion: FINANCIAL_CHARGE_COMPOSITION_SCHEMA_VERSION_V1,
|
|
32
|
+
contractVersion: FINANCIAL_CHARGE_COMPOSITION_CONTRACT_VERSION_V1,
|
|
33
|
+
baselineId: request.baselineId,
|
|
34
|
+
ownerScopeId: request.ownerScopeId,
|
|
35
|
+
period: request.period,
|
|
36
|
+
costBasis: request.costBasis,
|
|
37
|
+
estimateLens: request.estimateLens,
|
|
38
|
+
accountingCurrencyCode: request.accountingCurrencyCode,
|
|
39
|
+
buckets,
|
|
40
|
+
reconciliation: { status: 'reconciled', bucketTotal, sourceTotal: request.sourceTotal, difference: '0' },
|
|
41
|
+
algorithmVersion: request.algorithmVersion,
|
|
42
|
+
};
|
|
43
|
+
return { ...identity, chargeCompositionId: createFinancialChargeCompositionIdV1(identity) };
|
|
44
|
+
};
|