@enyo-energy/energy-app-sdk 1.36.0 → 1.38.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 (44) hide show
  1. package/README.md +143 -11
  2. package/dist/cjs/energy-app-package-definition.d.cts +26 -2
  3. package/dist/cjs/enyo-energy-app-sdk.cjs +7 -1
  4. package/dist/cjs/enyo-energy-app-sdk.d.cts +7 -1
  5. package/dist/cjs/implementations/subscriptions/define-subscription-plan.cjs +39 -1
  6. package/dist/cjs/implementations/subscriptions/define-subscription-plan.d.cts +32 -2
  7. package/dist/cjs/implementations/subscriptions/in-memory-subscriptions.cjs +112 -6
  8. package/dist/cjs/implementations/subscriptions/in-memory-subscriptions.d.cts +74 -3
  9. package/dist/cjs/implementations/subscriptions/subscription-plan-validators.cjs +127 -8
  10. package/dist/cjs/implementations/subscriptions/subscription-plan-validators.d.cts +74 -8
  11. package/dist/cjs/packages/energy-app-appliance.d.cts +4 -0
  12. package/dist/cjs/packages/energy-app-subscriptions.d.cts +80 -3
  13. package/dist/cjs/packages/energy-app-vehicle.d.cts +12 -4
  14. package/dist/cjs/packages/energy-app-webhook.d.cts +21 -2
  15. package/dist/cjs/types/enyo-appliance.d.cts +10 -0
  16. package/dist/cjs/types/enyo-subscription.cjs +53 -1
  17. package/dist/cjs/types/enyo-subscription.d.cts +190 -4
  18. package/dist/cjs/types/enyo-vehicle.cjs +27 -1
  19. package/dist/cjs/types/enyo-vehicle.d.cts +65 -1
  20. package/dist/cjs/types/enyo-webhook.d.cts +27 -0
  21. package/dist/cjs/version.cjs +1 -1
  22. package/dist/cjs/version.d.cts +1 -1
  23. package/dist/energy-app-package-definition.d.ts +26 -2
  24. package/dist/enyo-energy-app-sdk.d.ts +7 -1
  25. package/dist/enyo-energy-app-sdk.js +7 -1
  26. package/dist/implementations/subscriptions/define-subscription-plan.d.ts +32 -2
  27. package/dist/implementations/subscriptions/define-subscription-plan.js +37 -1
  28. package/dist/implementations/subscriptions/in-memory-subscriptions.d.ts +74 -3
  29. package/dist/implementations/subscriptions/in-memory-subscriptions.js +112 -6
  30. package/dist/implementations/subscriptions/subscription-plan-validators.d.ts +74 -8
  31. package/dist/implementations/subscriptions/subscription-plan-validators.js +125 -9
  32. package/dist/packages/energy-app-appliance.d.ts +4 -0
  33. package/dist/packages/energy-app-subscriptions.d.ts +80 -3
  34. package/dist/packages/energy-app-vehicle.d.ts +12 -4
  35. package/dist/packages/energy-app-webhook.d.ts +21 -2
  36. package/dist/types/enyo-appliance.d.ts +10 -0
  37. package/dist/types/enyo-subscription.d.ts +190 -4
  38. package/dist/types/enyo-subscription.js +52 -0
  39. package/dist/types/enyo-vehicle.d.ts +65 -1
  40. package/dist/types/enyo-vehicle.js +26 -0
  41. package/dist/types/enyo-webhook.d.ts +27 -0
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -244,7 +244,8 @@ energyApp.onShutdown(async () => {
244
244
  - `running`: App is functioning normally
245
245
  - `configuration-required`: App needs user configuration
246
246
  - `internet-connection-required`: App needs internet connectivity
247
- - `subscription-required`: App needs a subscription plan; the host shows the plans declared in the package definition
247
+ - `subscription-required`: App needs a subscription plan; the host shows the plans declared in the package definition.
248
+ Only for packages with `subscriptionMode: 'required'` — see [Subscription mode](#subscription-mode)
248
249
 
249
250
  ### Package Definition
250
251
 
@@ -886,8 +887,10 @@ vehicles and it does not go looking for them — the flow starts with the user:
886
887
  ```typescript
887
888
  const vehicles = energyApp.useVehicle();
888
889
 
889
- vehicles.onPairVehicle(async (vehicle, selection) => {
890
- const cars = await vendorApi.listVehicles();
890
+ vehicles.onPairVehicle(async (vehicle, selection, context) => {
891
+ // The brand the user selected in the enyo app, from the package's `compatibility`
892
+ // vendors — lets an aggregator package pick the right vendor API.
893
+ const cars = await vendorApi.listVehicles(context?.vendorName);
891
894
 
892
895
  // Several cars in the account and nothing to tell them apart —
893
896
  // hand the choice back to the user instead of guessing
@@ -929,6 +932,9 @@ vehicles.onPairVehicle(async (vehicle, selection) => {
929
932
 
930
933
  The handler is called a second time with `selection` after the user picks from
931
934
  `candidates`. A package serving single-car accounts can ignore the parameter.
935
+ `context.vendorName` is the vendor the user selected and is passed on both calls; it is
936
+ also kept on the link (`EnyoLinkedVehicle.vendorName`) so the package can read it back
937
+ after a restart.
932
938
 
933
939
  Once paired, the package has the `vehicleId` it was always missing and publishes
934
940
  readings the normal way — `VehicleSocUpdateV1`, keyed by that id. Nothing about
@@ -947,9 +953,11 @@ vehicles.onUnpairVehicle(async (link, reason) => {
947
953
  }
948
954
  });
949
955
 
950
- // The car vanished from the vendor account, or the subscription lapsed.
956
+ // The car vanished from the vendor account, or the vendor revoked access.
951
957
  // Does NOT call onUnpairVehicle — the package already knows.
952
- await vehicles.unpairVehicle(vehicleId);
958
+ // The reason decides how a billed car is billed — see "What a removal means for billing".
959
+ await vehicles.unpairVehicle(vehicleId, EnyoVehiclePackageUnpairReasonEnum.VehicleNotFound);
960
+ await vehicles.unpairVehicle(otherVehicleId, EnyoVehiclePackageUnpairReasonEnum.AccessRevoked);
953
961
 
954
962
  // Handlers only fire on change, so pick the links back up after a restart
955
963
  for (const link of await vehicles.listLinkedVehicles()) {
@@ -2276,6 +2284,17 @@ const listenerId = webhook.listenForWebhook<VendorEvent>(async (event) => {
2276
2284
  assertion, not validation — check the fields you rely on.
2277
2285
  - **At-least-once delivery.** A call may be forwarded more than once and order is not guaranteed —
2278
2286
  deduplicate on `webhookRequestId`.
2287
+ - **Hidden sources need a token.** Some webhook sources are only delivered to listeners that present their static
2288
+ access token. Pass it per listener; the listener then receives these sources in addition to the public ones:
2289
+
2290
+ ```typescript
2291
+ webhook.listenForWebhook<VendorEvent>(handleEvent, {
2292
+ secretKeys: [{ source: 'vendor-cloud', token: vendorWebhookToken }]
2293
+ });
2294
+ ```
2295
+
2296
+ The token only applies to that listener, and a source with a wrong token is not delivered. Keep the token in the
2297
+ secret manager, not in the source code.
2279
2298
 
2280
2299
  Requires the `Webhook` permission.
2281
2300
 
@@ -2308,8 +2327,11 @@ subscriptions.listenForSubscriptionChanges(async ({ subscription, previousStatus
2308
2327
  trial, grace or paid period) without a connection.
2309
2328
  - **Fall back safely.** When a plan stops being active, release any control over appliances instead of leaving
2310
2329
  them in the last commanded state.
2330
+ - **Per-unit plans:** report the units with `setUnits(planKey, units)` and check each one with
2331
+ `isUnitActive(subscriptions, planKey, unit)` — see [Per-unit plans](#per-unit-plans).
2311
2332
  - **Point the user to an offer** with a notification target `{ type: 'subscription-offer', planKey: 'premium' }`,
2312
- or set `EnergyAppStateEnum.SubscriptionRequired` when the whole app needs a plan.
2333
+ or set `EnergyAppStateEnum.SubscriptionRequired` when the whole app needs a plan (`subscriptionMode: 'required'`
2334
+ only).
2313
2335
 
2314
2336
  For tests, `InMemorySubscriptions` implements the same interface; `upsertSubscription()` plays the part of enyo
2315
2337
  and notifies the listeners.
@@ -2936,6 +2958,7 @@ import {
2936
2958
  defineSubscriptionPlan,
2937
2959
  EnergyAppPermissionTypeEnum,
2938
2960
  EnyoCurrencyEnum,
2961
+ EnyoSubscriptionModeEnum,
2939
2962
  EnyoSubscriptionPaymentIntervalEnum,
2940
2963
  validatePackageSubscriptionPlans
2941
2964
  } from '@enyo-energy/energy-app-sdk';
@@ -2943,6 +2966,7 @@ import {
2943
2966
  const packageDef = defineEnergyAppPackage({
2944
2967
  // ...
2945
2968
  permissions: [EnergyAppPermissionTypeEnum.Subscriptions /* , ... */],
2969
+ subscriptionMode: EnyoSubscriptionModeEnum.Required, // see "Subscription mode" below
2946
2970
  subscriptionPlans: [
2947
2971
  defineSubscriptionPlan({
2948
2972
  planKey: 'premium',
@@ -2972,7 +2996,8 @@ const packageDef = defineEnergyAppPackage({
2972
2996
  |---|---|
2973
2997
  | `planKey` | Kebab-case slug, unique in the package. Subscriptions refer to the plan by it — treat it as an API. |
2974
2998
  | `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. |
2999
+ | `pricingModel` | `per-package` (default): one price for the package on the device. `per-unit`: the price is charged per unit — see [Per-unit plans](#per-unit-plans). |
3000
+ | `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
3001
  | `trialDays` | Optional free trial in days. Not allowed for `one-time` plans. |
2977
3002
  | `storeEntry` | Title, short description and optional benefits; `de` and `en` are required. |
2978
3003
  | `internalComment` | Optional note, never shown to users. |
@@ -2980,6 +3005,113 @@ const packageDef = defineEnergyAppPackage({
2980
3005
  What a plan unlocks is decided by the app: check `isPlanActive(subscriptions, planKey)` at runtime (see
2981
3006
  [`useSubscriptions()`](#usesubscriptions-energyappsubscriptions)).
2982
3007
 
3008
+ ### Subscription mode
3009
+
3010
+ As soon as a package declares `subscriptionPlans`, it must also declare `subscriptionMode`: does the app work
3011
+ without a plan at all? enyo uses it to decide what to offer — a free base option next to the plans, a downgrade to
3012
+ that base in the subscription management, and the wording of the cancellation and uninstall texts.
3013
+
3014
+ | `subscriptionMode` | Meaning | App state without an active plan |
3015
+ |---|---|---|
3016
+ | `required` | The app sells its whole function; without a plan it does nothing useful. enyo offers no free option, and cancelling warns that the app stops working. | Reports `SubscriptionRequired`, and `Running` again once a plan is active. |
3017
+ | `optional` | The app has a free base; its plans add features. enyo offers the free base and lets the customer downgrade to it. | Keeps running. **Never** reports `SubscriptionRequired` — gate only the paid features with `isPlanActive()` / `isUnitActive()`. |
3018
+
3019
+ ```typescript
3020
+ defineEnergyAppPackage({
3021
+ // ...
3022
+ subscriptionMode: EnyoSubscriptionModeEnum.Optional,
3023
+ subscriptionPlans: [/* price optimisation on top of free basic control */]
3024
+ });
3025
+ ```
3026
+
3027
+ In tests, `InMemorySubscriptions` takes the mode as its third constructor argument; `assertEnergyAppState(state)`
3028
+ throws when an `optional` package reports `SubscriptionRequired`. The same check is available as
3029
+ `assertEnergyAppStateMatchesSubscriptionMode(mode, state)`.
3030
+
3031
+ ### Per-unit plans
3032
+
3033
+ For a `per-unit` plan the price is charged once per unit — 3 units on a yearly plan for 10 € cost 30 € per year
3034
+ (net). Each unit has a `type` and a `referenceId` it is paid under:
3035
+
3036
+ | `type` | `referenceId` | Name shown to the customer |
3037
+ |---|---|---|
3038
+ | `appliance` | the enyo appliance id (`EnyoAppliance.id`) | taken from the appliance |
3039
+ | `vehicle` | the enyo vehicle id (`EnyoVehicle.id` = `EnyoLinkedVehicle.vehicleId`), **never** the vendor's `externalId` | taken from the vehicle |
3040
+ | `custom` | an id chosen by the app | `name`, translated; `de` and `en` are required |
3041
+
3042
+ Where a unit comes from depends on its type:
3043
+
3044
+ | Unit | How the app declares it | Who keeps the list |
3045
+ |---|---|---|
3046
+ | Appliance | `billing: { billed: true, planKey }` on the appliance it saves | The host — added on save, removed when the appliance is removed or the mark is cleared |
3047
+ | Vehicle | `billing: { billed: true, planKey }` in the pair result | The host — added on pairing, removed on unpairing |
3048
+ | Custom | `setUnits(planKey, units)` — on boot and whenever one is added or removed | The app |
3049
+
3050
+ Marking appliances and vehicles where they are created keeps the billed units in step with what is actually
3051
+ connected, gives every unit the right enyo id, and lets the host bill a removal by its reason (see below). An app
3052
+ that also creates appliances it doesn't bill, such as a meter next to the heat pump, simply leaves `billing` off
3053
+ them. `onUnpairVehicle` and `listenForApplianceRemoved` then only need to stop serving the unit. `billing.planKey`
3054
+ must name a `per-unit` plan of the package; check it with `validateSubscriptionBilling(billing, plans)`.
3055
+
3056
+ ```typescript
3057
+ // A heat pump billed per unit; the meter the app also creates is not billed.
3058
+ await energyApp.useAppliances().save({
3059
+ ...heatpump,
3060
+ billing: { billed: true, planKey: 'heatpump-optimisation' }
3061
+ });
3062
+ await energyApp.useAppliances().save(meter);
3063
+
3064
+ // A car billed per unit, marked in the pair result.
3065
+ vehicles.onPairVehicle(async (vehicle, selection) => ({
3066
+ paired: true,
3067
+ externalId: car.id,
3068
+ billing: { billed: true, planKey: 'smartcar-vehicle' }
3069
+ }));
3070
+ ```
3071
+
3072
+ For anything else, report custom units yourself. `setUnits()` only takes custom units:
3073
+
3074
+ ```typescript
3075
+ import { EnyoSubscriptionUnitTypeEnum, isUnitActive } from '@enyo-energy/energy-app-sdk';
3076
+
3077
+ const subscriptions = energyApp.useSubscriptions();
3078
+
3079
+ await subscriptions.setUnits('room-climate', [{
3080
+ type: EnyoSubscriptionUnitTypeEnum.Custom,
3081
+ referenceId: 'living-room',
3082
+ name: [{ language: 'en', value: 'Living room' }, { language: 'de', value: 'Wohnzimmer' }]
3083
+ }]);
3084
+
3085
+ // Whatever the type: only the units the customer paid for are active.
3086
+ const all = await subscriptions.getSubscriptions();
3087
+ setClimateControl('living-room', isUnitActive(all, 'room-climate', { type: 'custom', referenceId: 'living-room' }));
3088
+ setOptimisation(heatpump, isUnitActive(all, 'heatpump-optimisation', { type: 'appliance', referenceId: heatpump.id }));
3089
+ ```
3090
+
3091
+ `getUnits(planKey)` returns all units of a plan — the billed appliances and vehicles plus the custom units.
3092
+
3093
+ - **Reporting a unit does not pay for it.** Paid units are listed in `subscription.units`; when the customer pays
3094
+ for more units, or a unit is removed, the change listeners are called.
3095
+ - **Keep `referenceId` stable.** `type` plus `referenceId` identify the unit; a new reference is a new, unpaid unit.
3096
+ - `validateSubscriptionUnits(units)` applies the host's rules up front: known type, non-empty reference, no unit
3097
+ twice, `de` and `en` names on custom units.
3098
+
3099
+ #### What a removal means for billing
3100
+
3101
+ The host decides, never the app's unit list:
3102
+
3103
+ | Unit leaves because… | Billing |
3104
+ |---|---|
3105
+ | The customer confirmed „Trennen" / „Entfernen" in the enyo app (vehicle unpaired by the user, appliance removed by the user) | The quantity drops at the next renewal; removing the last unit cancels the subscription at the end of the period. Nothing is refunded. |
3106
+ | The vehicle was deleted in the enyo app | Same as above. |
3107
+ | The app unlinked the car because it is gone from the vendor account — `unpairVehicle(id, EnyoVehiclePackageUnpairReasonEnum.VehicleNotFound)` | Same as above. |
3108
+ | Vendor sign-out | **Unchanged.** Usually followed by signing in again; a paid unit stays paid. |
3109
+ | The app unlinked the car because the vendor revoked access — `unpairVehicle(id, EnyoVehiclePackageUnpairReasonEnum.AccessRevoked)` | Unchanged — access is usually granted again. |
3110
+ | Unknown reason, `unpairVehicle()` without a reason, or the app's own report shrinks | Unchanged. |
3111
+
3112
+ A shorter unit list alone — an expired token, an app bug — never cancels anything a customer paid for. Every
3113
+ change takes effect at the next renewal or at the end of the paid period; there are no pro-rata refunds.
3114
+
2983
3115
  **A price change needs a new release** and only applies to new purchases — running subscriptions keep the price
2984
3116
  they were bought at.
2985
3117
 
@@ -2990,10 +3122,10 @@ const { ok, errors } = validatePackageSubscriptionPlans(packageDef);
2990
3122
  // or: assertValidPackageSubscriptionPlans(packageDef) — throws PackageSubscriptionPlansValidationError
2991
3123
  ```
2992
3124
 
2993
- It checks that plan keys are unique slugs, that the payment interval is known, that every plan has at least one
2994
- positive price per currency with at most two decimals, that `trialDays` is a positive whole number and not set on a
2995
- `one-time` plan, that `de` and
2996
- `en` store entries exist, and that the package requests the `Subscriptions` permission.
3125
+ It checks that `subscriptionMode` is declared, that plan keys are unique slugs, that the payment interval and
3126
+ pricing model are known, that every plan has at least one positive price per currency with at most two decimals,
3127
+ that `trialDays` is a positive whole number and not set on a `one-time` plan, that `de` and `en` store entries
3128
+ exist, and that the package requests the `Subscriptions` permission. A `subscriptionMode` without plans is a warning.
2997
3129
 
2998
3130
  ## Printable Manual Setup Guide
2999
3131
 
@@ -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 { EnyoSubscriptionMode, 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,20 @@ 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 units are the appliances and vehicles the app marks as
340
+ * billed under this plan ({@link EnyoAppliance.billing},
341
+ * {@link EnyoVehiclePairResult.billing}), plus the custom units it reports
342
+ * with {@link EnergyAppSubscriptions.setUnits}.
343
+ */
344
+ pricingModel?: EnyoSubscriptionPricingModel;
336
345
  /**
337
346
  * Net price, one entry per currency. At least one entry is required. The
338
347
  * amount is always the total for one interval: per month for `monthly`,
339
- * per year for `yearly`, and the single amount for `one-time`.
348
+ * per year for `yearly`, and the single amount for `one-time`. For a
349
+ * `per-unit` plan it is the amount per unit.
340
350
  */
341
351
  prices: EnyoSubscriptionPrice[];
342
352
  /**
@@ -882,6 +892,20 @@ export interface EnergyAppPackageDefinition {
882
892
  * Omit for packages that are free.
883
893
  */
884
894
  subscriptionPlans?: EnergyAppPackageSubscriptionPlan[];
895
+ /**
896
+ * Whether the app needs one of its plans to work at all.
897
+ *
898
+ * - `'required'`: without an active plan the app does nothing useful and
899
+ * reports {@link EnergyAppStateEnum.SubscriptionRequired}. enyo offers no
900
+ * free option, and cancelling warns that the app stops working.
901
+ * - `'optional'`: the app has a free base; its plans add features. The app
902
+ * never reports `SubscriptionRequired`. enyo offers a free base option
903
+ * and lets the customer downgrade to it.
904
+ *
905
+ * Required as soon as {@link subscriptionPlans} is set; omit it for free
906
+ * packages.
907
+ */
908
+ subscriptionMode?: EnyoSubscriptionMode;
885
909
  }
886
910
  /**
887
911
  * Defines an Energy App package with automatic SDK version injection.
@@ -9,6 +9,12 @@ var EnergyAppStateEnum;
9
9
  EnergyAppStateEnum["ConfigurationRequired"] = "configuration-required";
10
10
  /** This state tells the user that an internet connection of the system is required which blocks the energy app from running*/
11
11
  EnergyAppStateEnum["InternetConnectionRequired"] = "internet-connection-required";
12
- /** This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition. */
12
+ /**
13
+ * This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition.
14
+ *
15
+ * Only for packages with `subscriptionMode: 'required'`: such an app reports this state while it has no active
16
+ * plan, and returns to `Running` once one is active. An app with `subscriptionMode: 'optional'` must never report
17
+ * it — it keeps its free base running and gates only the paid features with `isPlanActive()` / `isUnitActive()`.
18
+ */
13
19
  EnergyAppStateEnum["SubscriptionRequired"] = "subscription-required";
14
20
  })(EnergyAppStateEnum || (exports.EnergyAppStateEnum = EnergyAppStateEnum = {}));
@@ -60,7 +60,13 @@ export declare enum EnergyAppStateEnum {
60
60
  ConfigurationRequired = "configuration-required",
61
61
  /** This state tells the user that an internet connection of the system is required which blocks the energy app from running*/
62
62
  InternetConnectionRequired = "internet-connection-required",
63
- /** This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition. */
63
+ /**
64
+ * This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition.
65
+ *
66
+ * Only for packages with `subscriptionMode: 'required'`: such an app reports this state while it has no active
67
+ * plan, and returns to `Running` once one is active. An app with `subscriptionMode: 'optional'` must never report
68
+ * it — it keeps its free base running and gates only the paid features with `isPlanActive()` / `isUnitActive()`.
69
+ */
64
70
  SubscriptionRequired = "subscription-required"
65
71
  }
66
72
  /**
@@ -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,10 @@ 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}, together with the billed appliance
15
+ * and vehicle units a test sets with {@link setBilledUnits}. {@link assertEnergyAppState}
16
+ * checks a reported app state against the package's subscription mode.
12
17
  *
13
18
  * @example
14
19
  * ```typescript
@@ -20,6 +25,7 @@ exports.InMemorySubscriptions = void 0;
20
25
  * planKey: 'premium',
21
26
  * status: EnyoSubscriptionStatusEnum.Active,
22
27
  * paymentInterval: EnyoSubscriptionPaymentIntervalEnum.Yearly,
28
+ * pricingModel: EnyoSubscriptionPricingModelEnum.PerPackage,
23
29
  * price: {amountWithoutTax: 41.18, currency: EnyoCurrencyEnum.EUR},
24
30
  * startedAtIso: '2026-01-01T00:00:00Z',
25
31
  * currentPeriodStartIso: '2026-01-01T00:00:00Z',
@@ -29,25 +35,101 @@ exports.InMemorySubscriptions = void 0;
29
35
  * ```
30
36
  */
31
37
  class InMemorySubscriptions {
38
+ plans;
39
+ subscriptionMode;
32
40
  subscriptions = new Map();
33
41
  listeners = new Map();
42
+ customUnitsByPlan = new Map();
43
+ billedUnitsByPlan = new Map();
34
44
  nextListenerId = 1;
35
45
  /**
36
46
  * @param initialSubscriptions - Subscriptions that exist before the app
37
47
  * starts. They do not trigger listeners.
48
+ * @param plans - Optional plans of the package. When given,
49
+ * {@link setUnits} and {@link setBilledUnits} reject plan keys that are
50
+ * not declared as `per-unit`, like the host does. When omitted, every
51
+ * plan key is accepted.
52
+ * @param subscriptionMode - Optional subscription mode of the package,
53
+ * checked by {@link assertEnergyAppState}.
38
54
  */
39
- constructor(initialSubscriptions = []) {
55
+ constructor(initialSubscriptions = [], plans, subscriptionMode) {
56
+ this.plans = plans;
57
+ this.subscriptionMode = subscriptionMode;
40
58
  for (const subscription of initialSubscriptions) {
41
- this.subscriptions.set(subscription.subscriptionId, { ...subscription });
59
+ this.subscriptions.set(subscription.subscriptionId, structuredClone(subscription));
42
60
  }
43
61
  }
62
+ /**
63
+ * Stores the custom units reported for a `per-unit` plan, replacing
64
+ * earlier ones.
65
+ *
66
+ * @param planKey - The key of the plan.
67
+ * @param units - Every custom unit the app currently manages for the plan.
68
+ * @returns Promise that resolves once the units are stored
69
+ * @throws {Error} If a unit is not a custom unit or invalid, or `plans`
70
+ * were given and `planKey` is not a `per-unit` plan among them.
71
+ */
72
+ async setUnits(planKey, units) {
73
+ this.assertPerUnitPlan(planKey);
74
+ const notCustom = units.filter(unit => unit.type !== enyo_subscription_js_1.EnyoSubscriptionUnitTypeEnum.Custom);
75
+ if (notCustom.length > 0) {
76
+ throw new Error(`setUnits() only takes custom units — mark appliances and vehicles with \`billing\` instead (got ${notCustom.map(unit => unit.type).join(', ')}).`);
77
+ }
78
+ this.assertValidUnits(units);
79
+ this.customUnitsByPlan.set(planKey, units.map(unit => structuredClone(unit)));
80
+ }
81
+ /**
82
+ * Test helper: sets the appliance and vehicle units the host keeps for a
83
+ * plan from their billing marks, replacing earlier ones — as if the app
84
+ * had saved those appliances or paired those vehicles with
85
+ * `billing: {billed: true, planKey}`.
86
+ *
87
+ * @param planKey - The key of the plan.
88
+ * @param units - The billed appliance and vehicle units.
89
+ * @returns Promise that resolves once the units are stored
90
+ * @throws {Error} If a unit is a custom unit or invalid, or `plans` were
91
+ * given and `planKey` is not a `per-unit` plan among them.
92
+ */
93
+ async setBilledUnits(planKey, units) {
94
+ this.assertPerUnitPlan(planKey);
95
+ if (units.some(unit => unit.type === enyo_subscription_js_1.EnyoSubscriptionUnitTypeEnum.Custom)) {
96
+ throw new Error('setBilledUnits() only takes appliance and vehicle units — report custom units with setUnits().');
97
+ }
98
+ this.assertValidUnits(units);
99
+ this.billedUnitsByPlan.set(planKey, units.map(unit => structuredClone(unit)));
100
+ }
101
+ /**
102
+ * Test helper: checks an app state against the subscription mode given to
103
+ * the constructor. Call it wherever the app under test reports its state.
104
+ *
105
+ * @param state - The state the app reports.
106
+ * @throws {Error} When the package's subscription mode is `optional` and
107
+ * the state is `subscription-required`.
108
+ */
109
+ assertEnergyAppState(state) {
110
+ (0, subscription_plan_validators_js_1.assertEnergyAppStateMatchesSubscriptionMode)(this.subscriptionMode, state);
111
+ }
112
+ /**
113
+ * Returns copies of the current units of a plan: the billed appliances and
114
+ * vehicles set with {@link setBilledUnits}, followed by the custom units
115
+ * reported with {@link setUnits}.
116
+ *
117
+ * @param planKey - The key of the plan.
118
+ * @returns Promise that resolves to the units; an empty list when none were reported
119
+ */
120
+ async getUnits(planKey) {
121
+ return [
122
+ ...(this.billedUnitsByPlan.get(planKey) ?? []),
123
+ ...(this.customUnitsByPlan.get(planKey) ?? []),
124
+ ].map(unit => structuredClone(unit));
125
+ }
44
126
  /**
45
127
  * Returns copies of all stored subscriptions.
46
128
  *
47
129
  * @returns Promise that resolves to the subscriptions in insertion order
48
130
  */
49
131
  async getSubscriptions() {
50
- return [...this.subscriptions.values()].map(subscription => ({ ...subscription }));
132
+ return [...this.subscriptions.values()].map(subscription => structuredClone(subscription));
51
133
  }
52
134
  /**
53
135
  * Registers a listener for new or changed subscriptions.
@@ -85,10 +167,34 @@ class InMemorySubscriptions {
85
167
  */
86
168
  async upsertSubscription(subscription) {
87
169
  const previousStatus = this.subscriptions.get(subscription.subscriptionId)?.status;
88
- this.subscriptions.set(subscription.subscriptionId, { ...subscription });
170
+ this.subscriptions.set(subscription.subscriptionId, structuredClone(subscription));
89
171
  for (const listener of [...this.listeners.values()]) {
90
- await listener({ subscription: { ...subscription }, previousStatus });
172
+ await listener({ subscription: structuredClone(subscription), previousStatus });
91
173
  }
92
174
  }
175
+ /**
176
+ * Throws when `plans` were given and `planKey` is not a `per-unit` plan
177
+ * among them.
178
+ *
179
+ * @param planKey - The key of the plan.
180
+ */
181
+ assertPerUnitPlan(planKey) {
182
+ if (!this.plans)
183
+ return;
184
+ const plan = this.plans.find(candidate => candidate.planKey === planKey);
185
+ if (plan?.pricingModel !== enyo_subscription_js_1.EnyoSubscriptionPricingModelEnum.PerUnit) {
186
+ throw new Error(`Plan "${planKey}" is not a per-unit plan of this package.`);
187
+ }
188
+ }
189
+ /**
190
+ * Throws when the units break the host's rules.
191
+ *
192
+ * @param units - The units to check.
193
+ */
194
+ assertValidUnits(units) {
195
+ const { ok, errors } = (0, subscription_plan_validators_js_1.validateSubscriptionUnits)(units);
196
+ if (!ok)
197
+ throw new Error(`Invalid subscription units:\n- ${errors.join('\n- ')}`);
198
+ }
93
199
  }
94
200
  exports.InMemorySubscriptions = InMemorySubscriptions;