]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp2): audit TransactionEvent — state ownership, deauthorization, meter values
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 19 Mar 2026 11:17:44 +0000 (12:17 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Thu, 19 Mar 2026 11:17:44 +0000 (12:17 +0100)
- Fix periodic TransactionEvent(Updated) to include meter values via buildMeterValue
- Replace non-UUID temp transactionId with generateUUID in OCPP20AuthAdapter
- Refactor state ownership: response handler is sole authority for transactionStarted,
  StatusNotification(Occupied), and TxUpdatedInterval start
- Add transactionPending flag to prevent duplicate RequestStartTransaction race conditions
- Add requestDeauthorizeTransaction per E05.FR.09/FR.10/E06.FR.04: sends
  Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)
- Fix rejection check to cover all non-Accepted idTokenInfo statuses
- Extract buildFinalMeterValues helper to eliminate DRY violation
- Skip Occupied/TxUpdatedInterval setup when idToken is rejected in same response
- Remove cleanup from Ended response handler (owned by caller)
- Add tests for getTxUpdatedInterval, requestDeauthorizeTransaction, Updated-failure path

src/charging-station/ChargingStation.ts
src/charging-station/Helpers.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts
src/types/ConnectorStatus.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts

index 05040822a4794c81468a034bc1598c4b48e9c502..7fd59b9aff8e472e4189f8477d9500e3934c15c7 100644 (file)
@@ -43,6 +43,7 @@ import {
   MeterValueMeasurand,
   type MeterValuesRequest,
   type MeterValuesResponse,
+  type OCPP20MeterValue,
   OCPPVersion,
   type OutgoingRequest,
   PowerUnits,
@@ -1119,12 +1120,14 @@ export class ChargingStation extends EventEmitter {
     connector.transactionTxUpdatedSetInterval = setInterval(() => {
       const connectorStatus = this.getConnectorStatus(connectorId)
       if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) {
+        const meterValue = buildMeterValue(this, connectorId, 0, interval) as OCPP20MeterValue
         OCPP20ServiceUtils.sendTransactionEvent(
           this,
           OCPP20TransactionEventEnumType.Updated,
           OCPP20TriggerReasonEnumType.MeterValuePeriodic,
           connectorId,
-          connectorStatus.transactionId as string
+          connectorStatus.transactionId as string,
+          { meterValue: [meterValue] }
         ).catch((error: unknown) => {
           logger.error(
             `${this.logPrefix()} Error sending periodic TransactionEvent at TxUpdatedInterval:`,
index dc5ec1da898daa1e6f519216e9cc4cd7a9db4c7a..85bf932223f678b848ad097ee1c9ff34248c08fd 100644 (file)
@@ -556,6 +556,7 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
     )
   }
   resetAuthorizeConnectorStatus(connectorStatus)
+  connectorStatus.transactionPending = false
   connectorStatus.transactionRemoteStarted = false
   connectorStatus.transactionStarted = false
   delete connectorStatus.transactionStart
index 2604396bfc0fefdb64f16790aff41d73974856be..99d3c41a1a3463a7c20ea85fcb2fdb2fe3defd30 100644 (file)
@@ -411,8 +411,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
                 error
               )
             })
-            const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
-            chargingStation.startTxUpdatedInterval(connectorId, txUpdatedInterval)
           }
         }
       }
@@ -2276,14 +2274,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
     }
 
-    if (connectorStatus.transactionStarted === true) {
+    if (
+      connectorStatus.transactionStarted === true ||
+      connectorStatus.transactionPending === true
+    ) {
       logger.warn(
-        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active transaction`
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active or pending transaction`
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
         statusInfo: {
-          additionalInfo: `Connector ${connectorId.toString()} already has an active transaction`,
+          additionalInfo: `Connector ${connectorId.toString()} already has an active or pending transaction`,
           reasonCode: ReasonCodeEnumType.TxInProgress,
         },
         transactionId: generateUUID(),
@@ -2474,7 +2475,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       logger.debug(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Setting transaction state for connector ${connectorId.toString()}, transaction ID: ${transactionId}`
       )
-      connectorStatus.transactionStarted = true
+      connectorStatus.transactionPending = true
       connectorStatus.transactionId = transactionId
       connectorStatus.transactionIdTag = idToken.idToken
       connectorStatus.transactionGroupIdToken = groupIdToken?.idToken
@@ -2485,16 +2486,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Transaction state set successfully for connector ${connectorId.toString()}`
       )
 
-      logger.debug(
-        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Updating connector ${connectorId.toString()} status to Occupied`
-      )
-      await sendAndSetConnectorStatus(
-        chargingStation,
-        connectorId,
-        ConnectorStatusEnum.Occupied,
-        evseId
-      )
-
       if (chargingProfile != null) {
         connectorStatus.chargingProfiles ??= []
         connectorStatus.chargingProfiles.push(chargingProfile)
@@ -2866,7 +2857,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     presentedGroupIdToken?: OCPP20IdTokenType
   ): boolean {
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
-    if (connectorStatus?.transactionStarted !== true) {
+    if (
+      connectorStatus?.transactionStarted !== true &&
+      connectorStatus?.transactionPending !== true
+    ) {
       logger.debug(
         `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: No active transaction on connector ${connectorId.toString()}`
       )
index 8a49ec10f15cdfa8bdaebe7d956baf07a603b042..6b6bcf2f51fefe35fdf5654d89ea3467564f5cc6 100644 (file)
@@ -2,11 +2,7 @@ import type { ValidateFunction } from 'ajv'
 
 import type { OCPP20IncomingRequestCommand } from '../../../types/index.js'
 
-import {
-  addConfigurationKey,
-  type ChargingStation,
-  resetConnectorStatus,
-} from '../../../charging-station/index.js'
+import { addConfigurationKey, type ChargingStation } from '../../../charging-station/index.js'
 import {
   ChargingStationEvents,
   ConnectorStatusEnum,
@@ -310,49 +306,26 @@ export class OCPP20ResponseService extends OCPPResponseService {
 
     switch (requestPayload.eventType) {
       case OCPP20TransactionEventEnumType.Ended:
+        // Cleanup (stopTxUpdatedInterval, resetConnectorStatus, StatusNotification) is owned by
+        // the caller that sends TransactionEvent(Ended) — see requestStopTransaction in OCPP20ServiceUtils.
         if (connectorId != null) {
-          if (
-            !chargingStation.isChargingStationAvailable() ||
-            !chargingStation.isConnectorAvailable(connectorId)
-          ) {
-            sendAndSetConnectorStatus(
-              chargingStation,
-              connectorId,
-              ConnectorStatusEnum.Unavailable
-            ).catch((error: unknown) => {
-              logger.error(
-                `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Unavailable):`,
-                error
-              )
-            })
-          } else {
-            sendAndSetConnectorStatus(
-              chargingStation,
-              connectorId,
-              ConnectorStatusEnum.Available
-            ).catch((error: unknown) => {
-              logger.error(
-                `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Available):`,
-                error
-              )
-            })
-          }
-          chargingStation.stopTxUpdatedInterval(connectorId)
-          chargingStation.stopMeterValues(connectorId)
           logger.info(
             `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}`
           )
         }
-        resetConnectorStatus(connectorStatus)
         break
       case OCPP20TransactionEventEnumType.Started:
-        if (connectorStatus != null && connectorStatus.transactionStarted !== true) {
+        if (connectorStatus != null) {
           connectorStatus.transactionStarted = true
-          connectorStatus.transactionId = requestPayload.transactionInfo.transactionId
-          connectorStatus.transactionIdTag = requestPayload.idToken?.idToken
-          connectorStatus.transactionStart = new Date()
-          connectorStatus.transactionEnergyActiveImportRegisterValue = 0
-          if (connectorId != null) {
+          connectorStatus.transactionPending = false
+          connectorStatus.transactionId ??= requestPayload.transactionInfo.transactionId
+          connectorStatus.transactionIdTag ??= requestPayload.idToken?.idToken
+          connectorStatus.transactionStart ??= new Date()
+          connectorStatus.transactionEnergyActiveImportRegisterValue ??= 0
+          const isIdTokenAccepted =
+            payload.idTokenInfo == null ||
+            payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted
+          if (connectorId != null && isIdTokenAccepted) {
             sendAndSetConnectorStatus(
               chargingStation,
               connectorId,
@@ -386,39 +359,31 @@ export class OCPP20ResponseService extends OCPPResponseService {
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}`
       )
-      // D01/D05: Stop transaction when idToken authorization is rejected by CSMS
-      const rejectedStatuses = new Set<OCPP20AuthorizationStatusEnumType>([
-        OCPP20AuthorizationStatusEnumType.Blocked,
-        OCPP20AuthorizationStatusEnumType.Expired,
-        OCPP20AuthorizationStatusEnumType.Invalid,
-        OCPP20AuthorizationStatusEnumType.NoCredit,
-      ])
-      if (rejectedStatuses.has(payload.idTokenInfo.status)) {
+      // E05.FR.09/FR.10 + E06.FR.04: Deauthorize transaction when idToken is not accepted by CSMS
+      if (payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted) {
         logger.warn(
-          `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', stopping active transaction per OCPP 2.0.1 spec (D01/D05)`
+          `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', de-authorizing transaction per E05.FR.09/E05.FR.10/E06.FR.04`
         )
-        // Find the specific connector for this transaction
-        const connectorId = chargingStation.getConnectorIdByTransactionId(
+        const txConnectorId = chargingStation.getConnectorIdByTransactionId(
           requestPayload.transactionInfo.transactionId
         )
-        const evseId = chargingStation.getEvseIdByTransactionId(
+        const txEvseId = chargingStation.getEvseIdByTransactionId(
           requestPayload.transactionInfo.transactionId
         )
-        if (connectorId != null && evseId != null) {
-          logger.info(
-            `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Stopping transaction ${requestPayload.transactionInfo.transactionId} on EVSE ${evseId.toString()}, connector ${connectorId.toString()} due to rejected idToken`
-          )
-          OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
-            (error: unknown) => {
-              logger.error(
-                `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error stopping transaction ${requestPayload.transactionInfo.transactionId} on connector ${connectorId.toString()}:`,
-                error
-              )
-            }
-          )
+        if (txConnectorId != null && txEvseId != null) {
+          OCPP20ServiceUtils.requestDeauthorizeTransaction(
+            chargingStation,
+            txConnectorId,
+            txEvseId
+          ).catch((error: unknown) => {
+            logger.error(
+              `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error de-authorizing transaction ${requestPayload.transactionInfo.transactionId} on connector ${txConnectorId.toString()}:`,
+              error
+            )
+          })
         } else {
           logger.warn(
-            `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot stop transaction`
+            `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot de-authorize`
           )
         }
       }
index dd299c2ff3b5d727199baf3e0e0c26b7cac70287..c22147d076e0a071587e65bb9ff152490dd086d4 100644 (file)
@@ -3,6 +3,7 @@ import { secondsToMilliseconds } from 'date-fns'
 import { type ChargingStation, resetConnectorStatus } from '../../../charging-station/index.js'
 import { OCPPError } from '../../../exception/index.js'
 import {
+  type ConnectorStatus,
   ConnectorStatusEnum,
   ErrorType,
   type JsonObject,
@@ -23,6 +24,7 @@ import {
   OCPP20ReadingContextEnumType,
 } from '../../../types/ocpp/2.0/MeterValues.js'
 import {
+  OCPP20ChargingStateEnumType,
   type OCPP20EVSEType,
   OCPP20ReasonEnumType,
   type OCPP20TransactionEventOptions,
@@ -290,13 +292,75 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     return { bytesLimit, itemsLimit }
   }
 
+  // E05.FR.09/FR.10 + E06.FR.04: Deauthorization flow when CSMS rejects idToken.
+  // Assumes StopTxOnInvalidId=true (simulator default). Sends Updated(Deauthorized, SuspendedEVSE)
+  // then Ended(Deauthorized, DeAuthorized) then cleanup.
+  public static async requestDeauthorizeTransaction (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    evseId?: number
+  ): Promise<OCPP20TransactionEventResponse> {
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+    if (
+      (connectorStatus?.transactionStarted === true ||
+        connectorStatus?.transactionPending === true) &&
+      connectorStatus.transactionId != null
+    ) {
+      const transactionId =
+        typeof connectorStatus.transactionId === 'string'
+          ? connectorStatus.transactionId
+          : connectorStatus.transactionId.toString()
+
+      await this.sendTransactionEvent(
+        chargingStation,
+        OCPP20TransactionEventEnumType.Updated,
+        OCPP20TriggerReasonEnumType.Deauthorized,
+        connectorId,
+        transactionId,
+        {
+          chargingState: OCPP20ChargingStateEnumType.SuspendedEVSE,
+          evseId,
+        }
+      )
+
+      const finalMeterValues = this.buildFinalMeterValues(connectorStatus)
+
+      const response = await this.sendTransactionEvent(
+        chargingStation,
+        OCPP20TransactionEventEnumType.Ended,
+        OCPP20TriggerReasonEnumType.Deauthorized,
+        connectorId,
+        transactionId,
+        {
+          evseId,
+          meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined,
+          stoppedReason: OCPP20ReasonEnumType.DeAuthorized,
+        }
+      )
+
+      chargingStation.stopTxUpdatedInterval(connectorId)
+      resetConnectorStatus(connectorStatus)
+      await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
+
+      return response
+    }
+    throw new OCPPError(
+      ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+      `No active transaction on connector ${connectorId.toString()}`
+    )
+  }
+
   public static async requestStopTransaction (
     chargingStation: ChargingStation,
     connectorId: number,
     evseId?: number
   ): Promise<OCPP20TransactionEventResponse> {
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
-    if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) {
+    if (
+      (connectorStatus?.transactionStarted === true ||
+        connectorStatus?.transactionPending === true) &&
+      connectorStatus.transactionId != null
+    ) {
       let transactionId: string
       if (typeof connectorStatus.transactionId === 'string') {
         transactionId = connectorStatus.transactionId
@@ -308,20 +372,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       }
 
       // F03.FR.04: Build final meter values for TransactionEvent(Ended)
-      const finalMeterValues: OCPP20MeterValue[] = []
-      const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
-      if (energyValue >= 0) {
-        finalMeterValues.push({
-          sampledValue: [
-            {
-              context: OCPP20ReadingContextEnumType.TRANSACTION_END,
-              measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
-              value: energyValue,
-            },
-          ],
-          timestamp: new Date(),
-        })
-      }
+      const finalMeterValues = this.buildFinalMeterValues(connectorStatus)
 
       const response = await this.sendTransactionEvent(
         chargingStation,
@@ -475,6 +526,24 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       throw error
     }
   }
+
+  private static buildFinalMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] {
+    const finalMeterValues: OCPP20MeterValue[] = []
+    const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+    if (energyValue >= 0) {
+      finalMeterValues.push({
+        sampledValue: [
+          {
+            context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+            measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+            value: energyValue,
+          },
+        ],
+        timestamp: new Date(),
+      })
+    }
+    return finalMeterValues
+  }
 }
 export function buildTransactionEvent (
   chargingStation: ChargingStation,
index ec5dbd58999e29b2c886b07ff170bf58749d3442..b805a23ff4db6d40af3ab306d715e6b9afd58f72 100644 (file)
@@ -20,7 +20,7 @@ import {
   OCPP20TriggerReasonEnumType,
 } from '../../../../types/ocpp/2.0/Transaction.js'
 import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
-import { logger, truncateId } from '../../../../utils/index.js'
+import { generateUUID, logger, truncateId } from '../../../../utils/index.js'
 import {
   AuthContext,
   AuthenticationMethod,
@@ -120,8 +120,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
 
         // OCPP 2.0: Authorization through TransactionEvent
         // According to OCPP 2.0.1 spec section G03 - Authorization
-        const tempTransactionId =
-          transactionId != null ? transactionId.toString() : `auth-${Date.now().toString()}`
+        const tempTransactionId = transactionId != null ? transactionId.toString() : generateUUID()
 
         // Get EVSE ID from connector
         const evseId = connectorId // In OCPP 2.0, connector maps to EVSE
index 7b272de22f2f31fd58c61771d5364bc086c224a6..c6b407c10d98a24beaf363ed84b59cf950653b0e 100644 (file)
@@ -43,6 +43,12 @@ export interface ConnectorStatus {
    * that occurs after the transaction has been authorized.
    */
   transactionIdTokenSent?: boolean
+  /**
+   * OCPP 2.0.1: Transaction is pending CSMS acknowledgment via TransactionEvent response.
+   * Set by RequestStartTransaction handler to block duplicate starts before the response
+   * handler finalizes the transaction state with transactionStarted = true.
+   */
+  transactionPending?: boolean
   transactionRemoteStarted?: boolean
   transactionSeqNo?: number
   transactionSetInterval?: NodeJS.Timeout
index 7631280bd17a8417b0513d1fd6cb1e41e819f9dd..0447013dc0af193b9d9fa1f74cf5aa8c22d112db 100644 (file)
@@ -97,7 +97,7 @@ await describe('C09 - GroupId-based Stop Transaction Authorization', async () =>
       assert.fail('Expected connectorStatus to be defined')
     }
     assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN)
-    assert.strictEqual(connectorStatus.transactionStarted, true)
+    assert.strictEqual(connectorStatus.transactionPending, true)
 
     const isAuthorized = testableService.isAuthorizedToStopTransaction(
       mockStation,
index 30cbd128fa7b2438f1e502bcfefcde4fb9326515..82e065f7745578208a270e875018b239fe4d46be 100644 (file)
@@ -144,7 +144,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
     }
     assert.strictEqual(connectorStatus.remoteStartId, 42)
     assert.strictEqual(connectorStatus.transactionIdTag, 'REMOTE_TOKEN_456')
-    assert.strictEqual(connectorStatus.transactionStarted, true)
+    assert.strictEqual(connectorStatus.transactionPending, true)
     assert.strictEqual(connectorStatus.transactionId, response.transactionId)
 
     OCPPAuthServiceFactory.clearAllInstances()
index 223315e73a56ed5bd3281286caa76b701295ba30..d1c581501c77eb4f9fecec57732a198b81a4d88d 100644 (file)
@@ -108,8 +108,10 @@ await describe('D01 - TransactionEvent Response', async () => {
 
   await it('should not stop transaction when idTokenInfo status is Accepted', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -122,13 +124,15 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
   })
 
   await it('should stop only the specific transaction when idTokenInfo status is Invalid', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -141,16 +145,18 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert — only the specific connector (1) on EVSE (1) is stopped
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
-    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station)
-    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1)
-    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[2], 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station)
+    assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[2], 1)
   })
 
   await it('should stop only the specific transaction when idTokenInfo status is Blocked', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -163,14 +169,16 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
-    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station)
   })
 
   await it('should not stop transaction when only chargingPriority is present', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       chargingPriority: 5,
@@ -181,13 +189,15 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
   })
 
   await it('should handle empty response without stopping transaction', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {}
     const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
@@ -196,13 +206,15 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
   })
 
   await it('should stop only the specific transaction when idTokenInfo status is Expired', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -215,13 +227,15 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
   })
 
   await it('should stop only the specific transaction when idTokenInfo status is NoCredit', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -234,13 +248,15 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
   })
 
   await it('should not stop transaction when response has totalCost and updatedPersonalMessage', () => {
     // Arrange
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       totalCost: 12.5,
@@ -255,7 +271,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     testable.handleResponseTransactionEvent(station, payload, requestPayload)
 
     // Assert
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
   })
 
   await it('should stop only the targeted transaction on multi-EVSE station', () => {
@@ -284,8 +300,10 @@ await describe('D01 - TransactionEvent Response', async () => {
       connector2.transactionId = txn2
     }
 
-    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
-      Promise.resolve({ status: 'Accepted' })
+    const mockDeauthTransaction = mock.method(
+      OCPP20ServiceUtils,
+      'requestDeauthorizeTransaction',
+      () => Promise.resolve({ status: 'Accepted' })
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -302,8 +320,8 @@ await describe('D01 - TransactionEvent Response', async () => {
     )
 
     // Assert — only 1 stop call targeting connector 1, EVSE 2 untouched
-    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
-    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], multiStation)
-    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], multiStation)
+    assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1)
   })
 })
index 88cbc11a9b545edb6dd09400896a0a15bab046ca..1f0915c0da13cb60e150479eadfed2378116053f 100644 (file)
@@ -20,12 +20,23 @@ import {
   buildTransactionEvent,
   OCPP20ServiceUtils,
 } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import { OCPPError } from '../../../../src/exception/OCPPError.js'
+import {
+  AttributeEnumType,
+  OCPP20ComponentName,
+  OCPP20RequiredVariableName,
+} from '../../../../src/types/index.js'
 import {
   ConnectorStatusEnum,
   OCPP20TransactionEventEnumType,
   OCPP20TriggerReasonEnumType,
   OCPPVersion,
 } from '../../../../src/types/index.js'
+import {
+  OCPP20MeasurandEnumType,
+  OCPP20ReadingContextEnumType,
+} from '../../../../src/types/ocpp/2.0/MeterValues.js'
 import {
   OCPP20ChargingStateEnumType,
   OCPP20IdTokenEnumType,
@@ -2321,4 +2332,235 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       })
     })
   })
+
+  await describe('getTxUpdatedInterval', async () => {
+    let station: ChargingStation
+
+    beforeEach(() => {
+      const mockTracking = createMockStationWithRequestTracking()
+      station = mockTracking.station
+      resetLimits(station)
+    })
+
+    afterEach(() => {
+      OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+      standardCleanup()
+    })
+
+    await it('should return default interval when TxUpdatedInterval is not configured', () => {
+      const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+      assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+    })
+
+    await it('should return configured interval in milliseconds', () => {
+      OCPP20VariableManager.getInstance().setVariables(station, [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: '60',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])
+
+      const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+      assert.strictEqual(interval, 60000)
+    })
+
+    await it('should return default for non-numeric value', () => {
+      OCPP20VariableManager.getInstance().setVariables(station, [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: 'abc',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])
+
+      const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+      assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+    })
+
+    await it('should return default for zero value', () => {
+      OCPP20VariableManager.getInstance().setVariables(station, [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: '0',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])
+
+      const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+      assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+    })
+
+    await it('should return default for negative value', () => {
+      OCPP20VariableManager.getInstance().setVariables(station, [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: '-10',
+          component: { name: OCPP20ComponentName.SampledDataCtrlr },
+          variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+        },
+      ])
+
+      const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+      assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000)
+    })
+  })
+
+  await describe('requestDeauthorizeTransaction', async () => {
+    let mockTracking: MockStationWithTracking
+
+    beforeEach(() => {
+      mockTracking = createMockStationWithRequestTracking()
+      resetConnectorTransactionState(mockTracking.station)
+    })
+
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should send Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)', async () => {
+      // Arrange
+      const connectorId = 1
+      const transactionId = generateUUID()
+      const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+      assert.notStrictEqual(connectorStatus, undefined)
+      if (connectorStatus != null) {
+        connectorStatus.transactionStarted = true
+        connectorStatus.transactionId = transactionId
+        connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+      }
+
+      // Act
+      await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+      // Assert
+      const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+      assert.strictEqual(txEvents.length, 2)
+
+      const updatedEvent = txEvents[0].payload
+      assert.strictEqual(updatedEvent.eventType, OCPP20TransactionEventEnumType.Updated)
+      assert.strictEqual(updatedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
+      assert.strictEqual(updatedEvent.chargingState, OCPP20ChargingStateEnumType.SuspendedEVSE)
+
+      const endedEvent = txEvents[1].payload
+      assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
+      assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
+      assert.strictEqual(endedEvent.stoppedReason, OCPP20ReasonEnumType.DeAuthorized)
+    })
+
+    await it('should include final meter values in Ended event', async () => {
+      // Arrange
+      const connectorId = 2
+      const transactionId = generateUUID()
+      const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+      assert.notStrictEqual(connectorStatus, undefined)
+      if (connectorStatus != null) {
+        connectorStatus.transactionStarted = true
+        connectorStatus.transactionId = transactionId
+        connectorStatus.transactionEnergyActiveImportRegisterValue = 1500
+      }
+
+      // Act
+      await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2)
+
+      // Assert
+      const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+      assert.strictEqual(txEvents.length, 2)
+
+      const endedPayload = txEvents[1].payload
+      assert.notStrictEqual(endedPayload.meterValue, undefined)
+      const meterValues = endedPayload.meterValue as {
+        sampledValue: { context: string; measurand: string; value: number }[]
+        timestamp: Date
+      }[]
+      assert.strictEqual(meterValues.length, 1)
+      assert.strictEqual(meterValues[0].sampledValue.length, 1)
+      assert.strictEqual(
+        meterValues[0].sampledValue[0].measurand,
+        OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+      )
+      assert.strictEqual(meterValues[0].sampledValue[0].value, 1500)
+      assert.strictEqual(
+        meterValues[0].sampledValue[0].context,
+        OCPP20ReadingContextEnumType.TRANSACTION_END
+      )
+    })
+
+    await it('should reset connector status after deauthorization', async () => {
+      // Arrange
+      const connectorId = 1
+      const transactionId = generateUUID()
+      const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+      assert.notStrictEqual(connectorStatus, undefined)
+      if (connectorStatus != null) {
+        connectorStatus.transactionStarted = true
+        connectorStatus.transactionId = transactionId
+        connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+      }
+
+      // Act
+      await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+      // Assert
+      const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+      assert.notStrictEqual(postStatus, undefined)
+      if (postStatus != null) {
+        assert.strictEqual(postStatus.transactionStarted, false)
+        assert.strictEqual(postStatus.transactionId, undefined)
+      }
+    })
+
+    await it('should throw if no active transaction', async () => {
+      const connectorId = 1
+
+      await assert.rejects(
+        OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
+        (error: unknown) => {
+          assert.ok(error instanceof OCPPError)
+          return true
+        }
+      )
+    })
+
+    await it('should propagate error and skip cleanup if Updated event fails', async () => {
+      const connectorId = 1
+      const transactionId = generateUUID()
+      const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+      if (connectorStatus != null) {
+        connectorStatus.transactionStarted = true
+        connectorStatus.transactionId = transactionId
+        connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+      }
+
+      const originalSend = OCPP20ServiceUtils.sendTransactionEvent.bind(OCPP20ServiceUtils)
+      const sendMock = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () => {
+        sendMock.mock.restore()
+        OCPP20ServiceUtils.sendTransactionEvent = originalSend
+        return Promise.reject(new Error('Network failure'))
+      })
+
+      await assert.rejects(
+        OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
+        (error: unknown) => {
+          assert.ok(error instanceof Error)
+          assert.strictEqual(error.message, 'Network failure')
+          return true
+        }
+      )
+
+      const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+      if (postStatus != null) {
+        assert.strictEqual(postStatus.transactionStarted, true)
+        assert.strictEqual(postStatus.transactionId, transactionId)
+      }
+    })
+  })
 })