@enyo-energy/energy-app-sdk 1.36.0 → 1.37.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 (30) hide show
  1. package/README.md +61 -2
  2. package/dist/cjs/energy-app-package-definition.d.cts +10 -2
  3. package/dist/cjs/implementations/subscriptions/define-subscription-plan.cjs +39 -1
  4. package/dist/cjs/implementations/subscriptions/define-subscription-plan.d.cts +32 -2
  5. package/dist/cjs/implementations/subscriptions/in-memory-subscriptions.cjs +46 -6
  6. package/dist/cjs/implementations/subscriptions/in-memory-subscriptions.d.cts +28 -3
  7. package/dist/cjs/implementations/subscriptions/subscription-plan-validators.cjs +50 -0
  8. package/dist/cjs/implementations/subscriptions/subscription-plan-validators.d.cts +25 -0
  9. package/dist/cjs/packages/energy-app-subscriptions.d.cts +55 -3
  10. package/dist/cjs/packages/energy-app-webhook.d.cts +21 -2
  11. package/dist/cjs/types/enyo-subscription.cjs +30 -1
  12. package/dist/cjs/types/enyo-subscription.d.cts +107 -4
  13. package/dist/cjs/types/enyo-webhook.d.cts +27 -0
  14. package/dist/cjs/version.cjs +1 -1
  15. package/dist/cjs/version.d.cts +1 -1
  16. package/dist/energy-app-package-definition.d.ts +10 -2
  17. package/dist/implementations/subscriptions/define-subscription-plan.d.ts +32 -2
  18. package/dist/implementations/subscriptions/define-subscription-plan.js +37 -1
  19. package/dist/implementations/subscriptions/in-memory-subscriptions.d.ts +28 -3
  20. package/dist/implementations/subscriptions/in-memory-subscriptions.js +46 -6
  21. package/dist/implementations/subscriptions/subscription-plan-validators.d.ts +25 -0
  22. package/dist/implementations/subscriptions/subscription-plan-validators.js +50 -1
  23. package/dist/packages/energy-app-subscriptions.d.ts +55 -3
  24. package/dist/packages/energy-app-webhook.d.ts +21 -2
  25. package/dist/types/enyo-subscription.d.ts +107 -4
  26. package/dist/types/enyo-subscription.js +29 -0
  27. package/dist/types/enyo-webhook.d.ts +27 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/package.json +1 -1
package/README.md CHANGED
@@ -2276,6 +2276,17 @@ const listenerId = webhook.listenForWebhook<VendorEvent>(async (event) => {
2276
2276
  assertion, not validation — check the fields you rely on.
2277
2277
  - **At-least-once delivery.** A call may be forwarded more than once and order is not guaranteed —
2278
2278
  deduplicate on `webhookRequestId`.
2279
+ - **Hidden sources need a token.** Some webhook sources are only delivered to listeners that present their static
2280
+ access token. Pass it per listener; the listener then receives these sources in addition to the public ones:
2281
+
2282
+ ```typescript
2283
+ webhook.listenForWebhook<VendorEvent>(handleEvent, {
2284
+ secretKeys: [{ source: 'vendor-cloud', token: vendorWebhookToken }]
2285
+ });
2286
+ ```
2287
+
2288
+ The token only applies to that listener, and a source with a wrong token is not delivered. Keep the token in the
2289
+ secret manager, not in the source code.
2279
2290
 
2280
2291
  Requires the `Webhook` permission.
2281
2292
 
@@ -2308,6 +2319,8 @@ subscriptions.listenForSubscriptionChanges(async ({ subscription, previousStatus
2308
2319
  trial, grace or paid period) without a connection.
2309
2320
  - **Fall back safely.** When a plan stops being active, release any control over appliances instead of leaving
2310
2321
  them in the last commanded state.
2322
+ - **Per-unit plans:** report the units with `setUnits(planKey, units)` and check each one with
2323
+ `isUnitActive(subscriptions, planKey, unit)` — see [Per-unit plans](#per-unit-plans).
2311
2324
  - **Point the user to an offer** with a notification target `{ type: 'subscription-offer', planKey: 'premium' }`,
2312
2325
  or set `EnergyAppStateEnum.SubscriptionRequired` when the whole app needs a plan.
2313
2326
 
@@ -2972,7 +2985,8 @@ const packageDef = defineEnergyAppPackage({
2972
2985
  |---|---|
2973
2986
  | `planKey` | Kebab-case slug, unique in the package. Subscriptions refer to the plan by it — treat it as an API. |
2974
2987
  | `paymentInterval` | `one-time`, `monthly` or `yearly`. One-time purchases never renew and stay `active` until refunded. |
2975
- | `prices` | One net price per currency. `amountWithoutTax` is always the **total for one interval** — per month, per year, or the single one-time amount — in the currency (not cent), at most two decimals; enyo adds VAT. |
2988
+ | `pricingModel` | `per-package` (default): one price for the package on the device. `per-unit`: the price is charged per unit the app reports — see [Per-unit plans](#per-unit-plans). |
2989
+ | `prices` | One net price per currency. `amountWithoutTax` is always the **total for one interval** — per month, per year, or the single one-time amount; per unit for `per-unit` plans — in the currency (not cent), at most two decimals; enyo adds VAT. |
2976
2990
  | `trialDays` | Optional free trial in days. Not allowed for `one-time` plans. |
2977
2991
  | `storeEntry` | Title, short description and optional benefits; `de` and `en` are required. |
2978
2992
  | `internalComment` | Optional note, never shown to users. |
@@ -2980,6 +2994,51 @@ const packageDef = defineEnergyAppPackage({
2980
2994
  What a plan unlocks is decided by the app: check `isPlanActive(subscriptions, planKey)` at runtime (see
2981
2995
  [`useSubscriptions()`](#usesubscriptions-energyappsubscriptions)).
2982
2996
 
2997
+ ### Per-unit plans
2998
+
2999
+ For a `per-unit` plan the price is charged once per unit, and the **quantity comes from the energy app**. The app
3000
+ reports every unit it currently manages with `setUnits()` — on boot and whenever a unit is added or removed. Each
3001
+ unit has a `type` and a `referenceId` it is paid under:
3002
+
3003
+ | `type` | `referenceId` | Name shown to the customer |
3004
+ |---|---|---|
3005
+ | `appliance` | the appliance id | taken from the appliance |
3006
+ | `vehicle` | the vehicle id | taken from the vehicle |
3007
+ | `custom` | an id chosen by the app | `name`, translated; `de` and `en` are required |
3008
+
3009
+ ```typescript
3010
+ import { EnyoSubscriptionUnitTypeEnum, isUnitActive } from '@enyo-energy/energy-app-sdk';
3011
+
3012
+ const subscriptions = energyApp.useSubscriptions();
3013
+ const wallboxes = await energyApp.useAppliances().list();
3014
+
3015
+ // 3 wallboxes on a yearly plan for 10 € → 30 € per year (net).
3016
+ await subscriptions.setUnits('smart-charging', wallboxes.map(wallbox => ({
3017
+ type: EnyoSubscriptionUnitTypeEnum.Appliance,
3018
+ referenceId: wallbox.id
3019
+ })));
3020
+
3021
+ // A custom unit carries its own translated name.
3022
+ await subscriptions.setUnits('room-climate', [{
3023
+ type: EnyoSubscriptionUnitTypeEnum.Custom,
3024
+ referenceId: 'living-room',
3025
+ name: [{ language: 'en', value: 'Living room' }, { language: 'de', value: 'Wohnzimmer' }]
3026
+ }]);
3027
+
3028
+ // Only the units the customer paid for are active.
3029
+ const all = await subscriptions.getSubscriptions();
3030
+ for (const wallbox of wallboxes) {
3031
+ setSmartCharging(wallbox, isUnitActive(all, 'smart-charging', { type: 'appliance', referenceId: wallbox.id }));
3032
+ }
3033
+ ```
3034
+
3035
+ - **Reporting a unit does not pay for it.** Paid units are listed in `subscription.units`; when the customer pays
3036
+ for more units, the change listeners are called.
3037
+ - **Keep `referenceId` stable.** `type` plus `referenceId` identify the unit; a new reference is a new, unpaid unit.
3038
+ - **enyo decides how a changed quantity is charged**, e.g. pro rata for the rest of the period.
3039
+ - `validateSubscriptionUnits(units)` applies the host's rules up front: known type, non-empty reference, no unit
3040
+ twice, `de` and `en` names on custom units.
3041
+
2983
3042
  **A price change needs a new release** and only applies to new purchases — running subscriptions keep the price
2984
3043
  they were bought at.
2985
3044
 
@@ -2990,7 +3049,7 @@ const { ok, errors } = validatePackageSubscriptionPlans(packageDef);
2990
3049
  // or: assertValidPackageSubscriptionPlans(packageDef) — throws PackageSubscriptionPlansValidationError
2991
3050
  ```
2992
3051
 
2993
- It checks that plan keys are unique slugs, that the payment interval is known, that every plan has at least one
3052
+ It checks that plan keys are unique slugs, that the payment interval and pricing model are known, that every plan has at least one
2994
3053
  positive price per currency with at most two decimals, that `trialDays` is a positive whole number and not set on a
2995
3054
  `one-time` plan, that `de` and
2996
3055
  `en` store entries exist, and that the package requests the `Subscriptions` permission.
@@ -2,7 +2,7 @@ import { EnergyAppPermissionType } from "./energy-app-permission.type.cjs";
2
2
  import { EnergyAppModelFeatureEnum } from "./energy-app-model-feature.enum.cjs";
3
3
  import type { EnyoFileTranslation } from "./types/enyo-file.cjs";
4
4
  import type { EnyoOnboardingV2Guide } from "./types/enyo-onboarding-v2.cjs";
5
- import type { EnyoSubscriptionPaymentInterval, EnyoSubscriptionPrice } from "./types/enyo-subscription.cjs";
5
+ import type { EnyoSubscriptionPaymentInterval, EnyoSubscriptionPrice, EnyoSubscriptionPricingModel } from "./types/enyo-subscription.cjs";
6
6
  export type EnergyAppPackageLanguage = 'de' | 'en';
7
7
  export declare enum EnergyAppPackageCategory {
8
8
  Inverter = "inverter",
@@ -333,10 +333,18 @@ export interface EnergyAppPackageSubscriptionPlan {
333
333
  planKey: string;
334
334
  /** How often the plan is paid for: `one-time`, `monthly` or `yearly`. */
335
335
  paymentInterval: EnyoSubscriptionPaymentInterval;
336
+ /**
337
+ * Whether the price is charged once for the package on the device
338
+ * (`per-package`, the default) or once per unit (`per-unit`). For
339
+ * `per-unit` the app reports the units — appliances, vehicles or custom
340
+ * units — at runtime with {@link EnergyAppSubscriptions.setUnits}.
341
+ */
342
+ pricingModel?: EnyoSubscriptionPricingModel;
336
343
  /**
337
344
  * Net price, one entry per currency. At least one entry is required. The
338
345
  * amount is always the total for one interval: per month for `monthly`,
339
- * per year for `yearly`, and the single amount for `one-time`.
346
+ * per year for `yearly`, and the single amount for `one-time`. For a
347
+ * `per-unit` plan it is the amount per unit.
340
348
  */
341
349
  prices: EnyoSubscriptionPrice[];
342
350
  /**
@@ -7,7 +7,8 @@
7
7
  * where it is written — mirroring `definePublicFile()` and
8
8
  * `defineFirmwareFile()`. `isSubscriptionActive()` and `isPlanActive()` hold
9
9
  * the single rule for which subscription statuses grant access, so every app
10
- * applies it the same way.
10
+ * applies it the same way; `isUnitActive()` applies it to one unit of a
11
+ * `per-unit` plan.
11
12
  *
12
13
  * Pair with `validatePackageSubscriptionPlans()`
13
14
  * (`./subscription-plan-validators.ts`) to fail fast before publishing.
@@ -17,6 +18,8 @@ exports.ACTIVE_SUBSCRIPTION_STATUSES = void 0;
17
18
  exports.defineSubscriptionPlan = defineSubscriptionPlan;
18
19
  exports.isSubscriptionActive = isSubscriptionActive;
19
20
  exports.isPlanActive = isPlanActive;
21
+ exports.isSameSubscriptionUnit = isSameSubscriptionUnit;
22
+ exports.isUnitActive = isUnitActive;
20
23
  const enyo_subscription_js_1 = require("../../types/enyo-subscription.cjs");
21
24
  /**
22
25
  * Subscription statuses in which the customer may use what a plan unlocks.
@@ -84,3 +87,38 @@ function isSubscriptionActive(subscription) {
84
87
  function isPlanActive(subscriptions, planKey) {
85
88
  return subscriptions.some(subscription => subscription.planKey === planKey && isSubscriptionActive(subscription));
86
89
  }
90
+ /**
91
+ * Tests whether two units are the same unit, i.e. have the same `type` and
92
+ * `referenceId`. The name of a custom unit is not compared.
93
+ *
94
+ * @param a - The first unit.
95
+ * @param b - The second unit.
96
+ * @returns True when both identify the same unit.
97
+ */
98
+ function isSameSubscriptionUnit(a, b) {
99
+ return a.type === b.type && a.referenceId === b.referenceId;
100
+ }
101
+ /**
102
+ * Tests whether one unit of a `per-unit` plan is paid for, i.e. whether an
103
+ * active subscription for the plan lists the unit in
104
+ * {@link EnyoSubscription.units}.
105
+ *
106
+ * @param subscriptions - The subscriptions, as returned by
107
+ * {@link EnergyAppSubscriptions.getSubscriptions}.
108
+ * @param planKey - The {@link EnergyAppPackageSubscriptionPlan.planKey} to check.
109
+ * @param unit - The unit to check, identified by `type` and `referenceId`.
110
+ * @returns True when the unit is covered by an active subscription for the plan.
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const subscriptions = await energyApp.useSubscriptions().getSubscriptions();
115
+ * if (isUnitActive(subscriptions, 'smart-charging', {type: 'appliance', referenceId: wallbox.id})) {
116
+ * enableSmartCharging(wallbox);
117
+ * }
118
+ * ```
119
+ */
120
+ function isUnitActive(subscriptions, planKey, unit) {
121
+ return subscriptions.some(subscription => subscription.planKey === planKey
122
+ && isSubscriptionActive(subscription)
123
+ && (subscription.units ?? []).some(paid => isSameSubscriptionUnit(paid, unit)));
124
+ }
@@ -6,13 +6,14 @@
6
6
  * where it is written — mirroring `definePublicFile()` and
7
7
  * `defineFirmwareFile()`. `isSubscriptionActive()` and `isPlanActive()` hold
8
8
  * the single rule for which subscription statuses grant access, so every app
9
- * applies it the same way.
9
+ * applies it the same way; `isUnitActive()` applies it to one unit of a
10
+ * `per-unit` plan.
10
11
  *
11
12
  * Pair with `validatePackageSubscriptionPlans()`
12
13
  * (`./subscription-plan-validators.ts`) to fail fast before publishing.
13
14
  */
14
15
  import type { EnergyAppPackageSubscriptionPlan } from '../../energy-app-package-definition.cjs';
15
- import { EnyoSubscription, EnyoSubscriptionStatus } from '../../types/enyo-subscription.cjs';
16
+ import { EnyoSubscription, EnyoSubscriptionStatus, EnyoSubscriptionUnit } from '../../types/enyo-subscription.cjs';
16
17
  /**
17
18
  * Subscription statuses in which the customer may use what a plan unlocks.
18
19
  *
@@ -68,3 +69,32 @@ export declare function isSubscriptionActive(subscription: EnyoSubscription): bo
68
69
  * ```
69
70
  */
70
71
  export declare function isPlanActive(subscriptions: EnyoSubscription[], planKey: string): boolean;
72
+ /**
73
+ * Tests whether two units are the same unit, i.e. have the same `type` and
74
+ * `referenceId`. The name of a custom unit is not compared.
75
+ *
76
+ * @param a - The first unit.
77
+ * @param b - The second unit.
78
+ * @returns True when both identify the same unit.
79
+ */
80
+ export declare function isSameSubscriptionUnit(a: Pick<EnyoSubscriptionUnit, 'type' | 'referenceId'>, b: Pick<EnyoSubscriptionUnit, 'type' | 'referenceId'>): boolean;
81
+ /**
82
+ * Tests whether one unit of a `per-unit` plan is paid for, i.e. whether an
83
+ * active subscription for the plan lists the unit in
84
+ * {@link EnyoSubscription.units}.
85
+ *
86
+ * @param subscriptions - The subscriptions, as returned by
87
+ * {@link EnergyAppSubscriptions.getSubscriptions}.
88
+ * @param planKey - The {@link EnergyAppPackageSubscriptionPlan.planKey} to check.
89
+ * @param unit - The unit to check, identified by `type` and `referenceId`.
90
+ * @returns True when the unit is covered by an active subscription for the plan.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * const subscriptions = await energyApp.useSubscriptions().getSubscriptions();
95
+ * if (isUnitActive(subscriptions, 'smart-charging', {type: 'appliance', referenceId: wallbox.id})) {
96
+ * enableSmartCharging(wallbox);
97
+ * }
98
+ * ```
99
+ */
100
+ export declare function isUnitActive(subscriptions: EnyoSubscription[], planKey: string, unit: Pick<EnyoSubscriptionUnit, 'type' | 'referenceId'>): boolean;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InMemorySubscriptions = void 0;
4
+ const enyo_subscription_js_1 = require("../../types/enyo-subscription.cjs");
5
+ const subscription_plan_validators_js_1 = require("./subscription-plan-validators.cjs");
4
6
  /**
5
7
  * In-memory implementation of {@link EnergyAppSubscriptions} for tests and
6
8
  * local development.
@@ -8,7 +10,8 @@ exports.InMemorySubscriptions = void 0;
8
10
  * It holds the subscriptions in memory and lets a test play the part of enyo:
9
11
  * {@link upsertSubscription} simulates a purchase, renewal, cancellation,
10
12
  * failed payment or expiry and notifies the registered listeners exactly like
11
- * the host would.
13
+ * the host would. Units reported with {@link setUnits} are kept per plan and
14
+ * can be read back with {@link getUnits}.
12
15
  *
13
16
  * @example
14
17
  * ```typescript
@@ -20,6 +23,7 @@ exports.InMemorySubscriptions = void 0;
20
23
  * planKey: 'premium',
21
24
  * status: EnyoSubscriptionStatusEnum.Active,
22
25
  * paymentInterval: EnyoSubscriptionPaymentIntervalEnum.Yearly,
26
+ * pricingModel: EnyoSubscriptionPricingModelEnum.PerPackage,
23
27
  * price: {amountWithoutTax: 41.18, currency: EnyoCurrencyEnum.EUR},
24
28
  * startedAtIso: '2026-01-01T00:00:00Z',
25
29
  * currentPeriodStartIso: '2026-01-01T00:00:00Z',
@@ -29,25 +33,61 @@ exports.InMemorySubscriptions = void 0;
29
33
  * ```
30
34
  */
31
35
  class InMemorySubscriptions {
36
+ plans;
32
37
  subscriptions = new Map();
33
38
  listeners = new Map();
39
+ unitsByPlan = new Map();
34
40
  nextListenerId = 1;
35
41
  /**
36
42
  * @param initialSubscriptions - Subscriptions that exist before the app
37
43
  * starts. They do not trigger listeners.
44
+ * @param plans - Optional plans of the package. When given, {@link setUnits}
45
+ * rejects plan keys that are not declared as `per-unit`, like the host
46
+ * does. When omitted, every plan key is accepted.
38
47
  */
39
- constructor(initialSubscriptions = []) {
48
+ constructor(initialSubscriptions = [], plans) {
49
+ this.plans = plans;
40
50
  for (const subscription of initialSubscriptions) {
41
- this.subscriptions.set(subscription.subscriptionId, { ...subscription });
51
+ this.subscriptions.set(subscription.subscriptionId, structuredClone(subscription));
42
52
  }
43
53
  }
54
+ /**
55
+ * Stores the units reported for a `per-unit` plan, replacing earlier ones.
56
+ *
57
+ * @param planKey - The key of the plan.
58
+ * @param units - Every unit the app currently manages for the plan.
59
+ * @returns Promise that resolves once the units are stored
60
+ * @throws {Error} If the units are invalid, or `plans` were given and
61
+ * `planKey` is not a `per-unit` plan among them.
62
+ */
63
+ async setUnits(planKey, units) {
64
+ if (this.plans) {
65
+ const plan = this.plans.find(candidate => candidate.planKey === planKey);
66
+ if (plan?.pricingModel !== enyo_subscription_js_1.EnyoSubscriptionPricingModelEnum.PerUnit) {
67
+ throw new Error(`Plan "${planKey}" is not a per-unit plan of this package.`);
68
+ }
69
+ }
70
+ const { ok, errors } = (0, subscription_plan_validators_js_1.validateSubscriptionUnits)(units);
71
+ if (!ok)
72
+ throw new Error(`Invalid subscription units:\n- ${errors.join('\n- ')}`);
73
+ this.unitsByPlan.set(planKey, units.map(unit => structuredClone(unit)));
74
+ }
75
+ /**
76
+ * Returns copies of the units last reported for a plan.
77
+ *
78
+ * @param planKey - The key of the plan.
79
+ * @returns Promise that resolves to the units; an empty list when none were reported
80
+ */
81
+ async getUnits(planKey) {
82
+ return (this.unitsByPlan.get(planKey) ?? []).map(unit => structuredClone(unit));
83
+ }
44
84
  /**
45
85
  * Returns copies of all stored subscriptions.
46
86
  *
47
87
  * @returns Promise that resolves to the subscriptions in insertion order
48
88
  */
49
89
  async getSubscriptions() {
50
- return [...this.subscriptions.values()].map(subscription => ({ ...subscription }));
90
+ return [...this.subscriptions.values()].map(subscription => structuredClone(subscription));
51
91
  }
52
92
  /**
53
93
  * Registers a listener for new or changed subscriptions.
@@ -85,9 +125,9 @@ class InMemorySubscriptions {
85
125
  */
86
126
  async upsertSubscription(subscription) {
87
127
  const previousStatus = this.subscriptions.get(subscription.subscriptionId)?.status;
88
- this.subscriptions.set(subscription.subscriptionId, { ...subscription });
128
+ this.subscriptions.set(subscription.subscriptionId, structuredClone(subscription));
89
129
  for (const listener of [...this.listeners.values()]) {
90
- await listener({ subscription: { ...subscription }, previousStatus });
130
+ await listener({ subscription: structuredClone(subscription), previousStatus });
91
131
  }
92
132
  }
93
133
  }
@@ -1,5 +1,6 @@
1
+ import type { EnergyAppPackageSubscriptionPlan } from '../../energy-app-package-definition.cjs';
1
2
  import type { EnergyAppSubscriptions } from '../../packages/energy-app-subscriptions.cjs';
2
- import type { EnyoSubscription, EnyoSubscriptionChangeListener } from '../../types/enyo-subscription.cjs';
3
+ import { EnyoSubscription, EnyoSubscriptionChangeListener, EnyoSubscriptionUnit } from '../../types/enyo-subscription.cjs';
3
4
  /**
4
5
  * In-memory implementation of {@link EnergyAppSubscriptions} for tests and
5
6
  * local development.
@@ -7,7 +8,8 @@ import type { EnyoSubscription, EnyoSubscriptionChangeListener } from '../../typ
7
8
  * It holds the subscriptions in memory and lets a test play the part of enyo:
8
9
  * {@link upsertSubscription} simulates a purchase, renewal, cancellation,
9
10
  * failed payment or expiry and notifies the registered listeners exactly like
10
- * the host would.
11
+ * the host would. Units reported with {@link setUnits} are kept per plan and
12
+ * can be read back with {@link getUnits}.
11
13
  *
12
14
  * @example
13
15
  * ```typescript
@@ -19,6 +21,7 @@ import type { EnyoSubscription, EnyoSubscriptionChangeListener } from '../../typ
19
21
  * planKey: 'premium',
20
22
  * status: EnyoSubscriptionStatusEnum.Active,
21
23
  * paymentInterval: EnyoSubscriptionPaymentIntervalEnum.Yearly,
24
+ * pricingModel: EnyoSubscriptionPricingModelEnum.PerPackage,
22
25
  * price: {amountWithoutTax: 41.18, currency: EnyoCurrencyEnum.EUR},
23
26
  * startedAtIso: '2026-01-01T00:00:00Z',
24
27
  * currentPeriodStartIso: '2026-01-01T00:00:00Z',
@@ -28,14 +31,36 @@ import type { EnyoSubscription, EnyoSubscriptionChangeListener } from '../../typ
28
31
  * ```
29
32
  */
30
33
  export declare class InMemorySubscriptions implements EnergyAppSubscriptions {
34
+ private readonly plans?;
31
35
  private readonly subscriptions;
32
36
  private readonly listeners;
37
+ private readonly unitsByPlan;
33
38
  private nextListenerId;
34
39
  /**
35
40
  * @param initialSubscriptions - Subscriptions that exist before the app
36
41
  * starts. They do not trigger listeners.
42
+ * @param plans - Optional plans of the package. When given, {@link setUnits}
43
+ * rejects plan keys that are not declared as `per-unit`, like the host
44
+ * does. When omitted, every plan key is accepted.
37
45
  */
38
- constructor(initialSubscriptions?: EnyoSubscription[]);
46
+ constructor(initialSubscriptions?: EnyoSubscription[], plans?: EnergyAppPackageSubscriptionPlan[] | undefined);
47
+ /**
48
+ * Stores the units reported for a `per-unit` plan, replacing earlier ones.
49
+ *
50
+ * @param planKey - The key of the plan.
51
+ * @param units - Every unit the app currently manages for the plan.
52
+ * @returns Promise that resolves once the units are stored
53
+ * @throws {Error} If the units are invalid, or `plans` were given and
54
+ * `planKey` is not a `per-unit` plan among them.
55
+ */
56
+ setUnits(planKey: string, units: EnyoSubscriptionUnit[]): Promise<void>;
57
+ /**
58
+ * Returns copies of the units last reported for a plan.
59
+ *
60
+ * @param planKey - The key of the plan.
61
+ * @returns Promise that resolves to the units; an empty list when none were reported
62
+ */
63
+ getUnits(planKey: string): Promise<EnyoSubscriptionUnit[]>;
39
64
  /**
40
65
  * Returns copies of all stored subscriptions.
41
66
  *
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.PackageSubscriptionPlansValidationError = void 0;
16
16
  exports.validatePackageSubscriptionPlans = validatePackageSubscriptionPlans;
17
17
  exports.assertValidPackageSubscriptionPlans = assertValidPackageSubscriptionPlans;
18
+ exports.validateSubscriptionUnits = validateSubscriptionUnits;
18
19
  const energy_app_permission_type_js_1 = require("../../energy-app-permission.type.cjs");
19
20
  const enyo_currency_js_1 = require("../../types/enyo-currency.cjs");
20
21
  const enyo_subscription_js_1 = require("../../types/enyo-subscription.cjs");
@@ -159,6 +160,10 @@ function validatePackageSubscriptionPlans(definition) {
159
160
  if (!Object.values(enyo_subscription_js_1.EnyoSubscriptionPaymentIntervalEnum).includes(plan.paymentInterval)) {
160
161
  errors.push(`${at}: paymentInterval "${plan.paymentInterval}" is not supported.`);
161
162
  }
163
+ if (plan.pricingModel !== undefined
164
+ && !Object.values(enyo_subscription_js_1.EnyoSubscriptionPricingModelEnum).includes(plan.pricingModel)) {
165
+ errors.push(`${at}: pricingModel "${plan.pricingModel}" is not supported.`);
166
+ }
162
167
  validatePrices(plan, at, errors);
163
168
  if (plan.trialDays !== undefined) {
164
169
  if (plan.paymentInterval === enyo_subscription_js_1.EnyoSubscriptionPaymentIntervalEnum.OneTime) {
@@ -189,3 +194,48 @@ function assertValidPackageSubscriptionPlans(definition) {
189
194
  throw new PackageSubscriptionPlansValidationError(errors);
190
195
  return definition.subscriptionPlans;
191
196
  }
197
+ /**
198
+ * Validates the units an app reports for a `per-unit` plan with
199
+ * {@link EnergyAppSubscriptions.setUnits}: every unit has a known type and a
200
+ * non-empty reference, no unit is reported twice (same `type` and
201
+ * `referenceId`), and every custom unit has a non-empty name in German and
202
+ * English. The host applies the same rules and rejects invalid units.
203
+ *
204
+ * @param units - The units to report.
205
+ * @returns The {@link SubscriptionUnitsValidationResult}.
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const {ok, errors} = validateSubscriptionUnits(units);
210
+ * if (!ok) logger.warn(errors.join('\n'));
211
+ * ```
212
+ */
213
+ function validateSubscriptionUnits(units) {
214
+ const errors = [];
215
+ const seen = new Set();
216
+ for (const [index, unit] of units.entries()) {
217
+ const at = `units[${index}] (${unit.type || '?'} ${unit.referenceId || '?'})`;
218
+ if (!Object.values(enyo_subscription_js_1.EnyoSubscriptionUnitTypeEnum).includes(unit.type)) {
219
+ errors.push(`${at}: type "${unit.type}" is not supported.`);
220
+ }
221
+ if (!unit.referenceId?.trim()) {
222
+ errors.push(`${at}: referenceId is required.`);
223
+ }
224
+ else {
225
+ const key = `${unit.type}\u0000${unit.referenceId}`;
226
+ if (seen.has(key)) {
227
+ errors.push(`${at}: duplicate unit — type and referenceId must be unique.`);
228
+ }
229
+ seen.add(key);
230
+ }
231
+ if (unit.type === enyo_subscription_js_1.EnyoSubscriptionUnitTypeEnum.Custom) {
232
+ const names = unit.name ?? [];
233
+ for (const language of REQUIRED_LANGUAGES) {
234
+ if (!names.some(name => name.language === language && name.value?.trim())) {
235
+ errors.push(`${at}: a custom unit needs a name in language "${language}".`);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ return { ok: errors.length === 0, errors };
241
+ }
@@ -11,6 +11,7 @@
11
11
  * {@link assertValidPackageSubscriptionPlans} to throw on failure.
12
12
  */
13
13
  import type { EnergyAppPackageDefinition, EnergyAppPackageSubscriptionPlan } from '../../energy-app-package-definition.cjs';
14
+ import { EnyoSubscriptionUnit } from '../../types/enyo-subscription.cjs';
14
15
  /**
15
16
  * Thrown by {@link assertValidPackageSubscriptionPlans} when a package's
16
17
  * subscription plans fail validation. The message lists every blocking error
@@ -69,3 +70,27 @@ export declare function validatePackageSubscriptionPlans(definition: PackageSubs
69
70
  * @throws {PackageSubscriptionPlansValidationError} When validation produces any error.
70
71
  */
71
72
  export declare function assertValidPackageSubscriptionPlans(definition: PackageSubscriptionPlansSource): EnergyAppPackageSubscriptionPlan[] | undefined;
73
+ /** The outcome of validating the units reported for a `per-unit` plan. */
74
+ export interface SubscriptionUnitsValidationResult {
75
+ /** True when there are no `errors`. */
76
+ ok: boolean;
77
+ /** Problems that make the host reject the units. */
78
+ errors: string[];
79
+ }
80
+ /**
81
+ * Validates the units an app reports for a `per-unit` plan with
82
+ * {@link EnergyAppSubscriptions.setUnits}: every unit has a known type and a
83
+ * non-empty reference, no unit is reported twice (same `type` and
84
+ * `referenceId`), and every custom unit has a non-empty name in German and
85
+ * English. The host applies the same rules and rejects invalid units.
86
+ *
87
+ * @param units - The units to report.
88
+ * @returns The {@link SubscriptionUnitsValidationResult}.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const {ok, errors} = validateSubscriptionUnits(units);
93
+ * if (!ok) logger.warn(errors.join('\n'));
94
+ * ```
95
+ */
96
+ export declare function validateSubscriptionUnits(units: EnyoSubscriptionUnit[]): SubscriptionUnitsValidationResult;
@@ -1,4 +1,4 @@
1
- import { EnyoSubscription, EnyoSubscriptionChangeListener } from "../types/enyo-subscription.cjs";
1
+ import { EnyoSubscription, EnyoSubscriptionChangeListener, EnyoSubscriptionUnit } from "../types/enyo-subscription.cjs";
2
2
  /**
3
3
  * Interface for reading the subscriptions a customer bought for this energy
4
4
  * app on this device.
@@ -16,6 +16,12 @@ import { EnyoSubscription, EnyoSubscriptionChangeListener } from "../types/enyo-
16
16
  * or grace period ending, a cancelled subscription expiring — are reported even
17
17
  * without a connection.
18
18
  *
19
+ * A `per-unit` plan is charged once per unit — appliance, vehicle or custom
20
+ * unit — and the quantity comes from the app: it reports the units it
21
+ * currently manages with {@link setUnits}, and enyo offers and charges the
22
+ * plan for them. {@link EnyoSubscription.units} lists the units that are paid
23
+ * for; check a single unit with `isUnitActive()`.
24
+ *
19
25
  * When a plan stops being active, the app must fall back to a safe behaviour.
20
26
  * For example, release any control it holds over a heat pump, wallbox or
21
27
  * battery instead of leaving the appliance in the last commanded state.
@@ -48,11 +54,57 @@ export interface EnergyAppSubscriptions {
48
54
  * permission is not granted.
49
55
  */
50
56
  getSubscriptions(): Promise<EnyoSubscription[]>;
57
+ /**
58
+ * Reports the units the app currently manages for a `per-unit` plan,
59
+ * replacing the previously reported list. The number of units is the
60
+ * quantity enyo offers and charges the plan for.
61
+ *
62
+ * Call it on boot and whenever a unit is added or removed, with the full
63
+ * list each time. A unit is identified by its `type` plus `referenceId`,
64
+ * so keep the reference stable: a changed reference reads as a removed and
65
+ * a new, unpaid unit. How a changed quantity is charged — for example
66
+ * pro rata for the rest of the period — is decided by enyo.
67
+ *
68
+ * Reporting a unit does not pay for it. Once the customer has paid, the
69
+ * unit appears in {@link EnyoSubscription.units} and the change listeners
70
+ * are called.
71
+ *
72
+ * @param planKey - The key of a `per-unit` plan declared in the package definition.
73
+ * @param units - Every unit the app currently manages for the plan; an
74
+ * empty list when there is none.
75
+ * @returns Promise that resolves once the host has stored the units
76
+ * @throws {Error} If `planKey` is not a `per-unit` plan of this package, or
77
+ * a unit is invalid (empty reference, duplicate `type` + `referenceId`,
78
+ * custom unit without `de` and `en` name).
79
+ * @throws {EnergyAppPermissionNotGrantedError} If the `Subscriptions`
80
+ * permission is not granted.
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * const wallboxes = await energyApp.useAppliances().list();
85
+ * await subscriptions.setUnits('smart-charging', wallboxes.map(wallbox => ({
86
+ * type: EnyoSubscriptionUnitTypeEnum.Appliance,
87
+ * referenceId: wallbox.id
88
+ * })));
89
+ * ```
90
+ */
91
+ setUnits(planKey: string, units: EnyoSubscriptionUnit[]): Promise<void>;
92
+ /**
93
+ * Returns the units last reported with {@link setUnits} for a `per-unit`
94
+ * plan. These are the units the app manages, whether paid for or not.
95
+ *
96
+ * @param planKey - The key of a `per-unit` plan declared in the package definition.
97
+ * @returns Promise that resolves to the reported units; an empty list when
98
+ * none were reported yet
99
+ * @throws {EnergyAppPermissionNotGrantedError} If the `Subscriptions`
100
+ * permission is not granted.
101
+ */
102
+ getUnits(planKey: string): Promise<EnyoSubscriptionUnit[]>;
51
103
  /**
52
104
  * Registers a listener that is called whenever a subscription of this app
53
105
  * on this device is created or changes — a purchase, a renewal, a
54
- * cancellation, a failed payment, or the end of a trial, grace or paid
55
- * period.
106
+ * cancellation, a failed payment, units paid for or removed, or the end of
107
+ * a trial, grace or paid period.
56
108
  *
57
109
  * The listener is not called for the state that exists when it is
58
110
  * registered. Read that with {@link getSubscriptions} on boot.
@@ -1,4 +1,4 @@
1
- import { EnyoWebhookEvent } from "../types/enyo-webhook.cjs";
1
+ import { EnyoWebhookEvent, EnyoWebhookListenOptions } from "../types/enyo-webhook.cjs";
2
2
  /**
3
3
  * Interface for receiving webhooks from third-party services.
4
4
  *
@@ -54,11 +54,30 @@ export interface EnergyAppWebhook {
54
54
  * is at least once and the order of calls is not guaranteed; deduplicate
55
55
  * with {@link EnyoWebhookEvent.webhookRequestId}.
56
56
  *
57
+ * By default the listener receives calls from the public sources only.
58
+ * To also receive a hidden source, pass its access token in
59
+ * `options.secretKeys`. The tokens apply to this listener only; other
60
+ * listeners of the app do not receive the hidden source unless they pass
61
+ * the token themselves.
62
+ *
57
63
  * @typeParam T - Shape of the JSON body the app expects.
58
64
  * @param listener - Callback invoked for every forwarded webhook call. May be async.
65
+ * @param options - Optional settings, such as access tokens for hidden sources.
59
66
  * @returns The listener ID, to be passed to {@link removeListener}
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * const token = await energyApp.useSecretManager().getSecret<string>('vendor-webhook-token', encryptionKey);
71
+ * webhook.listenForWebhook<VendorEvent>(async (event) => {
72
+ * if (event.source === 'vendor-cloud') {
73
+ * await handleVendorEvent(event.body);
74
+ * }
75
+ * }, {
76
+ * secretKeys: [{source: 'vendor-cloud', token}]
77
+ * });
78
+ * ```
60
79
  */
61
- listenForWebhook<T = unknown>(listener: (event: EnyoWebhookEvent<T>) => void | Promise<void>): string;
80
+ listenForWebhook<T = unknown>(listener: (event: EnyoWebhookEvent<T>) => void | Promise<void>, options?: EnyoWebhookListenOptions): string;
62
81
  /**
63
82
  * Removes a listener previously registered with {@link listenForWebhook}.
64
83
  *