@enyo-energy/energy-app-sdk 1.2.0 → 1.3.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.
Files changed (58) hide show
  1. package/README.md +211 -1
  2. package/dist/cjs/energy-app-package-definition.cjs +1 -0
  3. package/dist/cjs/energy-app-package-definition.d.cts +1 -0
  4. package/dist/cjs/energy-app-permission.type.cjs +2 -0
  5. package/dist/cjs/energy-app-permission.type.d.cts +3 -1
  6. package/dist/cjs/energy-app.cjs +16 -0
  7. package/dist/cjs/energy-app.d.cts +15 -0
  8. package/dist/cjs/enyo-energy-app-sdk.d.cts +3 -0
  9. package/dist/cjs/implementations/pricing/compose-electricity-prices.cjs +218 -0
  10. package/dist/cjs/implementations/pricing/compose-electricity-prices.d.cts +100 -0
  11. package/dist/cjs/implementations/pricing/price-component-validators.cjs +295 -0
  12. package/dist/cjs/implementations/pricing/price-component-validators.d.cts +90 -0
  13. package/dist/cjs/implementations/pricing/price-schedule-resolver.cjs +182 -0
  14. package/dist/cjs/implementations/pricing/price-schedule-resolver.d.cts +95 -0
  15. package/dist/cjs/index.cjs +7 -0
  16. package/dist/cjs/index.d.cts +7 -0
  17. package/dist/cjs/packages/energy-app-electricity-tariff.d.cts +34 -1
  18. package/dist/cjs/packages/energy-app-grid-fee.cjs +2 -0
  19. package/dist/cjs/packages/energy-app-grid-fee.d.cts +181 -0
  20. package/dist/cjs/types/enyo-electricity-tariff.cjs +33 -1
  21. package/dist/cjs/types/enyo-electricity-tariff.d.cts +161 -4
  22. package/dist/cjs/types/enyo-grid-fee.cjs +15 -0
  23. package/dist/cjs/types/enyo-grid-fee.d.cts +129 -0
  24. package/dist/cjs/types/enyo-price-composition.cjs +15 -0
  25. package/dist/cjs/types/enyo-price-composition.d.cts +126 -0
  26. package/dist/cjs/types/enyo-price-schedule.cjs +45 -0
  27. package/dist/cjs/types/enyo-price-schedule.d.cts +137 -0
  28. package/dist/cjs/version.cjs +1 -1
  29. package/dist/cjs/version.d.cts +1 -1
  30. package/dist/energy-app-package-definition.d.ts +1 -0
  31. package/dist/energy-app-package-definition.js +1 -0
  32. package/dist/energy-app-permission.type.d.ts +3 -1
  33. package/dist/energy-app-permission.type.js +2 -0
  34. package/dist/energy-app.d.ts +15 -0
  35. package/dist/energy-app.js +16 -0
  36. package/dist/enyo-energy-app-sdk.d.ts +3 -0
  37. package/dist/implementations/pricing/compose-electricity-prices.d.ts +100 -0
  38. package/dist/implementations/pricing/compose-electricity-prices.js +212 -0
  39. package/dist/implementations/pricing/price-component-validators.d.ts +90 -0
  40. package/dist/implementations/pricing/price-component-validators.js +285 -0
  41. package/dist/implementations/pricing/price-schedule-resolver.d.ts +95 -0
  42. package/dist/implementations/pricing/price-schedule-resolver.js +175 -0
  43. package/dist/index.d.ts +7 -0
  44. package/dist/index.js +7 -0
  45. package/dist/packages/energy-app-electricity-tariff.d.ts +34 -1
  46. package/dist/packages/energy-app-grid-fee.d.ts +181 -0
  47. package/dist/packages/energy-app-grid-fee.js +1 -0
  48. package/dist/types/enyo-electricity-tariff.d.ts +161 -4
  49. package/dist/types/enyo-electricity-tariff.js +32 -0
  50. package/dist/types/enyo-grid-fee.d.ts +129 -0
  51. package/dist/types/enyo-grid-fee.js +12 -0
  52. package/dist/types/enyo-price-composition.d.ts +126 -0
  53. package/dist/types/enyo-price-composition.js +12 -0
  54. package/dist/types/enyo-price-schedule.d.ts +137 -0
  55. package/dist/types/enyo-price-schedule.js +42 -0
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ import { EnyoTariffBonus } from '../../types/enyo-electricity-tariff.cjs';
2
+ import { EnyoEnergyPriceEntry } from '../../types/enyo-energy-prices.cjs';
3
+ import { EnyoEpexSpotPriceEntry } from '../../types/enyo-epex-spot-price.cjs';
4
+ import { EnyoComposableEnergyPriceEntry, EnyoComposeElectricityPricesInput, EnyoComposedElectricityPriceEntry } from '../../types/enyo-price-composition.cjs';
5
+ import { EnyoPriceAppliesToEnum } from '../../types/enyo-price-schedule.cjs';
6
+ /**
7
+ * Adapts a tariff price series from `useElectricityPrices().getPrices()` into
8
+ * composer input.
9
+ *
10
+ * @param entries - Price entries as returned by the electricity prices API
11
+ * @param direction - Which price to take; defaults to consumption. Feed-in
12
+ * entries without a `feedInPricePerKwh` are skipped.
13
+ * @returns Composable energy price entries
14
+ */
15
+ export declare function fromEnergyPriceEntries(entries: EnyoEnergyPriceEntry[], direction?: EnyoPriceAppliesToEnum): EnyoComposableEnergyPriceEntry[];
16
+ /**
17
+ * Adapts an EPEX SPOT day-ahead series into composer input.
18
+ *
19
+ * Spot prices are the raw exchange result and contain neither grid fees nor
20
+ * levies, taxes or supplier margin — so the tariff's `priceComposition` for such
21
+ * a series should leave every `includes*` flag `false`.
22
+ *
23
+ * @param entries - Spot price entries as returned by the EPEX SPOT price API
24
+ * @returns Composable energy price entries
25
+ */
26
+ export declare function fromEpexSpotEntries(entries: EnyoEpexSpotPriceEntry[]): EnyoComposableEnergyPriceEntry[];
27
+ /**
28
+ * Resolves a tariff's bonuses over a time range and combines them per interval
29
+ * according to the SDK's stacking rule.
30
+ *
31
+ * **Stacking rule:** overlapping bonuses add up, unless at least one bonus
32
+ * covering the interval is {@link EnyoTariffBonus.exclusive} — then the
33
+ * exclusive bonus with the highest `priority` applies alone (ties are broken by
34
+ * array order). Bonuses whose `appliesTo` does not match the requested
35
+ * direction, and intervals outside a bonus's validity window, contribute
36
+ * nothing.
37
+ *
38
+ * @param bonuses - The tariff's bonuses
39
+ * @param options - Range and direction to resolve for
40
+ * @returns A map from interval start (ISO) to the total discount and the contributing bonus ids
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const resolved = resolveTariffBonuses(tariff.bonuses ?? [], {
45
+ * fromIso: '2026-09-02T00:00:00Z',
46
+ * untilIso: '2026-09-03T00:00:00Z',
47
+ * });
48
+ * ```
49
+ */
50
+ export declare function resolveTariffBonuses(bonuses: EnyoTariffBonus[], options: {
51
+ /** Start of the range in ISO format (inclusive). */
52
+ fromIso: string;
53
+ /** End of the range in ISO format (exclusive). */
54
+ untilIso: string;
55
+ /** Direction to resolve for; defaults to consumption. */
56
+ appliesTo?: EnyoPriceAppliesToEnum;
57
+ }): Map<string, {
58
+ bonusPerKwh: number;
59
+ appliedBonusIds: string[];
60
+ }>;
61
+ /**
62
+ * Composes the effective electricity price per 15-minute interval from an energy
63
+ * price series, a dynamic grid fee and a tariff's bonuses.
64
+ *
65
+ * This runs **in the energy app**, never in the host, because only the app knows
66
+ * what its provider's API already returns: some providers deliver all-in prices
67
+ * with the network charge baked in, others deliver the pure energy price. Pass
68
+ * the tariff's `priceComposition` as `energyPriceComposition` and the composer
69
+ * reports each component without adding one that is already included.
70
+ *
71
+ * Every component is reported separately — `gridFeePerKwh` with its
72
+ * {@link EnyoPriceComponentOriginEnum}, the total `bonusPerKwh` and the
73
+ * `appliedBonusIds` behind it — so a UI can explain the number and an optimizer
74
+ * can reason about the parts.
75
+ *
76
+ * Entries are produced for the intervals present in `energyPrices`; grid fee and
77
+ * bonus values are matched by `timestampIso`, and an interval without a matching
78
+ * fee or bonus simply contributes `0`.
79
+ *
80
+ * @param input - Energy prices, what they already include, the grid fee and the bonuses
81
+ * @returns Composed entries in the order of the input energy prices
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * const tariff = await energyApp.useElectricityTariff().getDefaultTariff();
86
+ * const fees = await energyApp.useGridFee().getDynamicGridFees({fromIso, untilIso});
87
+ * const spot = await energyApp.useEpexSpotPrices().getPrices({fromIso, untilIso});
88
+ *
89
+ * const prices = composeElectricityPrices({
90
+ * energyPrices: fromEpexSpotEntries(spot.entries),
91
+ * energyPriceComposition: tariff?.priceComposition,
92
+ * gridFees: fees,
93
+ * bonuses: tariff?.bonuses,
94
+ * currency: 'EUR',
95
+ * });
96
+ *
97
+ * const cheapest = prices.reduce((a, b) => (b.effectivePricePerKwh < a.effectivePricePerKwh ? b : a));
98
+ * ```
99
+ */
100
+ export declare function composeElectricityPrices(input: EnyoComposeElectricityPricesInput): EnyoComposedElectricityPriceEntry[];
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PriceComponentValidationError = void 0;
4
+ exports.validatePriceSchedule = validatePriceSchedule;
5
+ exports.validateDynamicGridFee = validateDynamicGridFee;
6
+ exports.validateTariffBonus = validateTariffBonus;
7
+ exports.validateTariffBonuses = validateTariffBonuses;
8
+ exports.validateTariffGridFeeConfig = validateTariffGridFeeConfig;
9
+ exports.validateElectricityTariffPricing = validateElectricityTariffPricing;
10
+ const enyo_electricity_tariff_js_1 = require("../../types/enyo-electricity-tariff.cjs");
11
+ const enyo_price_schedule_js_1 = require("../../types/enyo-price-schedule.cjs");
12
+ const price_schedule_resolver_js_1 = require("./price-schedule-resolver.cjs");
13
+ /**
14
+ * Thrown when a grid fee, tariff bonus or price schedule violates one of the
15
+ * invariants enforced by this module. The message names the offending field or
16
+ * window index so callers can surface it directly to the user.
17
+ */
18
+ class PriceComponentValidationError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'PriceComponentValidationError';
22
+ }
23
+ }
24
+ exports.PriceComponentValidationError = PriceComponentValidationError;
25
+ function fail(message) {
26
+ throw new PriceComponentValidationError(message);
27
+ }
28
+ function requireNonEmpty(value, field) {
29
+ if (value === undefined || value.trim().length === 0) {
30
+ fail(`${field} must be a non-empty string`);
31
+ }
32
+ }
33
+ function requireValidTimezone(timezone, field) {
34
+ requireNonEmpty(timezone, field);
35
+ try {
36
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
37
+ }
38
+ catch {
39
+ fail(`${field} must be a valid IANA time zone identifier, got '${timezone}'`);
40
+ }
41
+ }
42
+ function requireNonNegativeAmount(amount, field) {
43
+ if (!Number.isFinite(amount)) {
44
+ fail(`${field} must be a finite number`);
45
+ }
46
+ if (amount < 0) {
47
+ fail(`${field} must not be negative — amounts are unsigned magnitudes; ` +
48
+ `grid fees are added to and bonuses subtracted from the energy price`);
49
+ }
50
+ }
51
+ function requireIsoTimestamp(value, field) {
52
+ const parsed = Date.parse(value);
53
+ if (Number.isNaN(parsed)) {
54
+ fail(`${field} must be a valid ISO timestamp, got '${value}'`);
55
+ }
56
+ return parsed;
57
+ }
58
+ function requireValidityRange(validFromIso, validUntilIso, label) {
59
+ const from = validFromIso === undefined ? undefined : requireIsoTimestamp(validFromIso, `${label}.validFromIso`);
60
+ const until = validUntilIso === undefined ? undefined : requireIsoTimestamp(validUntilIso, `${label}.validUntilIso`);
61
+ if (from !== undefined && until !== undefined && until <= from) {
62
+ fail(`${label}.validUntilIso must be after ${label}.validFromIso`);
63
+ }
64
+ }
65
+ function requireAppliesTo(appliesTo, field) {
66
+ if (!Object.values(enyo_price_schedule_js_1.EnyoPriceAppliesToEnum).includes(appliesTo)) {
67
+ fail(`${field} must be one of ${Object.values(enyo_price_schedule_js_1.EnyoPriceAppliesToEnum).join(', ')}`);
68
+ }
69
+ }
70
+ function toMinuteIntervals(start, end) {
71
+ return start < end
72
+ ? [{ start, end }]
73
+ : [{ start, end: 1440 }, { start: 0, end }];
74
+ }
75
+ function intervalsOverlap(a, b) {
76
+ return a.some(left => b.some(right => left.start < right.end && right.start < left.end));
77
+ }
78
+ function setsIntersect(a, b) {
79
+ if (a === undefined || b === undefined) {
80
+ return true;
81
+ }
82
+ return a.some(value => b.includes(value));
83
+ }
84
+ /**
85
+ * Validates a {@link EnyoPriceSchedule}. Throws on the first violation.
86
+ *
87
+ * Enforces finite, non-negative amounts, well-formed `HH:mm` times, day and
88
+ * month ranges, ordered absolute windows, and that no two windows which can
89
+ * occur on the same day overlap in time — an overlap would make the resolved
90
+ * amount depend on array order rather than on the schedule itself.
91
+ *
92
+ * Overlap detection for recurring windows is conservative: two windows are only
93
+ * compared when their day-of-week and month sets can intersect. A window that
94
+ * wraps past midnight is compared on both of its legs.
95
+ *
96
+ * @param schedule - The schedule to validate
97
+ * @param label - Prefix used in error messages, e.g. `'gridFee.schedule'`
98
+ * @throws {PriceComponentValidationError} On the first invariant violation
99
+ */
100
+ function validatePriceSchedule(schedule, label = 'schedule') {
101
+ if (schedule === null || typeof schedule !== 'object') {
102
+ fail(`${label} must be an object`);
103
+ }
104
+ if (schedule.type === enyo_price_schedule_js_1.EnyoPriceScheduleTypeEnum.Constant) {
105
+ requireNonNegativeAmount(schedule.amountPerKwh, `${label}.amountPerKwh`);
106
+ return;
107
+ }
108
+ if (schedule.type === enyo_price_schedule_js_1.EnyoPriceScheduleTypeEnum.Absolute) {
109
+ if (schedule.windows.length === 0) {
110
+ fail(`${label}.windows must contain at least one window`);
111
+ }
112
+ const ranges = schedule.windows.map((window, index) => {
113
+ const start = requireIsoTimestamp(window.startIso, `${label}.windows[${index}].startIso`);
114
+ const end = requireIsoTimestamp(window.endIso, `${label}.windows[${index}].endIso`);
115
+ if (end <= start) {
116
+ fail(`${label}.windows[${index}].endIso must be after startIso`);
117
+ }
118
+ requireNonNegativeAmount(window.amountPerKwh, `${label}.windows[${index}].amountPerKwh`);
119
+ return { start, end, index };
120
+ }).sort((a, b) => a.start - b.start);
121
+ for (let i = 1; i < ranges.length; i++) {
122
+ if (ranges[i].start < ranges[i - 1].end) {
123
+ fail(`${label}.windows[${ranges[i].index}] overlaps windows[${ranges[i - 1].index}]`);
124
+ }
125
+ }
126
+ return;
127
+ }
128
+ if (schedule.type === enyo_price_schedule_js_1.EnyoPriceScheduleTypeEnum.Recurring) {
129
+ if (schedule.windows.length === 0) {
130
+ fail(`${label}.windows must contain at least one window`);
131
+ }
132
+ const parsed = schedule.windows.map((window, index) => {
133
+ const start = (0, price_schedule_resolver_js_1.parseTimeOfDay)(window.startTimeOfDay);
134
+ const end = (0, price_schedule_resolver_js_1.parseTimeOfDay)(window.endTimeOfDay);
135
+ if (start === null) {
136
+ fail(`${label}.windows[${index}].startTimeOfDay must be a 'HH:mm' time, got '${window.startTimeOfDay}'`);
137
+ }
138
+ if (end === null) {
139
+ fail(`${label}.windows[${index}].endTimeOfDay must be a 'HH:mm' time, got '${window.endTimeOfDay}'`);
140
+ }
141
+ if (start === end) {
142
+ fail(`${label}.windows[${index}] must not start and end at the same time of day`);
143
+ }
144
+ if (window.daysOfWeek !== undefined) {
145
+ if (window.daysOfWeek.length === 0) {
146
+ fail(`${label}.windows[${index}].daysOfWeek must not be empty — omit it for 'every day'`);
147
+ }
148
+ for (const day of window.daysOfWeek) {
149
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
150
+ fail(`${label}.windows[${index}].daysOfWeek must contain integers 0 (Sunday) to 6 (Saturday), got ${day}`);
151
+ }
152
+ }
153
+ }
154
+ if (window.months !== undefined) {
155
+ if (window.months.length === 0) {
156
+ fail(`${label}.windows[${index}].months must not be empty — omit it for 'every month'`);
157
+ }
158
+ for (const month of window.months) {
159
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
160
+ fail(`${label}.windows[${index}].months must contain integers 1 to 12, got ${month}`);
161
+ }
162
+ }
163
+ }
164
+ requireNonNegativeAmount(window.amountPerKwh, `${label}.windows[${index}].amountPerKwh`);
165
+ return { window, index, intervals: toMinuteIntervals(start, end) };
166
+ });
167
+ for (let i = 0; i < parsed.length; i++) {
168
+ for (let j = i + 1; j < parsed.length; j++) {
169
+ const left = parsed[i];
170
+ const right = parsed[j];
171
+ if (!setsIntersect(left.window.daysOfWeek, right.window.daysOfWeek)) {
172
+ continue;
173
+ }
174
+ if (!setsIntersect(left.window.months, right.window.months)) {
175
+ continue;
176
+ }
177
+ if (intervalsOverlap(left.intervals, right.intervals)) {
178
+ fail(`${label}.windows[${right.index}] overlaps windows[${left.index}]`);
179
+ }
180
+ }
181
+ }
182
+ return;
183
+ }
184
+ fail(`${label}.type must be one of ${Object.values(enyo_price_schedule_js_1.EnyoPriceScheduleTypeEnum).join(', ')}`);
185
+ }
186
+ /**
187
+ * Validates a dynamic grid fee registration. Throws on the first violation.
188
+ *
189
+ * @param registration - The grid fee to validate
190
+ * @throws {PriceComponentValidationError} On the first invariant violation
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * validateDynamicGridFee(registration);
195
+ * await energyApp.useGridFee().registerGridFee(registration);
196
+ * ```
197
+ */
198
+ function validateDynamicGridFee(registration) {
199
+ requireNonEmpty(registration.id, 'gridFee.id');
200
+ requireNonEmpty(registration.name, 'gridFee.name');
201
+ requireNonEmpty(registration.gridOperator, 'gridFee.gridOperator');
202
+ requireNonEmpty(registration.currency, 'gridFee.currency');
203
+ requireValidTimezone(registration.timezone, 'gridFee.timezone');
204
+ requireAppliesTo(registration.appliesTo, 'gridFee.appliesTo');
205
+ requireValidityRange(registration.validFromIso, registration.validUntilIso, 'gridFee');
206
+ validatePriceSchedule(registration.schedule, 'gridFee.schedule');
207
+ }
208
+ /**
209
+ * Validates a single tariff bonus. Throws on the first violation.
210
+ *
211
+ * @param bonus - The bonus to validate
212
+ * @param label - Prefix used in error messages, e.g. `'bonuses[0]'`
213
+ * @throws {PriceComponentValidationError} On the first invariant violation
214
+ */
215
+ function validateTariffBonus(bonus, label = 'bonus') {
216
+ requireNonEmpty(bonus.id, `${label}.id`);
217
+ requireNonEmpty(bonus.name, `${label}.name`);
218
+ requireValidTimezone(bonus.timezone, `${label}.timezone`);
219
+ requireAppliesTo(bonus.appliesTo, `${label}.appliesTo`);
220
+ requireValidityRange(bonus.validFromIso, bonus.validUntilIso, label);
221
+ if (bonus.priority !== undefined && !Number.isFinite(bonus.priority)) {
222
+ fail(`${label}.priority must be a finite number`);
223
+ }
224
+ validatePriceSchedule(bonus.schedule, `${label}.schedule`);
225
+ }
226
+ /**
227
+ * Validates a tariff's bonus list: every bonus is individually valid and ids are
228
+ * unique within the tariff.
229
+ *
230
+ * @param bonuses - The bonuses to validate
231
+ * @throws {PriceComponentValidationError} On the first invariant violation
232
+ */
233
+ function validateTariffBonuses(bonuses) {
234
+ const seen = new Set();
235
+ bonuses.forEach((bonus, index) => {
236
+ validateTariffBonus(bonus, `bonuses[${index}]`);
237
+ if (seen.has(bonus.id)) {
238
+ fail(`bonuses[${index}].id '${bonus.id}' is used more than once in the same tariff`);
239
+ }
240
+ seen.add(bonus.id);
241
+ });
242
+ }
243
+ /**
244
+ * Validates how a tariff's pricing data declares its grid fee.
245
+ *
246
+ * Catches the configuration that silently double-charges the customer: declaring
247
+ * a dynamic grid fee while also declaring that the tariff's prices already
248
+ * include the grid fee. Anyone trusting the declaration would add the fee on top
249
+ * of prices that already contain it.
250
+ *
251
+ * @param config - The grid fee configuration of one tariff pricing shape
252
+ * @param priceComposition - The tariff's price composition declaration, if any
253
+ * @param label - Prefix used in error messages, e.g. `'dynamicTariffData'`
254
+ * @throws {PriceComponentValidationError} On the first invariant violation
255
+ */
256
+ function validateTariffGridFeeConfig(config, priceComposition, label = 'tariffData') {
257
+ const mode = config.gridFeeMode ?? enyo_electricity_tariff_js_1.GridFeeModeEnum.Static;
258
+ if (!Object.values(enyo_electricity_tariff_js_1.GridFeeModeEnum).includes(mode)) {
259
+ fail(`${label}.gridFeeMode must be one of ${Object.values(enyo_electricity_tariff_js_1.GridFeeModeEnum).join(', ')}`);
260
+ }
261
+ if (mode === enyo_electricity_tariff_js_1.GridFeeModeEnum.Dynamic && priceComposition?.includesGridFee === true) {
262
+ fail(`${label} declares a dynamic grid fee while priceComposition.includesGridFee is true — ` +
263
+ `the fee is already contained in the tariff's prices and must not be added again`);
264
+ }
265
+ }
266
+ /**
267
+ * Validates the pricing-related parts of a complete electricity tariff: the grid
268
+ * fee link of whichever pricing shape is present, and the tariff's bonuses.
269
+ *
270
+ * Call it before {@link EnergyAppElectricityTariff.registerTariff} to turn what
271
+ * would otherwise be a silently mispriced tariff into an actionable error.
272
+ *
273
+ * @param tariff - The tariff to validate (with or without its `id`)
274
+ * @throws {PriceComponentValidationError} On the first invariant violation
275
+ *
276
+ * @example
277
+ * ```typescript
278
+ * validateElectricityTariffPricing(tariff);
279
+ * await energyApp.useElectricityTariff().registerTariff(tariff);
280
+ * ```
281
+ */
282
+ function validateElectricityTariffPricing(tariff) {
283
+ if (tariff.staticTariffData !== undefined) {
284
+ validateTariffGridFeeConfig(tariff.staticTariffData, tariff.priceComposition, 'staticTariffData');
285
+ }
286
+ if (tariff.timeVariableTariffData !== undefined) {
287
+ validateTariffGridFeeConfig(tariff.timeVariableTariffData, tariff.priceComposition, 'timeVariableTariffData');
288
+ }
289
+ if (tariff.dynamicTariffData !== undefined) {
290
+ validateTariffGridFeeConfig(tariff.dynamicTariffData, tariff.priceComposition, 'dynamicTariffData');
291
+ }
292
+ if (tariff.bonuses !== undefined) {
293
+ validateTariffBonuses(tariff.bonuses);
294
+ }
295
+ }
@@ -0,0 +1,90 @@
1
+ import { EnyoElectricityTariff, EnyoTariffBonus, TariffGridFeeConfig, TariffPriceCompositionInfo } from '../../types/enyo-electricity-tariff.cjs';
2
+ import { EnyoDynamicGridFeeRegistration } from '../../types/enyo-grid-fee.cjs';
3
+ import { EnyoPriceSchedule } from '../../types/enyo-price-schedule.cjs';
4
+ /**
5
+ * Thrown when a grid fee, tariff bonus or price schedule violates one of the
6
+ * invariants enforced by this module. The message names the offending field or
7
+ * window index so callers can surface it directly to the user.
8
+ */
9
+ export declare class PriceComponentValidationError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ /**
13
+ * Validates a {@link EnyoPriceSchedule}. Throws on the first violation.
14
+ *
15
+ * Enforces finite, non-negative amounts, well-formed `HH:mm` times, day and
16
+ * month ranges, ordered absolute windows, and that no two windows which can
17
+ * occur on the same day overlap in time — an overlap would make the resolved
18
+ * amount depend on array order rather than on the schedule itself.
19
+ *
20
+ * Overlap detection for recurring windows is conservative: two windows are only
21
+ * compared when their day-of-week and month sets can intersect. A window that
22
+ * wraps past midnight is compared on both of its legs.
23
+ *
24
+ * @param schedule - The schedule to validate
25
+ * @param label - Prefix used in error messages, e.g. `'gridFee.schedule'`
26
+ * @throws {PriceComponentValidationError} On the first invariant violation
27
+ */
28
+ export declare function validatePriceSchedule(schedule: EnyoPriceSchedule, label?: string): void;
29
+ /**
30
+ * Validates a dynamic grid fee registration. Throws on the first violation.
31
+ *
32
+ * @param registration - The grid fee to validate
33
+ * @throws {PriceComponentValidationError} On the first invariant violation
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * validateDynamicGridFee(registration);
38
+ * await energyApp.useGridFee().registerGridFee(registration);
39
+ * ```
40
+ */
41
+ export declare function validateDynamicGridFee(registration: EnyoDynamicGridFeeRegistration): void;
42
+ /**
43
+ * Validates a single tariff bonus. Throws on the first violation.
44
+ *
45
+ * @param bonus - The bonus to validate
46
+ * @param label - Prefix used in error messages, e.g. `'bonuses[0]'`
47
+ * @throws {PriceComponentValidationError} On the first invariant violation
48
+ */
49
+ export declare function validateTariffBonus(bonus: EnyoTariffBonus, label?: string): void;
50
+ /**
51
+ * Validates a tariff's bonus list: every bonus is individually valid and ids are
52
+ * unique within the tariff.
53
+ *
54
+ * @param bonuses - The bonuses to validate
55
+ * @throws {PriceComponentValidationError} On the first invariant violation
56
+ */
57
+ export declare function validateTariffBonuses(bonuses: EnyoTariffBonus[]): void;
58
+ /**
59
+ * Validates how a tariff's pricing data declares its grid fee.
60
+ *
61
+ * Catches the configuration that silently double-charges the customer: declaring
62
+ * a dynamic grid fee while also declaring that the tariff's prices already
63
+ * include the grid fee. Anyone trusting the declaration would add the fee on top
64
+ * of prices that already contain it.
65
+ *
66
+ * @param config - The grid fee configuration of one tariff pricing shape
67
+ * @param priceComposition - The tariff's price composition declaration, if any
68
+ * @param label - Prefix used in error messages, e.g. `'dynamicTariffData'`
69
+ * @throws {PriceComponentValidationError} On the first invariant violation
70
+ */
71
+ export declare function validateTariffGridFeeConfig(config: TariffGridFeeConfig, priceComposition: TariffPriceCompositionInfo | undefined, label?: string): void;
72
+ /**
73
+ * Validates the pricing-related parts of a complete electricity tariff: the grid
74
+ * fee link of whichever pricing shape is present, and the tariff's bonuses.
75
+ *
76
+ * Call it before {@link EnergyAppElectricityTariff.registerTariff} to turn what
77
+ * would otherwise be a silently mispriced tariff into an actionable error.
78
+ *
79
+ * @param tariff - The tariff to validate (with or without its `id`)
80
+ * @throws {PriceComponentValidationError} On the first invariant violation
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * validateElectricityTariffPricing(tariff);
85
+ * await energyApp.useElectricityTariff().registerTariff(tariff);
86
+ * ```
87
+ */
88
+ export declare function validateElectricityTariffPricing(tariff: Omit<EnyoElectricityTariff, 'id'> & {
89
+ id?: string;
90
+ }): void;
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PRICE_SCHEDULE_INTERVAL_MINUTES = void 0;
4
+ exports.getZonedDateParts = getZonedDateParts;
5
+ exports.parseTimeOfDay = parseTimeOfDay;
6
+ exports.floorToInterval = floorToInterval;
7
+ exports.resolvePriceSchedule = resolvePriceSchedule;
8
+ const enyo_price_schedule_js_1 = require("../../types/enyo-price-schedule.cjs");
9
+ /**
10
+ * Length of one resolved interval, in minutes. Matches the resolution of
11
+ * `useElectricityPrices().getPrices()` so resolved schedules can be combined
12
+ * with a price series interval by interval.
13
+ */
14
+ exports.PRICE_SCHEDULE_INTERVAL_MINUTES = 15;
15
+ const INTERVAL_MS = exports.PRICE_SCHEDULE_INTERVAL_MINUTES * 60 * 1000;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const WEEKDAY_INDEX = {
18
+ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
19
+ };
20
+ const formatterCache = new Map();
21
+ function zonedFormatter(timezone) {
22
+ let formatter = formatterCache.get(timezone);
23
+ if (formatter === undefined) {
24
+ formatter = new Intl.DateTimeFormat('en-US', {
25
+ timeZone: timezone,
26
+ hourCycle: 'h23',
27
+ year: 'numeric',
28
+ month: 'numeric',
29
+ day: 'numeric',
30
+ weekday: 'short',
31
+ hour: 'numeric',
32
+ minute: 'numeric',
33
+ });
34
+ formatterCache.set(timezone, formatter);
35
+ }
36
+ return formatter;
37
+ }
38
+ /**
39
+ * Projects an instant onto the wall clock of a time zone.
40
+ *
41
+ * Uses `Intl.DateTimeFormat`, so daylight-saving transitions are handled by the
42
+ * platform's time zone database without pulling in a dependency.
43
+ *
44
+ * @param date - The instant to project
45
+ * @param timezone - IANA time zone identifier, e.g. `'Europe/Berlin'`
46
+ * @returns The wall-clock calendar fields of `date` in `timezone`
47
+ * @throws {RangeError} If `timezone` is not a valid IANA identifier
48
+ */
49
+ function getZonedDateParts(date, timezone) {
50
+ const parts = zonedFormatter(timezone).formatToParts(date);
51
+ const lookup = {};
52
+ for (const part of parts) {
53
+ lookup[part.type] = part.value;
54
+ }
55
+ const hour = Number(lookup.hour);
56
+ const minute = Number(lookup.minute);
57
+ return {
58
+ year: Number(lookup.year),
59
+ month: Number(lookup.month),
60
+ day: Number(lookup.day),
61
+ weekday: WEEKDAY_INDEX[lookup.weekday] ?? 0,
62
+ hour,
63
+ minute,
64
+ minutesOfDay: hour * 60 + minute,
65
+ };
66
+ }
67
+ /**
68
+ * Parses an `HH:mm` wall-clock time into minutes since midnight.
69
+ *
70
+ * @param value - Time in 24-hour `HH:mm` notation, e.g. `'22:00'`
71
+ * @returns Minutes since midnight, or `null` when the value is malformed
72
+ */
73
+ function parseTimeOfDay(value) {
74
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
75
+ if (match === null) {
76
+ return null;
77
+ }
78
+ return Number(match[1]) * 60 + Number(match[2]);
79
+ }
80
+ /**
81
+ * Rounds an instant down to the start of its 15-minute interval.
82
+ *
83
+ * @param timestampMs - Epoch milliseconds
84
+ * @returns Epoch milliseconds of the interval start
85
+ */
86
+ function floorToInterval(timestampMs) {
87
+ return Math.floor(timestampMs / INTERVAL_MS) * INTERVAL_MS;
88
+ }
89
+ function matchesRecurringWindow(window, timestampMs, timezone) {
90
+ const start = parseTimeOfDay(window.startTimeOfDay);
91
+ const end = parseTimeOfDay(window.endTimeOfDay);
92
+ if (start === null || end === null || start === end) {
93
+ return false;
94
+ }
95
+ const parts = getZonedDateParts(new Date(timestampMs), timezone);
96
+ let inWindow;
97
+ let anchor;
98
+ if (start < end) {
99
+ inWindow = parts.minutesOfDay >= start && parts.minutesOfDay < end;
100
+ anchor = parts;
101
+ }
102
+ else if (parts.minutesOfDay >= start) {
103
+ // Evening leg of a window that wraps past midnight.
104
+ inWindow = true;
105
+ anchor = parts;
106
+ }
107
+ else if (parts.minutesOfDay < end) {
108
+ // Early-morning leg — the window belongs to the previous local day.
109
+ inWindow = true;
110
+ anchor = getZonedDateParts(new Date(timestampMs - DAY_MS), timezone);
111
+ }
112
+ else {
113
+ return false;
114
+ }
115
+ if (!inWindow) {
116
+ return false;
117
+ }
118
+ if (window.daysOfWeek !== undefined && !window.daysOfWeek.includes(anchor.weekday)) {
119
+ return false;
120
+ }
121
+ return window.months === undefined || window.months.includes(anchor.month);
122
+ }
123
+ function matchesAbsoluteWindow(window, timestampMs) {
124
+ return timestampMs >= Date.parse(window.startIso) && timestampMs < Date.parse(window.endIso);
125
+ }
126
+ function amountAt(schedule, timestampMs, timezone) {
127
+ if (schedule.type === enyo_price_schedule_js_1.EnyoPriceScheduleTypeEnum.Constant) {
128
+ return schedule.amountPerKwh;
129
+ }
130
+ if (schedule.type === enyo_price_schedule_js_1.EnyoPriceScheduleTypeEnum.Absolute) {
131
+ const window = schedule.windows.find(candidate => matchesAbsoluteWindow(candidate, timestampMs));
132
+ return window?.amountPerKwh ?? 0;
133
+ }
134
+ const window = schedule.windows.find(candidate => matchesRecurringWindow(candidate, timestampMs, timezone));
135
+ return window?.amountPerKwh ?? 0;
136
+ }
137
+ /**
138
+ * Expands a price schedule into a flat 15-minute series over a time range.
139
+ *
140
+ * The range start is rounded down to the enclosing 15-minute interval. Intervals
141
+ * no window covers resolve to `amountPerKwh: 0`; intervals outside the
142
+ * component's validity window are omitted entirely, so an empty result means
143
+ * "this component does not apply here" rather than "it is free here".
144
+ *
145
+ * Windows are evaluated in array order and the first match wins — schedules are
146
+ * expected to be overlap-free, which the validators enforce at registration
147
+ * time.
148
+ *
149
+ * @param schedule - The schedule to expand
150
+ * @param options - Range, time zone and optional validity bounds
151
+ * @returns Resolved entries sorted ascending by `timestampIso`
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * const entries = resolvePriceSchedule(
156
+ * {
157
+ * type: EnyoPriceScheduleTypeEnum.Recurring,
158
+ * windows: [{ startTimeOfDay: '22:00', endTimeOfDay: '06:00', amountPerKwh: 0.05 }],
159
+ * },
160
+ * { fromIso: '2026-09-02T00:00:00Z', untilIso: '2026-09-03T00:00:00Z', timezone: 'Europe/Berlin' },
161
+ * );
162
+ * ```
163
+ */
164
+ function resolvePriceSchedule(schedule, options) {
165
+ const untilMs = Date.parse(options.untilIso);
166
+ const validFromMs = options.validFromIso === undefined ? undefined : Date.parse(options.validFromIso);
167
+ const validUntilMs = options.validUntilIso === undefined ? undefined : Date.parse(options.validUntilIso);
168
+ const entries = [];
169
+ for (let cursor = floorToInterval(Date.parse(options.fromIso)); cursor < untilMs; cursor += INTERVAL_MS) {
170
+ if (validFromMs !== undefined && cursor < validFromMs) {
171
+ continue;
172
+ }
173
+ if (validUntilMs !== undefined && cursor >= validUntilMs) {
174
+ break;
175
+ }
176
+ entries.push({
177
+ timestampIso: new Date(cursor).toISOString(),
178
+ amountPerKwh: amountAt(schedule, cursor, options.timezone),
179
+ });
180
+ }
181
+ return entries;
182
+ }