diff --git a/src/assets/i18n/cs.json b/src/assets/i18n/cs.json index 4ca7ec997c..58313eaf1e 100644 --- a/src/assets/i18n/cs.json +++ b/src/assets/i18n/cs.json @@ -332,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Billing - New Invoice", + "buttonText": "Pay Invoice", + "text": [ + "Please follow the link below to finalize the payment.", + "", + "The invoice {{invoiceNumber}} has been generated. You can download the corresponding document following the link below once paid" + ], + "table": [ + { + "label": "Amount Due", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Charging Station Connected", "buttonText": "View Charging Station", @@ -400,6 +415,15 @@ "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}." ] }, + "scan-pay-session-started": { + "title": "Session Started", + "buttonText": "Stop Session", + "text": [ + "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}.", + "", + "Click on the link below to stop the transaction" + ] + }, "verification-email": { "title": "User Account Activation", "buttonText": "Activate your Account", @@ -530,6 +554,13 @@ "An administrator will soon check and activate your account." ] }, + "scan-pay-account-verification-notification": { + "title": "Verify your email and proceed to charging", + "buttonText": "Proceed to charging", + "text": [ + "Click on the link below to verify your email and proceed to charging." + ] + }, "admin-account-verification-notification": { "title": "User Account Verification", "buttonText": "Verify Account", diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 4c0ce04813..5775776004 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -332,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Billing - New Invoice", + "buttonText": "Pay Invoice", + "text": [ + "Please follow the link below to finalize the payment.", + "", + "The invoice {{invoiceNumber}} has been generated. You can download the corresponding document following the link below once paid" + ], + "table": [ + { + "label": "Amount Due", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Ladestation Verbunden", "buttonText": "Ladestation anzeigen", @@ -400,6 +415,15 @@ "Ihr Fahrzeug wurde mit der Ladestation {{chargeBoxID}} an Ladepunkt {{connectorId}} verbunden." ] }, + "scan-pay-session-started": { + "title": "Session Started", + "buttonText": "Stop Session", + "text": [ + "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}.", + "", + "Click on the link below to stop the transaction" + ] + }, "verification-email": { "title": "Benutzerkonto Aktivieren", "buttonText": "Aktivieren Sie Ihr Benutzerkonto", @@ -530,6 +554,13 @@ "Ein Administrator wird Ihr Benutzerkonto prüfen und es aktivieren." ] }, + "scan-pay-account-verification-notification": { + "title": "Verify your email and proceed to charging", + "buttonText": "Proceed to charging", + "text": [ + "Click on the link below to verify your email and proceed to charging." + ] + }, "admin-account-verification-notification": { "title": "Benutzerkonto - Verifizierung", "buttonText": "Benutzerkonto verifizieren", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e91ac22790..5cb08fab02 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -332,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Billing - New Invoice", + "buttonText": "Pay Invoice", + "text": [ + "Please follow the link below to finalize the payment.", + "", + "The invoice {{invoiceNumber}} has been generated. You can download the corresponding document following the link below once paid" + ], + "table": [ + { + "label": "Amount Due", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Charging Station Connected", "buttonText": "View Charging Station", @@ -400,6 +415,15 @@ "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}." ] }, + "scan-pay-session-started": { + "title": "Session Started", + "buttonText": "Stop Session", + "text": [ + "Your vehicle connected to the charging station {{chargeBoxID}} on connector {{connectorId}} is now charging.", + "", + "Click on the link below to stop the transaction" + ] + }, "verification-email": { "title": "User Account Activation", "buttonText": "Activate your Account", @@ -530,6 +554,13 @@ "An administrator will soon check and activate your account." ] }, + "scan-pay-account-verification-notification": { + "title": "Verify your email and proceed to charging", + "buttonText": "Proceed to charging", + "text": [ + "Click on the link below to verify your email and proceed to charging." + ] + }, "admin-account-verification-notification": { "title": "User Account Verification", "buttonText": "Verify Account", diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 30f595c5fb..2e8c2e18a8 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -332,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Billing - New Invoice", + "buttonText": "Pay Invoice", + "text": [ + "Please follow the link below to finalize the payment.", + "", + "The invoice {{invoiceNumber}} has been generated. You can download the corresponding document following the link below once paid" + ], + "table": [ + { + "label": "Amount Due", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Estación de carga conectada", "buttonText": "Vista de la estación de carga", @@ -400,6 +415,15 @@ "Su vehículo ha sido conectado a la estación de carga {{chargeBoxID}} en el conector {{connectorId}}." ] }, + "scan-pay-session-started": { + "title": "Session Started", + "buttonText": "Stop Session", + "text": [ + "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}.", + "", + "Click on the link below to stop the transaction" + ] + }, "verification-email": { "title": "Activación de su cuenta de usuario", "buttonText": "Activar cuenta de usuario", @@ -530,6 +554,13 @@ "Un administrador verificará y activará su cuenta." ] }, + "scan-pay-account-verification-notification": { + "title": "Verify your email and proceed to charging", + "buttonText": "Proceed to charging", + "text": [ + "Click on the link below to verify your email and proceed to charging." + ] + }, "admin-account-verification-notification": { "title": "Verificación de una nueva cuenta de usuario", "buttonText": "Verificar la cuenta", diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 3f3aa742f6..1914aa915b 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -332,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Facturation - Nouvelle facture émise", + "buttonText": "Payer la facture", + "text": [ + "Veuillez suivre le lien ci-dessous pour finaliser le paiement.", + "", + "La facture numéro {{invoiceNumber}} a été émise et le document correspondant peut être téléchargé en suivant le lien ci-dessous après paiement." + ], + "table": [ + { + "label": "Montant dû", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Borne connectée", "buttonText": "Voir la borne", @@ -400,6 +415,15 @@ "Votre véhicule est connecté sur le connecteur {{connectorId}} de la borne {{chargeBoxID}}." ] }, + "scan-pay-session-started": { + "title": "Session démarrée", + "buttonText": "Stopper la session", + "text": [ + "Votre véhicule est connecté sur le connecteur {{connectorId}} de la borne {{chargeBoxID}}.", + "", + "Veuillez suivre le lien ci-dessous pour stopper la session." + ] + }, "verification-email": { "title": "Activation de votre compte utilisateur", "buttonText": "Activer le compte", @@ -530,6 +554,13 @@ "Votre compte va être activé par un administrateur. Merci de patienter." ] }, + "scan-pay-account-verification-notification": { + "title": "Vérifier votre compte et procéder au chargement", + "buttonText": "Procéder au chargement", + "text": [ + "Veuillez suivre le lien ci-dessous pour vérifier votre e-mail et procéder au chargement." + ] + }, "admin-account-verification-notification": { "title": "Vérification d'un nouveau compte utilisateur", "buttonText": "Vérifier le compte", diff --git a/src/assets/i18n/it.json b/src/assets/i18n/it.json index ae8aeb0bdc..dfc999d83a 100644 --- a/src/assets/i18n/it.json +++ b/src/assets/i18n/it.json @@ -224,6 +224,10 @@ { "label": "Livello di carica della batteria", "value": "{{stateOfCharge}} %" + }, + { + "label": "Battery Level", + "value": "{{stateOfCharge}} %" } ] }, @@ -234,6 +238,10 @@ "La sessione sulla stazione di ricarica {{chargeBoxID}}, presa {{connectorId}} é appena terminata." ], "table": [ + { + "label": "Session", + "value": "{{transactionId}}" + }, { "label": "Consumo", "value": "{{totalConsumption}} kW.h" @@ -278,6 +286,10 @@ "Il veicolo, connesso alla stazione di ricarica {{chargeBoxID}}, presa {{connectorId}}, ha appena finito la ricarica." ], "table": [ + { + "label": "Session", + "value": "{{transactionId}}" + }, { "label": "Consumo", "value": "{{totalConsumption}} kW.h" @@ -320,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Billing - New Invoice", + "buttonText": "Pay Invoice", + "text": [ + "Please follow the link below to finalize the payment.", + "", + "The invoice {{invoiceNumber}} has been generated. You can download the corresponding document following the link below once paid" + ], + "table": [ + { + "label": "Amount Due", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Stazione di Ricarica Connessa", "buttonText": "Visualizzare la stazione di ricarica", @@ -388,6 +415,15 @@ "Il veicolo é stato connesso alla stazione di ricarica {{chargeBoxID}} sulla presa {{connectorId}}." ] }, + "scan-pay-session-started": { + "title": "Session Started", + "buttonText": "Stop Session", + "text": [ + "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}.", + "", + "Click on the link below to stop the transaction" + ] + }, "verification-email": { "title": "Attivazione dell'account", "buttonText": "Attivare l'account", @@ -518,6 +554,13 @@ "Un amministratore procederà alla validazione e all'attivazione dell'account." ] }, + "scan-pay-account-verification-notification": { + "title": "Verify your email and proceed to charging", + "buttonText": "Proceed to charging", + "text": [ + "Click on the link below to verify your email and proceed to charging." + ] + }, "admin-account-verification-notification": { "title": "Verifica dell'account", "buttonText": "Verifica account", diff --git a/src/assets/i18n/pt.json b/src/assets/i18n/pt.json index 0a131620ff..91e1dde540 100644 --- a/src/assets/i18n/pt.json +++ b/src/assets/i18n/pt.json @@ -332,6 +332,21 @@ } ] }, + "billing-new-invoice-unpaid-scan-pay": { + "title": "Billing - New Invoice", + "buttonText": "Pay Invoice", + "text": [ + "Please follow the link below to finalize the payment.", + "", + "The invoice {{invoiceNumber}} has been generated. You can download the corresponding document following the link below once paid" + ], + "table": [ + { + "label": "Amount Due", + "value": "{{invoiceAmount}}" + } + ] + }, "charging-station-registered": { "title": "Charging Station Connected", "buttonText": "View Charging Station", @@ -400,6 +415,15 @@ "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}." ] }, + "scan-pay-session-started": { + "title": "Session Started", + "buttonText": "Stop Session", + "text": [ + "Your vehicle has been connected to the charging station {{chargeBoxID}} on connector {{connectorId}}.", + "", + "Click on the link below to stop the transaction" + ] + }, "verification-email": { "title": "User Account Activation", "buttonText": "Activate your Account", @@ -530,6 +554,13 @@ "An administrator will soon check and activate your account." ] }, + "scan-pay-account-verification-notification": { + "title": "Verify your email and proceed to charging", + "buttonText": "Proceed to charging", + "text": [ + "Click on the link below to verify your email and proceed to charging." + ] + }, "admin-account-verification-notification": { "title": "User Account Verification", "buttonText": "Verify Account", diff --git a/src/assets/schemas/tenant/tenant-components.json b/src/assets/schemas/tenant/tenant-components.json index db45f0ba14..347cb321d9 100644 --- a/src/assets/schemas/tenant/tenant-components.json +++ b/src/assets/schemas/tenant/tenant-components.json @@ -146,6 +146,15 @@ "sanitize": "mongo" } } + }, + "scanPay": { + "type": "object", + "properties": { + "active": { + "type": "boolean", + "sanitize": "mongo" + } + } } } } diff --git a/src/assets/schemas/user/user.json b/src/assets/schemas/user/user.json index 1a724f3bcc..0087dfcc35 100644 --- a/src/assets/schemas/user/user.json +++ b/src/assets/schemas/user/user.json @@ -76,7 +76,7 @@ "role": { "type": "string", "sanitize": "mongo", - "enum": ["S", "A", "B", "D"] + "enum": ["S", "A", "B", "D", "E"] }, "notifications": { "type": "object", diff --git a/src/assets/server/rest/v1/schemas/auth/auth-scan-pay-verify-email.json b/src/assets/server/rest/v1/schemas/auth/auth-scan-pay-verify-email.json new file mode 100644 index 0000000000..baeedf5b40 --- /dev/null +++ b/src/assets/server/rest/v1/schemas/auth/auth-scan-pay-verify-email.json @@ -0,0 +1,43 @@ +{ + "$id": "auth-scan-pay-verify-email", + "title": "Send scan and pay verification email", + "type": "object", + "properties": { + "email": { + "$ref": "user#/definitions/email" + }, + "name": { + "type": "string", + "maxLength": 100, + "transform": ["toUpperCase"], + "sanitize": "mongo" + }, + "firstName": { + "type": "string", + "maxLength": 100, + "sanitize": "mongo" + }, + "captcha": { + "$ref": "common#/definitions/captcha" + }, + "chargingStationID": { + "$ref": "chargingstation#/definitions/id" + }, + "connectorID": { + "$ref": "chargingstation#/definitions/id" + }, + "locale": { + "type": "string", + "sanitize": "mongo", + "maxLength": 5 + } + }, + "required": [ + "name", + "firstName", + "email", + "captcha", + "chargingStationID", + "connectorID" + ] +} diff --git a/src/assets/server/rest/v1/schemas/billing/billing-scan-pay.json b/src/assets/server/rest/v1/schemas/billing/billing-scan-pay.json new file mode 100644 index 0000000000..085985a87a --- /dev/null +++ b/src/assets/server/rest/v1/schemas/billing/billing-scan-pay.json @@ -0,0 +1,30 @@ +{ + "$id": "billing-scan-pay", + "title": "Handle user scan and pay", + "type": "object", + "properties": { + "email": { + "$ref": "user#/definitions/email" + }, + "paymentIntentID": { + "type": "string", + "sanitize": "mongo" + }, + "connectorID": { + "$ref": "chargingstation#/definitions/connector/properties/connectorId" + }, + "chargingStationID": { + "$ref": "chargingstation#/definitions/id" + }, + "verificationToken": { + "type": "string", + "sanitize": "mongo" + } + }, + "required": [ + "email", + "connectorID", + "chargingStationID", + "verificationToken" + ] +} diff --git a/src/assets/server/rest/v1/schemas/billing/billing-setup-payment-method.json b/src/assets/server/rest/v1/schemas/billing/billing-setup-payment-method.json index 9bf43882e9..6cafc0e3d4 100644 --- a/src/assets/server/rest/v1/schemas/billing/billing-setup-payment-method.json +++ b/src/assets/server/rest/v1/schemas/billing/billing-setup-payment-method.json @@ -6,7 +6,7 @@ "userID": { "$ref": "common#/definitions/id" }, - "paymentMethodId": { + "paymentMethodID": { "type": "string", "sanitize": "mongo" } diff --git a/src/assets/server/rest/v1/schemas/common/verify-tenant-id-subdomain.json b/src/assets/server/rest/v1/schemas/common/verify-tenant-id-subdomain.json index 0d135f14da..1ec41fbb18 100644 --- a/src/assets/server/rest/v1/schemas/common/verify-tenant-id-subdomain.json +++ b/src/assets/server/rest/v1/schemas/common/verify-tenant-id-subdomain.json @@ -18,7 +18,5 @@ "TenantID": { "$ref": "common#/definitions/id" } - }, - "required": [ - ] + } } diff --git a/src/assets/server/rest/v1/schemas/setting/setting-scan-pay-set.json b/src/assets/server/rest/v1/schemas/setting/setting-scan-pay-set.json new file mode 100644 index 0000000000..6b0645b786 --- /dev/null +++ b/src/assets/server/rest/v1/schemas/setting/setting-scan-pay-set.json @@ -0,0 +1,32 @@ +{ + "$id": "setting-scan-pay-set", + "title": "Set Scan & Pay Settings", + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "scanPay": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "sanitize": "mongo" + } + } + } + } + }, + "id": { + "$ref": "common#/definitions/id" + }, + "identifier": { + "type": "string", + "sanitize": "mongo", + "enum": ["scanPay"] + } + }, + "required": [ + "identifier" + ] +} diff --git a/src/assets/server/rest/v1/schemas/user/users-get.json b/src/assets/server/rest/v1/schemas/user/users-get.json index badef01e6f..c7a1c63092 100644 --- a/src/assets/server/rest/v1/schemas/user/users-get.json +++ b/src/assets/server/rest/v1/schemas/user/users-get.json @@ -16,7 +16,7 @@ "Role": { "type": "string", "sanitize": "mongo", - "pattern": "^[SABD](\\|[SABD])*$" + "pattern": "^[SABDE](\\|[SABDE])*$" }, "Technical": { "type": "boolean", diff --git a/src/assets/server/rest/v1/schemas/user/users-inerror-get.json b/src/assets/server/rest/v1/schemas/user/users-inerror-get.json index 5db1158e27..63f3f1851e 100644 --- a/src/assets/server/rest/v1/schemas/user/users-inerror-get.json +++ b/src/assets/server/rest/v1/schemas/user/users-inerror-get.json @@ -6,7 +6,7 @@ "Roles": { "type": "string", "sanitize": "mongo", - "pattern": "^[SABD](\\|[SABD])*$" + "pattern": "^[SABDE](\\|[SABDE])*$" }, "ErrorType": { "type": "string", diff --git a/src/authorization/Authorizations.ts b/src/authorization/Authorizations.ts index 8ec37c8aa6..63590a22fc 100644 --- a/src/authorization/Authorizations.ts +++ b/src/authorization/Authorizations.ts @@ -988,6 +988,9 @@ export default class Authorizations { case UserRole.DEMO: roles.push('demo'); break; + case UserRole.EXTERNAL: + roles.push('external'); + break; } return roles; } diff --git a/src/authorization/AuthorizationsDefinition.ts b/src/authorization/AuthorizationsDefinition.ts index 5cc1f32f59..e2de117e3d 100644 --- a/src/authorization/AuthorizationsDefinition.ts +++ b/src/authorization/AuthorizationsDefinition.ts @@ -353,7 +353,7 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { { resource: Entity.SITE, action: [ - Action.CREATE, Action.UPDATE, Action.DELETE, Action.EXPORT_OCPP_PARAMS, Action.GENERATE_QR, Action.MAINTAIN_PRICING_DEFINITIONS, + Action.CREATE, Action.UPDATE, Action.DELETE, Action.EXPORT_OCPP_PARAMS, Action.GENERATE_QR, Action.MAINTAIN_PRICING_DEFINITIONS, Action.GENERATE_QR_SCAN_PAY ], condition: { Fn: 'custom:dynamicAuthorizations', @@ -431,7 +431,7 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { action: [ Action.CREATE, Action.UPDATE, Action.DELETE, Action.ASSIGN_ASSETS_TO_SITE_AREA, Action.UNASSIGN_ASSETS_FROM_SITE_AREA, Action.ASSIGN_CHARGING_STATIONS_TO_SITE_AREA, - Action.UNASSIGN_CHARGING_STATIONS_FROM_SITE_AREA, Action.EXPORT_OCPP_PARAMS, Action.GENERATE_QR, + Action.UNASSIGN_CHARGING_STATIONS_FROM_SITE_AREA, Action.EXPORT_OCPP_PARAMS, Action.GENERATE_QR, Action.GENERATE_QR_SCAN_PAY, Action.READ_ASSETS_FROM_SITE_AREA ], condition: { @@ -495,11 +495,11 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { { resource: Entity.CHARGING_STATION, action: [ - Action.RESET, Action.CLEAR_CACHE, Action.CHANGE_AVAILABILITY, Action.UPDATE, Action.DELETE, Action.GENERATE_QR, + Action.RESET, Action.CLEAR_CACHE, Action.CHANGE_AVAILABILITY, Action.UPDATE, Action.DELETE, Action.GENERATE_QR, Action.GENERATE_QR_SCAN_PAY, Action.GET_CONFIGURATION, Action.CHANGE_CONFIGURATION, Action.STOP_TRANSACTION, Action.START_TRANSACTION, Action.AUTHORIZE, Action.SET_CHARGING_PROFILE,Action.GET_COMPOSITE_SCHEDULE, Action.CLEAR_CHARGING_PROFILE, Action.GET_DIAGNOSTICS, Action.UPDATE_FIRMWARE, Action.EXPORT_OCPP_PARAMS, Action.TRIGGER_DATA_TRANSFER, Action.UPDATE_OCPP_PARAMS, Action.LIMIT_POWER, Action.DELETE_CHARGING_PROFILE, Action.GET_OCPP_PARAMS, - Action.UPDATE_CHARGING_PROFILE, Action.GET_CONNECTOR_QR_CODE, Action.MAINTAIN_PRICING_DEFINITIONS + Action.UPDATE_CHARGING_PROFILE, Action.GET_CONNECTOR_QR_CODE, Action.MAINTAIN_PRICING_DEFINITIONS, Action.GET_CONNECTOR_QR_CODE_SCAN_PAY ], condition: { Fn: 'custom:dynamicAuthorizations', @@ -1400,7 +1400,7 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { }, { resource: Entity.CHARGING_STATION, - action: [Action.START_TRANSACTION, Action.STOP_TRANSACTION, Action.AUTHORIZE, Action.GET_CONNECTOR_QR_CODE], + action: [Action.START_TRANSACTION, Action.STOP_TRANSACTION, Action.AUTHORIZE, Action.GET_CONNECTOR_QR_CODE, Action.GET_CONNECTOR_QR_CODE_SCAN_PAY], condition: { Fn: 'custom:dynamicAuthorizations', args: { @@ -1949,7 +1949,7 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { 'siteArea', 'site', 'siteID', ] }, - { resource: Entity.CHARGING_STATION, action: [Action.GET_CONNECTOR_QR_CODE] }, + { resource: Entity.CHARGING_STATION, action: [Action.GET_CONNECTOR_QR_CODE, Action.GET_CONNECTOR_QR_CODE_SCAN_PAY] }, { resource: Entity.CHARGING_STATION, action: Action.PUSH_TRANSACTION_CDR, condition: { @@ -2569,7 +2569,7 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { }, { resource: Entity.CHARGING_STATION, - action: [Action.UPDATE, Action.EXPORT, Action.EXPORT_OCPP_PARAMS, Action.GENERATE_QR, Action.UPDATE_OCPP_PARAMS, Action.LIMIT_POWER, + action: [Action.UPDATE, Action.EXPORT, Action.EXPORT_OCPP_PARAMS, Action.GENERATE_QR, Action.GENERATE_QR_SCAN_PAY, Action.UPDATE_OCPP_PARAMS, Action.LIMIT_POWER, Action.DELETE_CHARGING_PROFILE, Action.GET_OCPP_PARAMS, Action.UPDATE_CHARGING_PROFILE, Action.MAINTAIN_PRICING_DEFINITIONS], condition: { Fn: 'custom:dynamicAuthorizations', @@ -3543,6 +3543,144 @@ export const AUTHORIZATION_DEFINITION: AuthorizationDefinition = { }, ] }, + external: { + grants: [ + { + resource: Entity.PAYMENT_INTENT, action: [Action.SETUP, Action.RETRIEVE] + }, + { + resource: Entity.TAG, action: Action.LIST, + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['OwnUser'], + metadata: { + id: { + visible: false + } + }, + } + }, + attributes: [ + 'userID', 'active', 'description', 'visualID', 'issuer', 'default', + 'createdOn', 'lastChangedOn' + ], + }, + { + resource: Entity.TAG, action: Action.READ, + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['OwnUser'], + } + }, + attributes: [ + 'userID', 'issuer', 'active', 'description', 'visualID', 'default', + 'user.id', 'user.name', 'user.firstName', 'user.email', 'user.issuer' + ], + }, + { resource: Entity.TRANSACTION, action: [Action.GET_ACTIVE_TRANSACTION, Action.READ], + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['OwnUser'] + } + }, + attributes: [ + 'id', 'chargeBoxID', 'timestamp', 'issuer', 'stateOfCharge', 'timezone', 'connectorId', 'status', 'meterStart', 'siteAreaID', 'siteID', 'companyID', + 'currentTotalDurationSecs', 'currentTotalInactivitySecs', 'currentInstantWatts', 'currentTotalConsumptionWh', 'currentStateOfCharge', + 'currentCumulatedPrice', 'currentInactivityStatus', 'roundedPrice', 'price', 'priceUnit','currentCumulatedRoundedPrice', 'stop.timestamp', 'stop.roundedPrice', 'stop.priceUnit', 'stop.totalDurationSecs', 'stop.totalConsumptionWh' + ] + }, + { resource: Entity.SETTING, action: Action.READ }, + { resource: Entity.TRANSACTION, action: [Action.REMOTE_STOP_TRANSACTION], + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['OwnUser'] + } + } + }, + { resource: Entity.CHARGING_STATION, + action: [Action.REMOTE_START_TRANSACTION, Action.REMOTE_STOP_TRANSACTION, Action.START_TRANSACTION, Action.STOP_TRANSACTION, Action.AUTHORIZE], + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['AssignedSites'] + } + }, + }, + { resource: Entity.CHARGING_STATION, + action: Action.READ, + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['AssignedSites'] + } + }, + attributes: [ + 'id','issuer','public','siteAreaID','lastSeen','inactive','forceInactive','manualConfiguration','voltage','coordinates','chargingStationURL', 'forceInactive', + 'maximumPower', 'masterSlave', 'chargePoints.chargePointID','chargePoints.currentType','chargePoints.voltage','chargePoints.amperage','chargePoints.numberOfConnectedPhase', + 'chargePoints.cannotChargeInParallel','chargePoints.sharePowerToAllConnectors','chargePoints.excludeFromPowerLimitation','chargePoints.ocppParamForPowerLimitation', + 'chargePoints.power','chargePoints.efficiency','chargePoints.connectorIDs', + 'connectors.status', 'connectors.type', 'connectors.power', 'connectors.errorCode', 'connectors.connectorId', 'connectors.currentTotalConsumptionWh', + 'connectors.currentInstantWatts', 'connectors.currentStateOfCharge', 'connectors.info', 'connectors.vendorErrorCode', 'connectors.currentTransactionID', + 'connectors.currentTotalInactivitySecs', 'connectors.phaseAssignmentToGrid', 'connectors.chargePointID', 'connectors.tariffID', 'connectors.currentTransactionDate', 'connectors.currentTagID', + 'ocpiData.evses.capabilities', + 'siteArea', 'site', 'siteID', + ] + }, + { + resource: Entity.CONNECTOR, action: [Action.REMOTE_START_TRANSACTION], + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['AssignedSites'] + } + }, + }, + { + resource: Entity.TRANSACTION, action: Action.READ, + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['OwnUser'] + } + }, + attributes: [ + 'id', 'chargeBoxID', 'timestamp', 'issuer', 'stateOfCharge', 'tagID', 'tag.visualID', 'tag.description', 'timezone', 'connectorId', 'meterStart', 'siteAreaID', 'siteID', 'companyID', + 'userID', 'user.id', 'user.name', 'user.firstName', 'user.email', 'roundedPrice', 'price', 'priceUnit', + 'stop.userID', 'stop.user.id', 'stop.user.name', 'stop.user.firstName', 'stop.user.email', + 'currentTotalDurationSecs', 'currentTotalInactivitySecs', 'currentInstantWatts', 'currentTotalConsumptionWh', 'currentStateOfCharge', + 'currentCumulatedPrice', 'currentInactivityStatus', 'signedData', 'stop.reason', + 'stop.roundedPrice', 'stop.price', 'stop.priceUnit', 'stop.inactivityStatus', 'stop.stateOfCharge', 'stop.timestamp', 'stop.totalConsumptionWh', 'stop.meterStop', + 'stop.totalDurationSecs', 'stop.totalInactivitySecs', 'stop.extraInactivitySecs', 'stop.pricingSource', 'stop.signedData', + 'stop.tagID', 'stop.tag.visualID', 'stop.tag.description', 'billingData.stop.status', 'billingData.stop.invoiceID', 'billingData.stop.invoiceItem', + 'billingData.stop.invoiceStatus', 'billingData.stop.invoiceNumber', + 'carID' ,'carCatalogID', 'carCatalog.vehicleMake', 'carCatalog.vehicleModel', 'carCatalog.vehicleModelVersion', 'car.licensePlate', + 'pricingModel' + ] + }, + { + resource: Entity.INVOICE, action: Action.DOWNLOAD, + condition: { + Fn: 'custom:dynamicAuthorizations', + args: { + asserts: [], + filters: ['OwnUser'] + } + }, + }, + ] + }, }; export const AUTHORIZATION_CONDITIONS: IDictionary = { diff --git a/src/integration/billing/BillingIntegration.ts b/src/integration/billing/BillingIntegration.ts index 4f36a2113f..9820fc7dbe 100644 --- a/src/integration/billing/BillingIntegration.ts +++ b/src/integration/billing/BillingIntegration.ts @@ -195,6 +195,7 @@ export default abstract class BillingIntegration { invoiceAmount: invoiceAmount, invoiceNumber: billingInvoice.number, invoiceStatus: billingInvoice.status, + evseScanPayInvoiceDownloadURL: Utils.buildEvseScanPayInvoiceDownloadURL(this.tenant.subdomain, billingInvoice.id, user), } ).catch((error) => { Logging.logPromiseError(error, this.tenant?.id); @@ -504,7 +505,7 @@ export default abstract class BillingIntegration { }; transaction.billingData = { withBillingActive: false, - lastUpdate:new Date(), + lastUpdate: new Date(), stop }; // Save to clear billing data @@ -639,7 +640,7 @@ export default abstract class BillingIntegration { endDateTime = moment().add(-1,'days').endOf('day').toDate(); // yesterday at midnight } // Filter the invoice status based on the billing settings - const invoiceStatus = [ BillingInvoiceStatus.OPEN ]; + const invoiceStatus = [BillingInvoiceStatus.OPEN]; // Now return the query parameters return { // -------------------------------------------------------------------------------- @@ -718,6 +719,12 @@ export default abstract class BillingIntegration { public abstract sendTransfer(transfer: BillingTransfer, user: User): Promise; + public abstract setupPaymentIntent(user: User, paymentIntentID: string, scanPayAmount?: number, currency?: string): Promise; + + public abstract capturePayment(user: User, amount: number, paymentIntentId: string): Promise; + + public abstract retrievePaymentIntent(user: User, paymentIntentId: string): Promise; + public async isUserSynchronized(user: User): Promise { // Make sure to get fresh data user = await UserStorage.getUser(this.tenant, user.id); diff --git a/src/integration/billing/stripe/StripeBillingIntegration.ts b/src/integration/billing/stripe/StripeBillingIntegration.ts index 1d099c661a..11babd9da0 100644 --- a/src/integration/billing/stripe/StripeBillingIntegration.ts +++ b/src/integration/billing/stripe/StripeBillingIntegration.ts @@ -5,7 +5,9 @@ import { DimensionType, PricedConsumptionData, PricedDimensionData } from '../.. import FeatureToggles, { Feature } from '../../../utils/FeatureToggles'; import StripeHelpers, { StripeChargeOperationResult } from './StripeHelpers'; import Transaction, { StartTransactionErrorCode } from '../../../types/Transaction'; +import User, { UserRole } from '../../../types/User'; +import AppError from '../../../exception/AppError'; import AsyncTaskBuilder from '../../../async-task/AsyncTaskBuilder'; import AxiosFactory from '../../../utils/AxiosFactory'; import { AxiosInstance } from 'axios'; @@ -17,6 +19,7 @@ import BillingStorage from '../../../storage/mongodb/BillingStorage'; import Constants from '../../../utils/Constants'; import Cypher from '../../../utils/Cypher'; import DatabaseUtils from '../../../storage/mongodb/DatabaseUtils'; +import { HTTPError } from '../../../types/HTTPError'; import I18nManager from '../../../utils/I18nManager'; import Logging from '../../../utils/Logging'; import LoggingHelper from '../../../utils/LoggingHelper'; @@ -29,7 +32,6 @@ import SettingStorage from '../../../storage/mongodb/SettingStorage'; import Stripe from 'stripe'; import Tenant from '../../../types/Tenant'; import TransactionStorage from '../../../storage/mongodb/TransactionStorage'; -import User from '../../../types/User'; import UserStorage from '../../../storage/mongodb/UserStorage'; import Utils from '../../../utils/Utils'; import moment from 'moment'; @@ -273,7 +275,7 @@ export default class StripeBillingIntegration extends BillingIntegration { return stripeInvoice; } - private async createStripeInvoice(customerID: string, userID: string, idempotencyKey: string, currency: string): Promise { + private async createStripeInvoice(customerID: string, userID: string, idempotencyKey: string, currency: string, lastPaymentIntentID?: string): Promise { const creationParameters: Stripe.InvoiceCreateParams = { customer: customerID, // collection_method: 'send_invoice', //Default option is 'charge_automatically' @@ -281,7 +283,8 @@ export default class StripeBillingIntegration extends BillingIntegration { auto_advance: false, // our integration is responsible for transitioning the invoice between statuses metadata: { tenantID: this.tenant.id, - userID + userID, + lastPaymentIntentID }, currency }; @@ -471,10 +474,11 @@ export default class StripeBillingIntegration extends BillingIntegration { return billingInvoice; } - private async chargeStripeInvoice(invoiceID: string): Promise { + private async chargeStripeInvoice(invoiceID: string, user?: User, lastPaymentIntentID = null): Promise { try { // Fetch the invoice from stripe (do NOT TRUST the local copy) let stripeInvoice: Stripe.Invoice = await this.stripe.invoices.retrieve(invoiceID); + const paymentOptions: Stripe.InvoicePayParams = {}; // Check the current invoice status if (stripeInvoice.status !== BillingInvoiceStatus.PAID) { // Finalize the invoice (if necessary) @@ -484,8 +488,16 @@ export default class StripeBillingIntegration extends BillingIntegration { // Once finalized, the invoice is in the "open" state! if (stripeInvoice.status === BillingInvoiceStatus.OPEN || stripeInvoice.status === BillingInvoiceStatus.UNCOLLECTIBLE) { - // Set the payment options - const paymentOptions: Stripe.InvoicePayParams = {}; + if (lastPaymentIntentID) { + const paymentIntent = await this.stripe.paymentIntents.retrieve(lastPaymentIntentID as string); + if (stripeInvoice.amount_due <= paymentIntent.amount) { + await this.capturePayment(user, stripeInvoice.amount_due, lastPaymentIntentID as string); + // Paid out of band tells stripe we captured the invoice amount from the hold amount, invoice is paid outside the stripe's standard flow + paymentOptions.paid_out_of_band = true; + } else { + paymentOptions.payment_method = paymentIntent.payment_method as string; + } + } stripeInvoice = await this.stripe.invoices.pay(invoiceID, paymentOptions); } } @@ -501,8 +513,75 @@ export default class StripeBillingIntegration extends BillingIntegration { } } - public async setupPaymentMethod(user: User, paymentMethodId: string): Promise { + // Called once at step #1 once at step #2 with or without paymentIntentID to know where the call comes from + public async setupPaymentIntent(user: User, paymentIntentID: string, scanPayAmount?: number, currency?: string): Promise { // Check Stripe + await this.checkConnection(); + // Check billing data consistency + let customerID = user?.billingData?.customerID; + if (!customerID) { + // User Sync is now made implicitly - LAZY mode + const billingUser = await this.synchronizeUser(user); + customerID = billingUser?.billingData?.customerID; + } + // User should now exist + if (!customerID) { + throw new BackendError({ + message: `User is not known in Stripe: '${user.id}')`, + module: MODULE_NAME, + method: 'setupPaymentIntent', + actionOnUser: user, + action: ServerAction.BILLING_SETUP_PAYMENT_METHOD + }); + } + // Let's do it! + let billingOperationResult: BillingOperationResult; + if (!paymentIntentID) { + // Let's create a payment intent for the stripe customer + billingOperationResult = await this.createPaymentIntent(user, customerID, scanPayAmount, currency); + } else { + // Retrieve payment intent + billingOperationResult = await this.retrievePaymentIntent(user, paymentIntentID); + } + return billingOperationResult; + } + + // Only called when final amount is less than holded amount + public async capturePayment(user: User, amount: number, paymentIntentID: string): Promise { + try { + // Let's capture the amount + const paymentIntent: Stripe.PaymentIntent = await this.stripe.paymentIntents.capture(paymentIntentID, { + amount_to_capture: amount + }); + await Logging.logInfo({ + tenantID: this.tenant.id, + action: ServerAction.BILLING_SETUP_PAYMENT_METHOD, + module: MODULE_NAME, method: 'capturePayment', + actionOnUser: user, + message: 'payment intent has been captured' + }); + // Send some feedback + return { + succeeded: true, + internalData: paymentIntent + }; + } catch (error) { + // catch stripe errors and send the information back to the client + await Logging.logError({ + tenantID: this.tenant.id, + action: ServerAction.BILLING_SETUP_PAYMENT_METHOD, + actionOnUser: user, + module: MODULE_NAME, method: 'capturePayment', + message: `Stripe operation failed - ${error?.message as string}` + }); + return { + succeeded: false, + error + }; + } + } + + public async setupPaymentMethod(user: User, paymentMethodId: string): Promise { await this.checkConnection(); // Check billing data consistency let customerID = user?.billingData?.customerID; @@ -770,8 +849,10 @@ export default class StripeBillingIntegration extends BillingIntegration { const customerID: string = transaction.user?.billingData?.customerID; // Check whether the customer exists or not const customer = await this.checkStripeCustomer(customerID); - // Check whether the customer has a default payment method - await this.checkStripePaymentMethod(customer); + if (!transaction.lastPaymentIntentID) { + // Check whether the customer has a default payment method + await this.checkStripePaymentMethod(customer); + } // Well ... when in test mode we may allow to start the transaction if (!customerID) { // Not yet LIVE ... starting a transaction without a STRIPE CUSTOMER is allowed @@ -787,6 +868,66 @@ export default class StripeBillingIntegration extends BillingIntegration { }; } + public async retrievePaymentIntent(user: User, paymentIntentId: string): Promise { + try { + // Let's retrieve the paymentIntent for the stripe customer + const paymentIntent: Stripe.PaymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); + // Send some feedback + return { + succeeded: true, + internalData: paymentIntent + }; + } catch (error) { + // catch stripe errors and send the information back to the client + await Logging.logError({ + tenantID: this.tenant.id, + action: ServerAction.BILLING_SETUP_PAYMENT_METHOD, + actionOnUser: user, + module: MODULE_NAME, method: 'retrievePaymentIntent', + message: `Stripe operation failed - ${error?.message as string}` + }); + return { + succeeded: false, + error + }; + } + } + + private async createPaymentIntent(user: User, customerID: string, scanPayAmount?: number, currency?: string): Promise { + try { + // Let's create a paymentIntent for the stripe customer + const paymentIntent: Stripe.PaymentIntent = await this.stripe.paymentIntents.create({ + customer: customerID, + // Stripe wait for cents amount = x100, ui displays euros as db + amount: scanPayAmount * 100, + currency: currency.toLowerCase(), + setup_future_usage: 'off_session', + capture_method: 'manual', + receipt_email: user.email + }); + await Logging.logInfo({ + tenantID: this.tenant.id, + action: ServerAction.BILLING_SETUP_PAYMENT_METHOD, + module: MODULE_NAME, method: 'createPaymentIntent', + actionOnUser: user, + message: `Payment intent has been created - customer '${customerID}'` + }); + // Send some feedback + return { + succeeded: true, + internalData: paymentIntent + }; + } catch (error) { + // catch stripe errors and send the information back to the client + throw new AppError({ + errorCode: HTTPError.SCAN_PAY_HOLD_AMOUNT_MISSING, + user: user, + module: MODULE_NAME, method: 'createPaymentIntent', + message: 'Scan & Pay hold amount is not configured' + }); + } + } + private async checkStripeCustomer(customerID: string): Promise { const customer = await this.getStripeCustomer(customerID); if (!customer) { @@ -803,7 +944,7 @@ export default class StripeBillingIntegration extends BillingIntegration { private async checkStripePaymentMethod(customer: Stripe.Customer): Promise { if (Utils.isDevelopmentEnv() && customer.default_source) { // Specific situation used only while running tests - return ; + return; } if (!customer.invoice_settings?.default_payment_method) { throw new BackendError({ @@ -1022,9 +1163,9 @@ export default class StripeBillingIntegration extends BillingIntegration { const accountData = await this.retrieveAccountData(transaction); // ACHTUNG: a single transaction may generate several lines in the invoice - one line per paring dimension const invoiceItem: BillingInvoiceItem = this.convertToBillingInvoiceItem(transaction, accountData); - const billingInvoice = await this.billInvoiceItem(transaction.user, invoiceItem); + const billingInvoice = await this.billInvoiceItem(transaction.user, invoiceItem, transaction.lastPaymentIntentID); // Send a notification to the user - void this.sendInvoiceNotification(billingInvoice); + await this.sendInvoiceNotification(billingInvoice); return { status: BillingStatus.BILLED, invoiceID: billingInvoice.id, @@ -1167,7 +1308,7 @@ export default class StripeBillingIntegration extends BillingIntegration { return pricingConsumptionData; } - public async billInvoiceItem(user: User, billingInvoiceItem: BillingInvoiceItem): Promise { + public async billInvoiceItem(user: User, billingInvoiceItem: BillingInvoiceItem, lastPaymentIntentID: string = null): Promise { // Let's collect the required information let refreshDataRequired = false; const userID: string = user.id; @@ -1175,14 +1316,15 @@ export default class StripeBillingIntegration extends BillingIntegration { const currency = billingInvoiceItem.currency.toLowerCase(); // Check whether a DRAFT invoice can be used or not let stripeInvoice: Stripe.Invoice = null; - if (!this.settings.billing?.immediateBillingAllowed) { + // we are NOT in the immediate billing scenario and we are NOT in the scan & pay flow + if (!this.settings.billing?.immediateBillingAllowed && !lastPaymentIntentID) { // immediateBillingAllowed is OFF - let's retrieve to the latest DRAFT invoice (if any) stripeInvoice = await this.getLatestDraftInvoiceOfTheMonth(this.tenant.id, userID, customerID); } if (FeatureToggles.isFeatureActive(Feature.BILLING_INVOICES_EXCLUDE_PENDING_ITEMS)) { if (!stripeInvoice) { // NEW STRIPE API - Invoice can mow be created before its items - stripeInvoice = await this.createStripeInvoice(customerID, userID, this.buildIdemPotencyKey(billingInvoiceItem.transactionID, 'invoice'), currency); + stripeInvoice = await this.createStripeInvoice(customerID, userID, this.buildIdemPotencyKey(billingInvoiceItem.transactionID, 'invoice'), currency, lastPaymentIntentID); } // Let's create an invoice item per dimension await this.createStripeInvoiceItems(customerID, billingInvoiceItem, stripeInvoice.id); @@ -1199,9 +1341,9 @@ export default class StripeBillingIntegration extends BillingIntegration { } } let operationResult: StripeChargeOperationResult; - if (this.settings.billing?.immediateBillingAllowed) { + if (this.settings.billing?.immediateBillingAllowed || lastPaymentIntentID) { // Let's try to bill the stripe invoice using the default payment method of the customer - operationResult = await this.chargeStripeInvoice(stripeInvoice.id); + operationResult = await this.chargeStripeInvoice(stripeInvoice.id, user, lastPaymentIntentID); if (!operationResult?.succeeded && operationResult?.error) { await Logging.logError({ tenantID: this.tenant.id, @@ -1452,7 +1594,7 @@ export default class StripeBillingIntegration extends BillingIntegration { await this.checkConnection(); await this.checkIfUserCanBeUpdated(user); // Let's check if the STRIPE customer exists - const customerID:string = user?.billingData?.customerID; + const customerID : string = user?.billingData?.customerID; if (!customerID) { throw new Error('Unexpected situation - the customerID is NOT set'); } @@ -1568,8 +1710,10 @@ export default class StripeBillingIntegration extends BillingIntegration { try { // Check whether the customer exists or not const customer = await this.checkStripeCustomer(customerID); - // Check whether the customer has a default payment method - await this.checkStripePaymentMethod(customer); + if (user.role !== UserRole.EXTERNAL) { + // Check whether the customer has a default payment method + await this.checkStripePaymentMethod(customer); + } } catch (error) { await Logging.logError({ tenantID: this.tenant.id, diff --git a/src/notification/NotificationHandler.ts b/src/notification/NotificationHandler.ts index f0eb1006ce..92f29f34c6 100644 --- a/src/notification/NotificationHandler.ts +++ b/src/notification/NotificationHandler.ts @@ -1,5 +1,5 @@ import User, { UserRole } from '../types/User'; -import UserNotifications, { AccountVerificationNotification, AdminAccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationSeverity, NotificationSource, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserNotificationKeys, VerificationEmailNotification } from '../types/UserNotifications'; +import UserNotifications, { AccountVerificationNotification, AdminAccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationSeverity, NotificationSource, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, ScanPayTransactionStartedNotification, ScanPayVerifyEmailNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserNotificationKeys, VerificationEmailNotification } from '../types/UserNotifications'; import ChargingStation from '../types/ChargingStation'; import Configuration from '../utils/Configuration'; @@ -11,7 +11,6 @@ import NotificationStorage from '../storage/mongodb/NotificationStorage'; import RemotePushNotificationTask from './remote-push-notification/RemotePushNotificationTask'; import { ServerAction } from '../types/Server'; import Tenant from '../types/Tenant'; -import TenantStorage from '../storage/mongodb/TenantStorage'; import UserStorage from '../storage/mongodb/UserStorage'; import Utils from '../utils/Utils'; import moment from 'moment'; @@ -370,6 +369,49 @@ export default class NotificationHandler { } } + public static async sendScanPayVerifyEmail(tenant: Tenant, notificationID: string, user: User, sourceData: ScanPayVerifyEmailNotification): Promise { + if (tenant.id !== Constants.DEFAULT_TENANT_ID) { + // For each Sources + for (const notificationSource of NotificationHandler.notificationSources) { + // Active? + if (notificationSource.enabled) { + try { + // Save + await NotificationHandler.saveNotification( + tenant, notificationSource.channel, notificationID, ServerAction.SCAN_PAY_VERIFY_EMAIL, { user }); + // Send + await notificationSource.notificationTask.sendScanPayVerifyEmailNotification( + sourceData, user, tenant, NotificationSeverity.INFO); + } catch (error) { + await Logging.logActionExceptionMessage(tenant.id, ServerAction.SCAN_PAY_VERIFY_EMAIL, error); + } + } + } + } + } + + public static async sendScanPayTransactionStarted(tenant: Tenant, notificationID: string, user: User, chargingStation: ChargingStation, + sourceData: ScanPayTransactionStartedNotification): Promise { + if (tenant.id !== Constants.DEFAULT_TENANT_ID) { + // For each Sources + for (const notificationSource of NotificationHandler.notificationSources) { + // Active? + if (notificationSource.enabled) { + try { + // Save + await NotificationHandler.saveNotification( + tenant, notificationSource.channel, notificationID, ServerAction.SCAN_PAY_TRANSACTION_STARTED, { user }); + // Send + await notificationSource.notificationTask.sendScanPaySessionStarted( + sourceData, user, tenant, NotificationSeverity.INFO); + } catch (error) { + await Logging.logActionExceptionMessage(tenant.id, ServerAction.SCAN_PAY_VERIFY_EMAIL, error); + } + } + } + } + } + public static async sendAdminAccountVerification(tenant: Tenant, notificationID: string, user: User, sourceData: AdminAccountVerificationNotification): Promise { if (tenant.id !== Constants.DEFAULT_TENANT_ID) { // Get the admin diff --git a/src/notification/NotificationTask.ts b/src/notification/NotificationTask.ts index 63d35753bd..162ee494df 100644 --- a/src/notification/NotificationTask.ts +++ b/src/notification/NotificationTask.ts @@ -1,5 +1,5 @@ /* eslint-disable max-len */ -import { AccountVerificationNotification, AdminAccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationResult, NotificationSeverity, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserCreatePassword, VerificationEmailNotification } from '../types/UserNotifications'; +import { AccountVerificationNotification, AdminAccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationResult, NotificationSeverity, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, ScanPayVerifyEmailNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserCreatePassword, VerificationEmailNotification } from '../types/UserNotifications'; import Tenant from '../types/Tenant'; import User from '../types/User'; @@ -37,4 +37,6 @@ export default interface NotificationTask { sendAdminAccountVerificationNotification(data: AdminAccountVerificationNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise; sendUserCreatePassword(data: UserCreatePassword, user: User, tenant: Tenant, severity: NotificationSeverity): Promise; sendVerificationEmailUserImport(data: VerificationEmailNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise; + sendScanPayVerifyEmailNotification(data: ScanPayVerifyEmailNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise; + sendScanPaySessionStarted(data: TransactionStartedNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise; } diff --git a/src/notification/email/EMailNotificationTask.ts b/src/notification/email/EMailNotificationTask.ts index c7cc61d204..c4b1601466 100644 --- a/src/notification/email/EMailNotificationTask.ts +++ b/src/notification/email/EMailNotificationTask.ts @@ -1,7 +1,8 @@ /* eslint-disable max-len */ -import { AccountVerificationNotification, AdminAccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EmailNotificationMessage, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationResult, NotificationSeverity, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserCreatePassword, VerificationEmailNotification } from '../../types/UserNotifications'; +import { AccountVerificationNotification, AdminAccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EmailNotificationMessage, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationResult, NotificationSeverity, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, ScanPayTransactionStartedNotification, ScanPayVerifyEmailNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserCreatePassword, VerificationEmailNotification } from '../../types/UserNotifications'; import EmailComponentManager, { EmailComponent } from './EmailComponentManager'; import { Message, SMTPClient, SMTPError } from 'emailjs'; +import User, { UserRole } from '../../types/User'; import BackendError from '../../exception/BackendError'; import BrandingConstants from '../../utils/BrandingConstants'; @@ -14,7 +15,6 @@ import LoggingHelper from '../../utils/LoggingHelper'; import NotificationTask from '../NotificationTask'; import { ServerAction } from '../../types/Server'; import Tenant from '../../types/Tenant'; -import User from '../../types/User'; import Utils from '../../utils/Utils'; import mjmlBuilder from './EmailMjmlBuilder'; import rfc2047 from 'rfc2047'; @@ -181,11 +181,15 @@ export default class EMailNotificationTask implements NotificationTask { const optionalComponents = [await EmailComponentManager.getComponent(EmailComponent.MJML_TABLE)]; let templateName: string; if (data.invoiceStatus === 'paid') { - data.buttonUrl = data.invoiceDownloadUrl; + if (user.role === UserRole.EXTERNAL) { + data.buttonUrl = data.evseScanPayInvoiceDownloadURL; + } else { + data.buttonUrl = data.invoiceDownloadUrl; + } templateName = 'billing-new-invoice-paid'; } else { data.buttonUrl = data.payInvoiceUrl; - templateName = 'billing-new-invoice-unpaid'; + templateName = user.role === UserRole.EXTERNAL ? 'billing-new-invoice-unpaid-scan-pay' : 'billing-new-invoice-unpaid'; } return await this.prepareAndSendEmail(templateName, data, user, tenant, severity, optionalComponents); } @@ -216,6 +220,18 @@ export default class EMailNotificationTask implements NotificationTask { return await this.prepareAndSendEmail(templateName, data, user, tenant, severity); } + public async sendScanPayVerifyEmailNotification(data: ScanPayVerifyEmailNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise { + data.buttonUrl = data.evseDashboardVerifyScanPayEmailURL; + const templateName = 'scan-pay-account-verification-notification'; + return await this.prepareAndSendEmail(templateName, data, user, tenant, severity); + } + + public async sendScanPaySessionStarted(data: ScanPayTransactionStartedNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise { + data.buttonUrl = data.evseStopScanPayTransactionURL; + const templateName = 'scan-pay-session-started'; + return await this.prepareAndSendEmail(templateName, data, user, tenant, severity); + } + public async sendAdminAccountVerificationNotification(data: AdminAccountVerificationNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise { data.buttonUrl = data.evseUserToVerifyURL; data.email = data.user.email; @@ -364,6 +380,12 @@ export default class EMailNotificationTask implements NotificationTask { } // Enrich the sourceData with constant values this.enrichSourceData(tenant, sourceData); + // Handle + sign in email addresses + if (recipient.role === UserRole.EXTERNAL && recipient.email.includes('+')) { + const lastPlusSignIndex = recipient.email.lastIndexOf('+'); + const atSignIndex = recipient.email.indexOf('@'); + recipient.email = recipient.email.slice(0, lastPlusSignIndex) + recipient.email.slice(atSignIndex); + } // Build the context with recipient data const context: Record = this.populateNotificationContext(tenant, recipient, sourceData); // Send the email diff --git a/src/notification/remote-push-notification/RemotePushNotificationTask.ts b/src/notification/remote-push-notification/RemotePushNotificationTask.ts index 992cce7b16..0bbe91168a 100644 --- a/src/notification/remote-push-notification/RemotePushNotificationTask.ts +++ b/src/notification/remote-push-notification/RemotePushNotificationTask.ts @@ -1,5 +1,5 @@ /* eslint-disable max-len */ -import { AccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingPeriodicOperationFailedNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationResult, NotificationSeverity, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserNotificationType, VerificationEmailNotification } from '../../types/UserNotifications'; +import { AccountVerificationNotification, BillingAccountActivationNotification, BillingAccountCreationLinkNotification, BillingInvoiceSynchronizationFailedNotification, BillingNewInvoiceNotification, BillingPeriodicOperationFailedNotification, BillingUserSynchronizationFailedNotification, CarCatalogSynchronizationFailedNotification, ChargingStationRegisteredNotification, ChargingStationStatusErrorNotification, ComputeAndApplyChargingProfilesFailedNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, EndUserErrorNotification, NewRegisteredUserNotification, NotificationResult, NotificationSeverity, OCPIPatchChargingStationsStatusesErrorNotification, OICPPatchChargingStationsErrorNotification, OICPPatchChargingStationsStatusesErrorNotification, OfflineChargingStationNotification, OptimalChargeReachedNotification, PreparingSessionNotStartedNotification, RequestPasswordNotification, ScanPayTransactionStartedNotification, ScanPayVerifyEmailNotification, SessionNotStartedNotification, TransactionStartedNotification, UnknownUserBadgedNotification, UserAccountInactivityNotification, UserAccountStatusChangedNotification, UserNotificationType, VerificationEmailNotification } from '../../types/UserNotifications'; import User, { UserStatus } from '../../types/User'; import Configuration from '../../utils/Configuration'; @@ -314,6 +314,16 @@ export default class RemotePushNotificationTask implements NotificationTask { return Promise.resolve({}); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sendScanPayVerifyEmailNotification(data: ScanPayVerifyEmailNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise { + return Promise.resolve({}); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sendScanPaySessionStarted(data: ScanPayTransactionStartedNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise { + return Promise.resolve({}); + } + public async sendOCPIPatchChargingStationsStatusesError(data: OCPIPatchChargingStationsStatusesErrorNotification, user: User, tenant: Tenant, severity: NotificationSeverity): Promise { // Set the locale diff --git a/src/server/ocpp/services/OCPPService.ts b/src/server/ocpp/services/OCPPService.ts index 2b8cc9c010..ce7bfbdf5f 100644 --- a/src/server/ocpp/services/OCPPService.ts +++ b/src/server/ocpp/services/OCPPService.ts @@ -5,6 +5,7 @@ import ChargingStation, { ChargerVendor, Connector, ConnectorCurrentLimitSource, import FeatureToggles, { Feature } from '../../../utils/FeatureToggles'; import Tenant, { TenantComponents } from '../../../types/Tenant'; import Transaction, { InactivityStatus, TransactionAction } from '../../../types/Transaction'; +import User, { UserRole } from '../../../types/User'; import { Action } from '../../../types/Authorization'; import AsyncTaskBuilder from '../../../async-task/AsyncTaskBuilder'; @@ -39,7 +40,6 @@ import SiteArea from '../../../types/SiteArea'; import SiteAreaStorage from '../../../storage/mongodb/SiteAreaStorage'; import SmartChargingFactory from '../../../integration/smart-charging/SmartChargingFactory'; import TransactionStorage from '../../../storage/mongodb/TransactionStorage'; -import User from '../../../types/User'; import UserStorage from '../../../storage/mongodb/UserStorage'; import Utils from '../../../utils/Utils'; import moment from 'moment'; @@ -385,6 +385,7 @@ export default class OCPPService { newTransaction.userID = user.id; newTransaction.user = user; newTransaction.authorizationID = user.authorizationID; + newTransaction.lastPaymentIntentID = user.role === UserRole.EXTERNAL && user.lastPaymentIntentID ? user.lastPaymentIntentID : null; } // Cleanup ongoing Transaction - TODO - To be clarified - header is incomplete >> Cannot read properties of undefined (reading 'ocppVersion') // await this.processExistingTransaction(tenant, chargingStation, startTransaction.connectorId); @@ -407,7 +408,11 @@ export default class OCPPService { // Save await ChargingStationStorage.saveChargingStation(tenant, chargingStation); // Notify - NotificationHelper.notifyStartTransaction(tenant, newTransaction, chargingStation, user); + if (user.role === UserRole.EXTERNAL) { + NotificationHelper.notifyScanPayStartTransaction(tenant, newTransaction, chargingStation, user); + } else { + NotificationHelper.notifyStartTransaction(tenant, newTransaction, chargingStation, user); + } await Logging.logInfo({ ...LoggingHelper.getChargingStationProperties(chargingStation), tenantID: tenant.id, diff --git a/src/server/rest/v1/router/api/BillingRouter.ts b/src/server/rest/v1/router/api/BillingRouter.ts index 3968fe5e76..b124b5d441 100644 --- a/src/server/rest/v1/router/api/BillingRouter.ts +++ b/src/server/rest/v1/router/api/BillingRouter.ts @@ -51,6 +51,8 @@ export default class BillingRouter { this.buildRouteBillingSendAccountOnboarding(); this.buildRouteBillingGetTransfers(); this.buildRouteBillingGetTransfer(); + this.buildRouteScanPayPaymentIntentRetrieve(); + this.buildRouteScanPayPaymentIntentSetup(); this.buildRouteBillingFinalizeTransfer(); this.buildRouteBillingSendTransfer(); return this.router; @@ -68,6 +70,20 @@ export default class BillingRouter { }); } + private buildRouteScanPayPaymentIntentSetup(): void { + this.router.post(`/${RESTServerRoute.REST_SCAN_PAY_PAYMENT_INTENT_SETUP}`, (req: Request, res: Response, next: NextFunction) => { + // Step #1 - Creates user and payment intent + void RouterUtils.handleRestServerAction(BillingService.handleScanPayPaymentIntentSetup.bind(this), ServerAction.SCAN_PAY_PAYMENT_INTENT_SETUP, req, res, next); + }); + } + + private buildRouteScanPayPaymentIntentRetrieve(): void { + this.router.post(`/${RESTServerRoute.REST_SCAN_PAY_PAYMENT_INTENT_RETRIEVE}`, (req: Request, res: Response, next: NextFunction) => { + // Step #2 - Validates the payment intent once credit card has been approved - prepare for capturing once session is ended + void RouterUtils.handleRestServerAction(BillingService.handleScanPayPaymentIntentRetrieve.bind(this), ServerAction.SCAN_PAY_PAYMENT_INTENT_RETRIEVE, req, res, next); + }); + } + private buildRouteCheckBillingConnection(): void { this.router.post(`/${RESTServerRoute.REST_BILLING_CHECK}`, (req: Request, res: Response, next: NextFunction) => { void RouterUtils.handleRestServerAction(BillingService.handleCheckBillingConnection.bind(this), ServerAction.SETTINGS, req, res, next); @@ -107,7 +123,6 @@ export default class BillingRouter { private buildRouteBillingPaymentMethodSetup(): void { this.router.post(`/${RESTServerRoute.REST_BILLING_PAYMENT_METHOD_SETUP}`, (req: Request, res: Response, next: NextFunction) => { // STRIPE prerequisite - ask for a setup intent first! - req.body.userID = req.params.userID; void RouterUtils.handleRestServerAction(BillingService.handleBillingSetupPaymentMethod.bind(this), ServerAction.BILLING_SETUP_PAYMENT_METHOD, req, res, next); }); } @@ -115,8 +130,6 @@ export default class BillingRouter { private buildRouteBillingPaymentMethodAttach(): void { this.router.post(`/${RESTServerRoute.REST_BILLING_PAYMENT_METHOD_ATTACH}`, (req: Request, res: Response, next: NextFunction) => { // Creates a new payment method and attach it to the user as its default - req.body.userID = req.params.userID; - req.body.paymentMethodId = req.params.paymentMethodID; void RouterUtils.handleRestServerAction(BillingService.handleBillingSetupPaymentMethod.bind(this), ServerAction.BILLING_SETUP_PAYMENT_METHOD, req, res, next); }); } diff --git a/src/server/rest/v1/router/api/ChargingStationRouter.ts b/src/server/rest/v1/router/api/ChargingStationRouter.ts index e79b301c1f..f9e749aaba 100644 --- a/src/server/rest/v1/router/api/ChargingStationRouter.ts +++ b/src/server/rest/v1/router/api/ChargingStationRouter.ts @@ -34,10 +34,12 @@ export default class ChargingStationRouter { this.buildRouteChargingStationRemoteStop(); this.buildRouteChargingStationUnlockConnector(); this.buildRouteChargingStationGenerateQRCode(); + this.buildRouteChargingStationGenerateQRCodeScanPay(); this.buildRouteChargingStationGetCompositeSchedule(); this.buildRouteChargingStationGetDiagnostics(); this.buildRouteChargingStationUpdateFirmware(); this.buildRouteChargingStationDownloadQRCode(); + this.buildRouteChargingStationDownloadQRCodeScanPay(); this.buildRouteChargingStationGetOCPPParameters(); this.buildRouteChargingStationExportOCPPParameters(); this.buildRouteChargingStationUpdateParameters(); @@ -164,12 +166,27 @@ export default class ChargingStationRouter { }); } + private buildRouteChargingStationGenerateQRCodeScanPay(): void { + this.router.get(`/${RESTServerRoute.REST_CHARGING_STATIONS_QRCODE_GENERATE_SCAN_PAY}`, (req: Request, res: Response, next: NextFunction) => { + req.query.ChargingStationID = req.params.id; + req.query.ConnectorID = req.params.connectorId; + void RouterUtils.handleRestServerAction(ChargingStationService.handleGenerateQrCodeForConnectorScanPay.bind(this), + ServerAction.GENERATE_QR_CODE_FOR_CONNECTOR_SCAN_PAY, req, res, next); + }); + } + private buildRouteChargingStationDownloadQRCode(): void { this.router.get(`/${RESTServerRoute.REST_CHARGING_STATIONS_QRCODE_DOWNLOAD}`, (req: Request, res: Response, next: NextFunction) => { void RouterUtils.handleRestServerAction(ChargingStationService.handleDownloadQrCodesPdf.bind(this), ServerAction.CHARGING_STATION_DOWNLOAD_QR_CODE_PDF, req, res, next); }); } + private buildRouteChargingStationDownloadQRCodeScanPay(): void { + this.router.get(`/${RESTServerRoute.REST_CHARGING_STATIONS_QRCODE_SCAN_PAY_DOWNLOAD}`, (req: Request, res: Response, next: NextFunction) => { + void RouterUtils.handleRestServerAction(ChargingStationService.handleDownloadQrCodesScanPayPdf.bind(this), ServerAction.CHARGING_STATION_DOWNLOAD_QR_CODE_SCAN_PAY_PDF, req, res, next); + }); + } + private buildRouteChargingStationGetOCPPParameters(): void { this.router.get(`/${RESTServerRoute.REST_CHARGING_STATION_GET_OCPP_PARAMETERS}`, (req: Request, res: Response, next: NextFunction) => { req.query.ChargingStationID = req.params.id; diff --git a/src/server/rest/v1/router/auth/AuthRouter.ts b/src/server/rest/v1/router/auth/AuthRouter.ts index 247cc1c232..aa79329dc1 100644 --- a/src/server/rest/v1/router/auth/AuthRouter.ts +++ b/src/server/rest/v1/router/auth/AuthRouter.ts @@ -21,6 +21,7 @@ export default class AuthRouter { this.buildRouteEndUserLicenseAgreement(); this.buildRouteEndUserLicenseAgreementHtml(); this.buildRouteEndUserLicenseAgreementCheck(); + this.buildRouteVerifyScanPayEmail(); return this.router; } @@ -60,6 +61,13 @@ export default class AuthRouter { }); } + protected buildRouteVerifyScanPayEmail(): void { + this.router.post(`/${RESTServerRoute.REST_SCAN_PAY_VERIFY_EMAIL}`, (req: Request, res: Response, next: NextFunction) => { + // Step #0 - Create user and send verification email + void RouterUtils.handleRestServerAction(AuthService.handleScanPayVerifyEmail.bind(this), ServerAction.VERIFY_EMAIL, req, res, next); + }); + } + protected buildRouteEndUserLicenseAgreement(): void { this.router.get(`/${RESTServerRoute.REST_END_USER_LICENSE_AGREEMENT}`, (req: Request, res: Response, next: NextFunction) => { void RouterUtils.handleRestServerAction(AuthService.handleGetEndUserLicenseAgreement.bind(this), ServerAction.END_USER_LICENSE_AGREEMENT, req, res, next); diff --git a/src/server/rest/v1/service/AuthService.ts b/src/server/rest/v1/service/AuthService.ts index 51aa07dc8d..ddf73e7925 100644 --- a/src/server/rest/v1/service/AuthService.ts +++ b/src/server/rest/v1/service/AuthService.ts @@ -1,11 +1,12 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { Handler, NextFunction, Request, RequestHandler, Response } from 'express'; -import { HttpLoginRequest, HttpResetPasswordRequest } from '../../../../types/requests/HttpUserRequest'; +import { HttpLoginRequest, HttpResetPasswordRequest, HttpScanPayVerifyEmailRequest } from '../../../../types/requests/HttpUserRequest'; import User, { UserRole, UserStatus } from '../../../../types/User'; import AppError from '../../../../exception/AppError'; import AuthValidatorRest from '../validator/AuthValidatorRest'; import Authorizations from '../../../../authorization/Authorizations'; +import BillingService from './BillingService'; import Configuration from '../../../../utils/Configuration'; import Constants from '../../../../utils/Constants'; import { Details } from 'express-useragent'; @@ -278,6 +279,44 @@ export default class AuthService { next(); } + public static async checkAndSendVerifyScanPayEmail(tenant: Tenant, filteredRequest: HttpScanPayVerifyEmailRequest, action: ServerAction, req: Request, + res: Response, next: NextFunction): Promise { + if (!filteredRequest.captcha) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: 'The captcha is mandatory', + module: MODULE_NAME, + method: 'checkAndSendVerifyScanPayEmail' + }); + } + // Check reCaptcha + await UtilsService.checkReCaptcha(tenant, action, 'checkAndSendVerifyScanPayEmail', + centralSystemRestConfig, filteredRequest.captcha, req.connection.remoteAddress); + const tag = await BillingService.handleUserScanPay(filteredRequest, tenant); + await Logging.logInfo({ + tenantID: tenant.id, + user: tag.user, action: action, + module: MODULE_NAME, + method: 'checkAndSendVerifyScanPayEmail', + message: `User with Email '${req.body.email as string}' will receive an email to verify his email` + }); + // Send notification + const evseDashboardVerifyScanPayEmailURL = Utils.buildEvseURL(req.tenant.subdomain) + '/auth/scan-pay?VerificationToken=' + tag.user.verificationToken + '&email=' + encodeURIComponent(tag.user.email) + '&chargingStationID=' + filteredRequest.chargingStationID + '&connectorID=' + filteredRequest.connectorID; + // Notify + await NotificationHandler.sendScanPayVerifyEmail( + tenant, + Utils.generateUUID(), + tag.user, + { + user: tag.user, + 'evseDashboardURL': Utils.buildEvseURL(req.tenant.subdomain), + 'evseDashboardVerifyScanPayEmailURL': evseDashboardVerifyScanPayEmailURL + } + ); + res.json(Constants.REST_RESPONSE_SUCCESS); + next(); + } + public static async resetUserPassword(tenant: Tenant, filteredRequest: Partial, action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { // Get the user @@ -332,6 +371,21 @@ export default class AuthService { } } + public static async handleScanPayVerifyEmail(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { + // Check Tenant + if (!req.tenant) { + throw new AppError({ + errorCode: StatusCodes.BAD_REQUEST, + message: 'Tenant must be provided', + module: MODULE_NAME, method: 'handleScanPayVerifyEmail', action: action, + }); + } + // Filter + const filteredRequest = AuthValidatorRest.getInstance().validateScanPayVerifyEmailReq(req.body); + // Send verification email to validae email is valid + await AuthService.checkAndSendVerifyScanPayEmail(req.tenant, filteredRequest, action, req, res, next); + } + public static async handleCheckEndUserLicenseAgreement(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { // Check Tenant if (!req.tenant) { diff --git a/src/server/rest/v1/service/AuthorizationService.ts b/src/server/rest/v1/service/AuthorizationService.ts index 662355b388..36f4e57a07 100644 --- a/src/server/rest/v1/service/AuthorizationService.ts +++ b/src/server/rest/v1/service/AuthorizationService.ts @@ -5,7 +5,7 @@ import { Car, CarCatalog } from '../../../../types/Car'; import { ChargePointStatus, OCPPProtocol, OCPPVersion } from '../../../../types/ocpp/OCPPServer'; import ChargingStation, { ChargingStationTemplate } from '../../../../types/ChargingStation'; import { HttpAssetGetRequest, HttpAssetsGetRequest } from '../../../../types/requests/HttpAssetRequest'; -import { HttpBillingAccountGetRequest, HttpBillingAccountsGetRequest, HttpBillingInvoiceRequest, HttpBillingInvoicesRequest, HttpBillingTransferGetRequest, HttpBillingTransfersGetRequest, HttpDeletePaymentMethod, HttpPaymentMethods, HttpSetupPaymentMethod } from '../../../../types/requests/HttpBillingRequest'; +import { HttpBillingAccountGetRequest, HttpBillingAccountsGetRequest, HttpBillingInvoiceRequest, HttpBillingInvoicesRequest, HttpBillingScanPayRequest, HttpBillingTransferGetRequest, HttpBillingTransfersGetRequest, HttpDeletePaymentMethod, HttpPaymentMethods, HttpSetupPaymentMethod } from '../../../../types/requests/HttpBillingRequest'; import { HttpCarCatalogGetRequest, HttpCarCatalogsGetRequest, HttpCarGetRequest, HttpCarsGetRequest } from '../../../../types/requests/HttpCarRequest'; import { HttpChargingProfileRequest, HttpChargingProfilesGetRequest, HttpChargingStationGetRequest, HttpChargingStationsGetRequest } from '../../../../types/requests/HttpChargingStationRequest'; import { HttpChargingStationTemplateGetRequest, HttpChargingStationTemplatesGetRequest } from '../../../../types/requests/HttpChargingStationTemplateRequest'; @@ -94,6 +94,8 @@ export default class AuthorizationService { tenant, userToken, Entity.SITE_AREA, Action.EXPORT_OCPP_PARAMS, authorizationFilter, { SiteID: site.id }, site); site.canGenerateQrCode = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.SITE_AREA, Action.GENERATE_QR, authorizationFilter, { SiteID: site.id }, site); + site.canGenerateQrCodeScanPay = await AuthorizationService.canPerformAuthorizationAction( + tenant, userToken, Entity.SITE_AREA, Action.GENERATE_QR_SCAN_PAY, authorizationFilter, { SiteID: site.id }, site); site.canAssignUnassignUsers = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.SITE, Action.ASSIGN_UNASSIGN_USERS, authorizationFilter, { SiteID: site.id }, site); site.canListSiteUsers = await AuthorizationService.canPerformAuthorizationAction( @@ -671,6 +673,8 @@ export default class AuthorizationService { tenant, userToken, Entity.SITE_AREA, Action.EXPORT_OCPP_PARAMS, authorizationFilter, { SiteAreaID: siteArea.id, SiteID: siteArea.siteID }, siteArea); siteArea.canGenerateQrCode = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.SITE_AREA, Action.GENERATE_QR, authorizationFilter, { SiteAreaID: siteArea.id, SiteID: siteArea.siteID }, siteArea); + siteArea.canGenerateQrCodeScanPay = await AuthorizationService.canPerformAuthorizationAction( + tenant, userToken, Entity.SITE_AREA, Action.GENERATE_QR_SCAN_PAY, authorizationFilter, { SiteAreaID: siteArea.id, SiteID: siteArea.siteID }, siteArea); // Optimize data over the net Utils.removeCanPropertiesWithFalseValue(siteArea); } @@ -795,6 +799,9 @@ export default class AuthorizationService { chargingStation.canDelete = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.CHARGING_STATION, Action.DELETE, authorizationFilter, { chargingStationID: chargingStation.id, SiteID: chargingStation.siteID }, chargingStation); + chargingStation.canGetConnectorQRCodeScanPay = await AuthorizationService.canPerformAuthorizationAction( + tenant, userToken, Entity.CHARGING_STATION, Action.GET_CONNECTOR_QR_CODE_SCAN_PAY, authorizationFilter, + { chargingStationID: chargingStation.id, SiteID: chargingStation.siteID }, chargingStation); chargingStation.canReserveNow = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.CHARGING_STATION, Action.RESERVE_NOW, authorizationFilter, { chargingStationID: chargingStation.id, SiteID: chargingStation.siteID }, chargingStation); @@ -849,6 +856,9 @@ export default class AuthorizationService { chargingStation.canGenerateQrCode = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.CHARGING_STATION, Action.GENERATE_QR, authorizationFilter, { chargingStationID: chargingStation.id, SiteID: chargingStation.siteID }, chargingStation); + chargingStation.canGenerateQrCodeScanPay = await AuthorizationService.canPerformAuthorizationAction( + tenant, userToken, Entity.CHARGING_STATION, Action.GENERATE_QR_SCAN_PAY, authorizationFilter, + { chargingStationID: chargingStation.id, SiteID: chargingStation.siteID }, chargingStation); chargingStation.canMaintainPricingDefinitions = await AuthorizationService.canPerformAuthorizationAction( tenant, userToken, Entity.CHARGING_STATION, Action.MAINTAIN_PRICING_DEFINITIONS, authorizationFilter, { chargingStationID: chargingStation.id, SiteID: chargingStation.siteID }, chargingStation); @@ -1092,6 +1102,13 @@ export default class AuthorizationService { return authorizations; } + // EntityData is not usable here, the object is returned via external API call + public static async checkAndGetPaymentIntentAuthorizations(tenant: Tenant, userToken: UserToken, + filteredRequest: Partial, authAction: Action, entityData?: EntityData): Promise { + return AuthorizationService.checkAndGetEntityAuthorizations( + tenant, Entity.PAYMENT_INTENT, userToken, filteredRequest, {}, authAction, entityData); + } + // EntityData is not usable here, the object is returned via external API call public static async checkAndGetPaymentMethodAuthorizations(tenant: Tenant, userToken: UserToken, filteredRequest: Partial, authAction: Action, entityData?: EntityData): Promise { diff --git a/src/server/rest/v1/service/BillingService.ts b/src/server/rest/v1/service/BillingService.ts index 0681971ef9..1ee62e2052 100644 --- a/src/server/rest/v1/service/BillingService.ts +++ b/src/server/rest/v1/service/BillingService.ts @@ -1,18 +1,25 @@ import { Action, Entity } from '../../../../types/Authorization'; import { BillingAccount, BillingAccountStatus, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingTransferStatus } from '../../../../types/Billing'; import { BillingInvoiceDataResult, BillingPaymentMethodDataResult, BillingTaxDataResult } from '../../../../types/DataResult'; +import { BillingSettings, ScanPaySettings } from '../../../../types/Setting'; +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { NextFunction, Request, Response } from 'express'; +import Tenant, { TenantComponents } from '../../../../types/Tenant'; +import User, { UserRole, UserStatus } from '../../../../types/User'; import AppError from '../../../../exception/AppError'; import AuthorizationService from './AuthorizationService'; import { BillingAccountCreationLinkNotification } from '../../../../types/UserNotifications'; import BillingFactory from '../../../../integration/billing/BillingFactory'; import BillingSecurity from './security/BillingSecurity'; -import { BillingSettings } from '../../../../types/Setting'; import BillingStorage from '../../../../storage/mongodb/BillingStorage'; import BillingValidatorRest from '../validator/BillingValidatorRest'; +import ChargingStationService from './ChargingStationService'; +import Configuration from '../../../../utils/Configuration'; import Constants from '../../../../utils/Constants'; import { HTTPError } from '../../../../types/HTTPError'; +import { HttpScanPayVerifyEmailRequest } from '../../../../types/requests/HttpUserRequest'; +import I18nManager from '../../../../utils/I18nManager'; import LockingHelper from '../../../../locking/LockingHelper'; import LockingManager from '../../../../locking/LockingManager'; import Logging from '../../../../utils/Logging'; @@ -20,14 +27,15 @@ import NotificationHandler from '../../../../notification/NotificationHandler'; import { ServerAction } from '../../../../types/Server'; import SettingStorage from '../../../../storage/mongodb/SettingStorage'; import { StatusCodes } from 'http-status-codes'; -import { TenantComponents } from '../../../../types/Tenant'; -import User from '../../../../types/User'; +import Tag from '../../../../types/Tag'; +import TagStorage from '../../../../storage/mongodb/TagStorage'; import UserStorage from '../../../../storage/mongodb/UserStorage'; import Utils from '../../../../utils/Utils'; import UtilsService from './UtilsService'; const MODULE_NAME = 'BillingService'; +const centralSystemRestConfig = Configuration.getCentralSystemRestServiceConfig(); export default class BillingService { public static async handleClearBillingTestData(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { @@ -260,8 +268,169 @@ export default class BillingService { // Check and get user for whom we wish to update the payment method const user: User = await UtilsService.checkAndGetUserAuthorization(req.tenant, req.user, filteredRequest.userID, Action.READ, action); // Invoke the billing implementation - const paymentMethodId: string = filteredRequest.paymentMethodId; - const operationResult: BillingOperationResult = await billingImpl.setupPaymentMethod(user, paymentMethodId); + const operationResult: BillingOperationResult = await billingImpl.setupPaymentMethod(user, filteredRequest.paymentMethodID); + if (operationResult) { + Utils.isDevelopmentEnv() && Logging.logConsoleError(operationResult as unknown as string); + } + res.json(operationResult); + next(); + } + + // Called a Step #1 + public static async handleScanPayPaymentIntentSetup(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { + const filteredRequest = BillingValidatorRest.getInstance().validateBillingScanPayPaymentReq(req.body); + UtilsService.assertComponentIsActiveFromToken(req.user, TenantComponents.SCAN_PAY, + Action.SETUP, Entity.PAYMENT_INTENT, MODULE_NAME, 'handleScanPayPaymentIntentSetup'); + // Dynamic auth + await AuthorizationService.checkAndGetPaymentIntentAuthorizations(req.tenant, req.user, filteredRequest, Action.SETUP); + // Virtual user tag + let tag: Tag; + // Check if the user exist + const foundUser = await UserStorage.getUserByEmail(req.tenant, filteredRequest.email); + if (foundUser) { + tag = await TagStorage.getDefaultUserTag(req.tenant, foundUser.id); + if (!tag) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: `User '${foundUser.id}' does not have any badge`, + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + user: foundUser + }); + } + tag.user = foundUser; + } else { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: `User '${filteredRequest.email}' does not exist`, + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + }); + } + // Check verificationToken + if (foundUser.verifiedAt || foundUser.verificationToken !== filteredRequest.verificationToken) { + throw new AppError({ + errorCode: HTTPError.INVALID_TOKEN_ERROR, + action: action, + user: foundUser, + module: MODULE_NAME, method: 'handleScanPayPaymentIntentSetup', + message: 'Wrong Verification Token, cannot verify email' + }); + } + // Save User Verification Account + await UserStorage.saveUserAccountVerification(req.tenant, foundUser.id, + { verifiedAt: new Date() }); + // Generate a password from verification token and save it + const password = await Utils.hashPasswordBcrypt(filteredRequest.verificationToken); + await UserStorage.saveUserPassword(req.tenant, foundUser.id, { password }); + // Filter + const billingImpl = await BillingFactory.getBillingImpl(req.tenant); + if (!billingImpl) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: 'Billing service is not configured', + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + action: action, + user: req.user + }); + } + const scanPaySettings: ScanPaySettings = await UtilsService.checkAndGetScanPaySettingAuthorization(req.tenant, req.user, null, Action.READ, action); + const pricingSettings = await SettingStorage.getPricingSettings(req.tenant); + if (!scanPaySettings || !pricingSettings) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: 'Scan Pay or Pricing is not configured', + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + action: action, + user: req.user + }); + } + const operationResult: BillingOperationResult = await billingImpl.setupPaymentIntent( + tag.user, + filteredRequest.paymentIntentID, + scanPaySettings.scanPay.amount, + pricingSettings.simple.currency + ); + if (operationResult) { + Utils.isDevelopmentEnv() && Logging.logConsoleError(operationResult as unknown as string); + } + res.json(operationResult); + next(); + } + + // Called at Step #2 + public static async handleScanPayPaymentIntentRetrieve(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { + const filteredRequest = BillingValidatorRest.getInstance().validateBillingScanPayPaymentReq(req.body); + // Dynamic auth + await AuthorizationService.checkAndGetPaymentIntentAuthorizations(req.tenant, req.user, filteredRequest, Action.RETRIEVE); + // Get charging station to check connector availablity + const chargingStation = await UtilsService.checkAndGetChargingStationAuthorization( + req.tenant, req.user, filteredRequest.chargingStationID, Action.READ, action, null, {}, true); + const connector = chargingStation.connectors.find((connector) => connector.connectorId === filteredRequest.connectorID); + if (!connector.canRemoteStartTransaction) { + // catch connector is not available + throw new AppError({ + errorCode: HTTPError.CANNOT_REMOTE_START_CONNECTOR, + module: MODULE_NAME, method: 'handleScanPayPaymentIntentRetrieve', + message: 'Connector is not available' + }); + } + let tag: Tag; + // Check if the user exist + const foundUser = await UserStorage.getUserByEmail(req.tenant, filteredRequest.email); + if (foundUser) { + tag = await TagStorage.getDefaultUserTag(req.tenant, foundUser.id); + if (!tag) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: `User '${foundUser.id}' does not have any badge`, + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + user: foundUser + }); + } + tag.user = foundUser; + } else { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: `User '${filteredRequest.email}' does not exist`, + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + }); + } + const match = await Utils.checkPasswordBCrypt(filteredRequest.verificationToken, foundUser.password); + if (!match) { + throw new AppError({ + errorCode: HTTPError.INVALID_TOKEN_ERROR, + action: action, + user: foundUser, + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + message: 'Wrong Verification Token, cannot verify email' + }); + } + // Filter + const billingImpl = await BillingFactory.getBillingImpl(req.tenant); + if (!billingImpl) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: 'Billing service is not configured', + module: MODULE_NAME, method: 'handleScanPayPaymentIntent', + action: action, + user: req.user + }); + } + const operationResult: BillingOperationResult = await billingImpl.setupPaymentIntent(tag.user, filteredRequest.paymentIntentID); + if (operationResult.internalData['status'] === 'requires_capture') { + // Save last Payment Intent ID to store it in transaction + tag.user.lastPaymentIntentID = filteredRequest.paymentIntentID; + await UserStorage.saveUser(req.tenant, tag.user); + // Execute start transaction + req.body = { + args: { + tagID: tag.id, + connectorId: filteredRequest.connectorID + }, + chargingStationID: filteredRequest.chargingStationID, + userID: foundUser.id + }; + await ChargingStationService.handleOcppAction(ServerAction.CHARGING_STATION_REMOTE_START_TRANSACTION, req, res, next); + } if (operationResult) { Utils.isDevelopmentEnv() && Logging.logConsoleError(operationResult as unknown as string); } @@ -439,6 +608,15 @@ export default class BillingService { next(); } + public static async handleGetScanPaySetting(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { + // Check if component is active + UtilsService.assertComponentIsActiveFromToken(req.user, TenantComponents.BILLING, + Action.READ, Entity.SETTING, MODULE_NAME, 'handleGetScanPaySetting'); + const scanPaySettings: ScanPaySettings = await UtilsService.checkAndGetScanPaySettingAuthorization(req.tenant, req.user, null, Action.READ, action); + res.json(scanPaySettings); + next(); + } + public static async handleUpdateBillingSetting(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { // Check if component is active UtilsService.assertComponentIsActiveFromToken(req.user, TenantComponents.BILLING, @@ -905,6 +1083,71 @@ export default class BillingService { next(); } + public static async handleUserScanPay(filteredRequest: HttpScanPayVerifyEmailRequest, tenant: Tenant): Promise { + const locale = filteredRequest.locale || Constants.DEFAULT_LOCALE; + // Prepare the User + const newUser = UserStorage.createNewUser(); + const verificationToken = Utils.generateToken(filteredRequest.email); + const aliasEmail = Utils.buildAliasEmail(filteredRequest.email); + const user = { + ...newUser, + name: filteredRequest.name, + firstName: filteredRequest.firstName, + email: aliasEmail, + locale, + verificationToken, + role: UserRole.EXTERNAL, + } as User; + // Create the User + user.id = await UserStorage.saveUser(tenant, user, true); + // Save User Status + await UserStorage.saveUserStatus(tenant, user.id, UserStatus.ACTIVE); + // Save User Role + await UserStorage.saveUserRole(tenant, user.id, UserRole.EXTERNAL); + const endUserLicenseAgreement = await UserStorage.getEndUserLicenseAgreement(tenant, Utils.getLanguageFromLocale(newUser.locale)); + // Save User EULA + await UserStorage.saveUserEULA(tenant, newUser.id, { + eulaAcceptedOn: new Date(), + eulaAcceptedVersion: endUserLicenseAgreement.version, + eulaAcceptedHash: endUserLicenseAgreement.hash + }); + const newPasswordHashed = await Utils.hashPasswordBcrypt(verificationToken); + // Save User password + await UserStorage.saveUserPassword(tenant, newUser.id, { + password: newPasswordHashed, + passwordWrongNbrTrials: 0, + passwordResetHash: null, + passwordBlockedUntil: null + }); + // Assign user to all Sites with auto-assign flag + await UtilsService.assignCreatedUserToSites(tenant, newUser as User); + // Get the i18n translation class + const i18nManager = I18nManager.getInstanceForLocale(locale); + // Create tag for the user + const tag: Tag = { + id: await TagStorage.findAvailableID(tenant), + active: true, + issuer: true, + userID: newUser.id, + createdBy: { id: newUser.id }, + createdOn: new Date(), + description: i18nManager.translate('tags.virtualBadge'), + default: true + }; + // Save the default Tag + await TagStorage.saveTag(tenant, tag); + tag.user = user; + // Log + await Logging.logInfo({ + tenantID: tenant.id, + user: user, actionOnUser: user, + module: MODULE_NAME, method: 'handleUserScanAndPay', + message: `User with ID '${user.id}' has been created successfully`, + action: ServerAction.SCAN_PAY_PAYMENT_INTENT_SETUP + }); + return tag; + } + private static async checkActivationPrerequisites(action: ServerAction, req: Request): Promise { const billingImpl = await BillingFactory.getBillingImpl(req.tenant); if (!billingImpl) { diff --git a/src/server/rest/v1/service/ChargingStationService.ts b/src/server/rest/v1/service/ChargingStationService.ts index ef0e856f7a..31f0f92504 100644 --- a/src/server/rest/v1/service/ChargingStationService.ts +++ b/src/server/rest/v1/service/ChargingStationService.ts @@ -282,6 +282,25 @@ export default class ChargingStationService { next(); } + public static async handleGenerateQrCodeForConnectorScanPay(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { + UtilsService.assertComponentIsActiveFromToken(req.user, TenantComponents.SCAN_PAY, + Action.GET_CONNECTOR_QR_CODE_SCAN_PAY, Entity.CHARGING_STATION, MODULE_NAME, 'handleGenerateQrCodeForConnectorScanPay'); + // Filter + const filteredRequest = ChargingStationValidatorRest.getInstance().validateChargingStationQRCodeGenerateReq(req.query); + // Check dynamic auth + const chargingStation = await UtilsService.checkAndGetChargingStationAuthorization(req.tenant, req.user, + filteredRequest.ChargingStationID, Action.GET_CONNECTOR_QR_CODE_SCAN_PAY, action); + // Found Connector ? + UtilsService.assertObjectExists(action, Utils.getConnectorFromID(chargingStation, filteredRequest.ConnectorID), + `Connector ID '${filteredRequest.ConnectorID}' does not exist`, + MODULE_NAME, 'handleGenerateQrCodeForConnectorScanPay', req.user); + const scanPayConnectorURL = Utils.buildEvseScanPayConnectorURL(req.tenant.subdomain, chargingStation, filteredRequest.ConnectorID); + // Generate + const generatedQR = await Utils.generateQrCode(scanPayConnectorURL); + res.json({ image: generatedQR }); + next(); + } + public static async handleCreateChargingProfile(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { // Filter const filteredRequest = ChargingStationValidatorRest.getInstance().validateChargingProfileCreateReq(req.body); @@ -498,6 +517,17 @@ export default class ChargingStationService { ChargingStationService.convertQrCodeToPDF.bind(this)); } + public static async handleDownloadQrCodesScanPayPdf(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { + UtilsService.assertComponentIsActiveFromToken(req.user, TenantComponents.SCAN_PAY, + Action.GET_CONNECTOR_QR_CODE_SCAN_PAY, Entity.CHARGING_STATION, MODULE_NAME, 'handleDownloadQrCodesScanPayPdf'); + // Filter + const filteredRequest = ChargingStationValidatorRest.getInstance().validateChargingStationQRCodeDownloadReq(req.query); + // Export + await UtilsService.exportToPDF(req, res, 'exported-charging-stations-qr-code-scan-pay.pdf', + ChargingStationService.getChargingStations.bind(this, req, filteredRequest, Action.GENERATE_QR_SCAN_PAY), + ChargingStationService.convertQrCodeScanPayToPDF.bind(this)); + } + public static async handleGetChargingStationsInError(action: ServerAction, req: Request, res: Response, next: NextFunction): Promise { // Filter const filteredRequest = ChargingStationValidatorRest.getInstance().validateChargingStationInErrorReq(req.query); @@ -1113,6 +1143,39 @@ export default class ChargingStationService { } } + private static async convertQrCodeScanPayToPDF(req: Request, pdfDocument: PDFKit.PDFDocument, chargingStations: ChargingStation[]): Promise { + const i18nManager = I18nManager.getInstanceForLocale(req.user.locale); + // Check for Connector ID + let connectorID = null; + if (req.query.ConnectorID) { + connectorID = Utils.convertToInt(req.query.ConnectorID); + } + // Content + for (const chargingStation of chargingStations) { + if (!Utils.isEmptyArray(chargingStation.connectors)) { + for (const connector of chargingStation.connectors) { + // Filter on connector ID? + if (connectorID > 0 && connector.connectorId !== connectorID) { + continue; + } + // Create data + const scanPayConnectorURL = Utils.buildEvseScanPayConnectorURL(req.tenant.subdomain, chargingStation, connector.connectorId); + // Generated QR-Code + const qrCodeImage = await Utils.generateQrCode(scanPayConnectorURL); + // Build title + const qrCodeTitle = `${chargingStation.id} / ${i18nManager.translate('chargers.connector')} ${Utils.getConnectorLetterFromConnectorID(connector.connectorId)}`; + // Add the QR Codes + ChargingStationService.build3SizesPDFQrCode(pdfDocument, qrCodeImage, qrCodeTitle); + // Add page (expect the last one) + if (!connectorID && (chargingStations[chargingStations.length - 1] !== chargingStation || + chargingStation.connectors[chargingStation.connectors.length - 1] !== connector)) { + pdfDocument.addPage(); + } + } + } + } + } + private static build3SizesPDFQrCode(pdfDocument: PDFKit.PDFDocument, qrCodeImage: string, qrCodeTitle: string): void { const bigSquareSide = 300; const mediumSquareSide = 150; diff --git a/src/server/rest/v1/service/SettingService.ts b/src/server/rest/v1/service/SettingService.ts index d585d7fb8a..945a349d97 100644 --- a/src/server/rest/v1/service/SettingService.ts +++ b/src/server/rest/v1/service/SettingService.ts @@ -279,6 +279,8 @@ export default class SettingService { return SettingValidatorRest.getInstance().validateSettingOrganizationSetReq(req.body); case IntegrationSettings.STATISTICS: return SettingValidatorRest.getInstance().validateSettingStatisticsSetReq(req.body); + case IntegrationSettings.SCAN_PAY: + return SettingValidatorRest.getInstance().validateSettingScanPaySetReq(req.body); default: throw new AppError({ module: MODULE_NAME, diff --git a/src/server/rest/v1/service/UtilsService.ts b/src/server/rest/v1/service/UtilsService.ts index 5fc97750f7..82284a76a5 100644 --- a/src/server/rest/v1/service/UtilsService.ts +++ b/src/server/rest/v1/service/UtilsService.ts @@ -1,6 +1,6 @@ import { Action, AuthorizationFilter, Entity } from '../../../../types/Authorization'; import { BillingAccount, BillingInvoice, BillingTransfer } from '../../../../types/Billing'; -import { BillingSettings, SettingDB } from '../../../../types/Setting'; +import { BillingSettings, ScanPaySettings, SettingDB } from '../../../../types/Setting'; import { Car, CarCatalog } from '../../../../types/Car'; import ChargingStation, { ChargePoint, ChargingStationTemplate, Command } from '../../../../types/ChargingStation'; import { EntityData, URLInfo } from '../../../../types/GlobalType'; @@ -931,6 +931,29 @@ export default class UtilsService { return billingSetting; } + public static async checkAndGetScanPaySettingAuthorization(tenant: Tenant, userToken: UserToken, scanPaySettingID: string, authAction: Action, + action: ServerAction, entityData?: EntityData): Promise { + // Get dynamic auth + const authorizations = await AuthorizationService.checkAndGetSettingAuthorizations(tenant, userToken, { ID: scanPaySettingID }, authAction, entityData); + // Get the entity from storage + const scanPaySetting = await SettingStorage.getScanPaySettings(tenant); + // Check it exists + UtilsService.assertObjectExists(action, scanPaySetting, `Scan & Pay setting for tenantID '${tenant.id}' does not exist`, + MODULE_NAME, 'checkAndGetScanPaySettingAuthorization', userToken); + // Add actions + await AuthorizationService.addSettingAuthorizations(tenant, userToken, scanPaySetting, authorizations); + const authorized = AuthorizationService.canPerformAction(scanPaySetting, authAction); + if (!authorized) { + throw new AppAuthError({ + errorCode: HTTPAuthError.FORBIDDEN, + user: userToken, + action: authAction, entity: Entity.SETTING, + module: MODULE_NAME, method: 'checkAndGetBillingSettingAuthorization', + }); + } + return scanPaySetting; + } + public static async checkAndGetInvoiceAuthorization(tenant: Tenant, userToken: UserToken, invoiceID: string, authAction: Action, action: ServerAction, entityData?: EntityData, additionalFilters: Record = {}, applyProjectFields = false): Promise { // Check mandatory fields @@ -1654,7 +1677,6 @@ export default class UtilsService { } } - // eslint-disable-next-line @typescript-eslint/ban-types public static hashSensitiveData(tenantID: string, properties: object): unknown { const sensitivePropertyNames: string [] = _.get(properties, 'sensitiveData'); if (sensitivePropertyNames) { diff --git a/src/server/rest/v1/validator/AuthValidatorRest.ts b/src/server/rest/v1/validator/AuthValidatorRest.ts index b332c01b83..58358c5b82 100644 --- a/src/server/rest/v1/validator/AuthValidatorRest.ts +++ b/src/server/rest/v1/validator/AuthValidatorRest.ts @@ -1,4 +1,4 @@ -import { HttpCheckEulaRequest, HttpEulaRequest, HttpLoginRequest, HttpRegisterUserRequest, HttpResendVerificationMailRequest, HttpResetPasswordRequest, HttpVerifyEmailRequest } from '../../../../types/requests/HttpUserRequest'; +import { HttpCheckEulaRequest, HttpEulaRequest, HttpLoginRequest, HttpRegisterUserRequest, HttpResendVerificationMailRequest, HttpResetPasswordRequest, HttpScanPayVerifyEmailRequest, HttpVerifyEmailRequest } from '../../../../types/requests/HttpUserRequest'; import Schema from '../../../../types/validator/Schema'; import SchemaValidator from '../../../../validator/SchemaValidator'; @@ -14,6 +14,7 @@ export default class AuthValidatorRest extends SchemaValidator { private authEmailVerify: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/auth/auth-email-verify.json`, 'utf8')); private authVerificationEmailResend: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/auth/auth-verification-email-resend.json`, 'utf8')); private authEula: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/auth/auth-eula.json`, 'utf8')); + private authScanPayVerifyEmail: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/auth/auth-scan-pay-verify-email.json`, 'utf8')); private constructor() { super('AuthValidatorRest'); @@ -53,4 +54,9 @@ export default class AuthValidatorRest extends SchemaValidator { public validateAuthEulaReq(data: Record): Partial { return this.validate(this.authEula, data); } + + // Called at step #0 from unsecured "enter your email" UI + public validateScanPayVerifyEmailReq(data: Record): HttpScanPayVerifyEmailRequest { + return this.validate(this.authScanPayVerifyEmail, data); + } } diff --git a/src/server/rest/v1/validator/BillingValidatorRest.ts b/src/server/rest/v1/validator/BillingValidatorRest.ts index dfbe63d80d..93536894fb 100644 --- a/src/server/rest/v1/validator/BillingValidatorRest.ts +++ b/src/server/rest/v1/validator/BillingValidatorRest.ts @@ -1,4 +1,4 @@ -import { HttpBillingAccountActivateRequest, HttpBillingAccountCreateRequest, HttpBillingAccountGetRequest, HttpBillingAccountsGetRequest, HttpBillingInvoiceRequest, HttpBillingInvoicesRequest, HttpBillingTransferFinalizeRequest, HttpBillingTransferGetRequest, HttpBillingTransferSendRequest, HttpBillingTransfersGetRequest, HttpDeletePaymentMethod, HttpPaymentMethods, HttpSetupPaymentMethod } from '../../../../types/requests/HttpBillingRequest'; +import { HttpBillingAccountActivateRequest, HttpBillingAccountCreateRequest, HttpBillingAccountGetRequest, HttpBillingAccountsGetRequest, HttpBillingInvoiceRequest, HttpBillingInvoicesRequest, HttpBillingScanPayRequest, HttpBillingScanPayStopTransactionRequest, HttpBillingScanPayTransactionRequest, HttpBillingTransferFinalizeRequest, HttpBillingTransferGetRequest, HttpBillingTransferSendRequest, HttpBillingTransfersGetRequest, HttpDeletePaymentMethod, HttpPaymentMethods, HttpSetupPaymentMethod } from '../../../../types/requests/HttpBillingRequest'; import { BillingSettings } from '../../../../types/Setting'; import Schema from '../../../../types/validator/Schema'; @@ -22,6 +22,7 @@ export default class BillingValidatorRest extends SchemaValidator { private billingTransferGet: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/billing/billing-transfer-get.json`, 'utf8')); private billingTransferFinalize: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/billing/billing-transfer-finalize.json`, 'utf8')); private billingTransferSend: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/billing/billing-transfer-send.json`, 'utf8')); + private billingScanPay: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/billing/billing-scan-pay.json`, 'utf8')); private constructor() { super('BillingValidatorRest'); @@ -89,4 +90,9 @@ export default class BillingValidatorRest extends SchemaValidator { public validateBillingTransferSendReq(data: Record): HttpBillingTransferSendRequest { return this.validate(this.billingTransferSend, data); } + + // Called at step #1 and step #2 when user enter card details to start transaction + public validateBillingScanPayPaymentReq(data: Record): HttpBillingScanPayRequest { + return this.validate(this.billingScanPay, data); + } } diff --git a/src/server/rest/v1/validator/SettingValidatorRest.ts b/src/server/rest/v1/validator/SettingValidatorRest.ts index 596633d68b..f6b9a3a5b4 100644 --- a/src/server/rest/v1/validator/SettingValidatorRest.ts +++ b/src/server/rest/v1/validator/SettingValidatorRest.ts @@ -26,6 +26,7 @@ export default class SettingValidatorRest extends SchemaValidator { private settingDelete = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/setting/setting-delete.json`, 'utf8')); private settingByIdentifierGet = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/setting/setting-by-identifier-get.json`, 'utf8')); private settingsGet = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/setting/settings-get.json`, 'utf8')); + private settingScanPaySet: Schema = JSON.parse(fs.readFileSync(`${global.appRoot}/assets/server/rest/v1/schemas/setting/setting-scan-pay-set.json`, 'utf8')); private constructor() { super('SettingValidatorRest'); @@ -113,4 +114,8 @@ export default class SettingValidatorRest extends SchemaValidator { public validateSettingStatisticsSetReq(data: Record): HttpSettingUpdateRequest { return this.validate(this.settingStatisticsSet, data); } + + public validateSettingScanPaySetReq(data: Record): HttpSettingUpdateRequest { + return this.validate(this.settingScanPaySet, data); + } } diff --git a/src/server/rest/v1/validator/TenantValidatorRest.ts b/src/server/rest/v1/validator/TenantValidatorRest.ts index a673cf3f87..1479fd219c 100644 --- a/src/server/rest/v1/validator/TenantValidatorRest.ts +++ b/src/server/rest/v1/validator/TenantValidatorRest.ts @@ -119,6 +119,15 @@ export default class TenantValidatorRest extends SchemaValidator { module: this.moduleName, method: 'validateComponentDependencies' }); } + // Scan & Pay active: Billing and Pricing must be active + if (tenant.components.billing && tenant.components.pricing && tenant.components.scanPay && + tenant.components.scanPay.active && (!tenant.components.billing.active || !tenant.components.pricing.active)) { + throw new AppError({ + errorCode: HTTPError.GENERAL_ERROR, + message: 'Pricing and Billing must be active to use the Scan & Pay component', + module: this.moduleName, method: 'validateComponentDependencies' + }); + } } } } diff --git a/src/storage/mongodb/SettingStorage.ts b/src/storage/mongodb/SettingStorage.ts index 534633672a..495ceb5749 100644 --- a/src/storage/mongodb/SettingStorage.ts +++ b/src/storage/mongodb/SettingStorage.ts @@ -1,4 +1,4 @@ -import { AnalyticsSettings, AnalyticsSettingsType, AssetSettings, AssetSettingsType, BillingSetting, BillingSettings, BillingSettingsType, CarConnectorSettings, CarConnectorSettingsType, CryptoSetting, CryptoSettings, CryptoSettingsType, PricingSettings, PricingSettingsType, RefundSettings, RefundSettingsType, RoamingSettings, SettingDB, SmartChargingSettings, SmartChargingSettingsType, TaskSettings, TaskSettingsType, TechnicalSettings, UserSettings, UserSettingsType } from '../../types/Setting'; +import { AnalyticsSettings, AnalyticsSettingsType, AssetSettings, AssetSettingsType, BillingSetting, BillingSettings, BillingSettingsType, CarConnectorSettings, CarConnectorSettingsType, CryptoSetting, CryptoSettings, CryptoSettingsType, PricingSettings, PricingSettingsType, RefundSettings, RefundSettingsType, RoamingSettings, ScanPaySettings, ScanPaySettingsType, SettingDB, SmartChargingSettings, SmartChargingSettingsType, TaskSettings, TaskSettingsType, TechnicalSettings, UserSettings, UserSettingsType } from '../../types/Setting'; import Tenant, { TenantComponents } from '../../types/Tenant'; import global, { DatabaseCount, FilterParams } from '../../types/GlobalType'; @@ -517,4 +517,18 @@ export default class SettingStorage { return taskSettings; } + public static async getScanPaySettings(tenant: Tenant): Promise { + let scanPaySettings: ScanPaySettings; + // Get Scan & Pay settings + const settings = await SettingStorage.getSettings(tenant, { identifier: TenantComponents.SCAN_PAY }, Constants.DB_PARAMS_SINGLE_RECORD); + if (settings.count > 0) { + scanPaySettings = { + id: settings.result[0].id, + identifier: TenantComponents.SCAN_PAY, + type: ScanPaySettingsType.SCAN_PAY, + scanPay: settings.result[0].content.scanPay, + }; + } + return scanPaySettings; + } } diff --git a/src/storage/mongodb/TransactionStorage.ts b/src/storage/mongodb/TransactionStorage.ts index 78c59978c3..cdca90d9c5 100644 --- a/src/storage/mongodb/TransactionStorage.ts +++ b/src/storage/mongodb/TransactionStorage.ts @@ -100,6 +100,7 @@ export default class TransactionStorage { currentInstantAmpsL3: Utils.convertToInt(transactionToSave.currentInstantAmpsL3), currentInstantAmpsDC: Utils.convertToInt(transactionToSave.currentInstantAmpsDC), migrationTag: transactionToSave.migrationTag, + lastPaymentIntentID: transactionToSave.lastPaymentIntentID }; if (transactionToSave.phasesUsed) { transactionMDB.phasesUsed = { diff --git a/src/storage/mongodb/UserStorage.ts b/src/storage/mongodb/UserStorage.ts index 925a63954d..36f4da9cc4 100644 --- a/src/storage/mongodb/UserStorage.ts +++ b/src/storage/mongodb/UserStorage.ts @@ -233,6 +233,8 @@ export default class UserStorage { importedData: userToSave.importedData, notificationsActive: userToSave.notificationsActive, authorizationID: userToSave.authorizationID, + lastPaymentIntentID: userToSave.lastPaymentIntentID, + verificationToken: userToSave.verificationToken, notifications: { sendSessionStarted: userToSave.notifications ? Utils.convertToBoolean(userToSave.notifications.sendSessionStarted) : false, sendOptimalChargeReached: userToSave.notifications ? Utils.convertToBoolean(userToSave.notifications.sendOptimalChargeReached) : false, diff --git a/src/types/Authorization.ts b/src/types/Authorization.ts index 018135988b..495eb375e0 100644 --- a/src/types/Authorization.ts +++ b/src/types/Authorization.ts @@ -7,6 +7,7 @@ export interface AuthorizationDefinition { demo: AuthorizationDefinitionRole; siteAdmin: AuthorizationDefinitionRole; siteOwner: AuthorizationDefinitionRole; + external: AuthorizationDefinitionRole; } export interface AuthorizationDefinitionRole { grants: AuthorizationDefinitionGrant[]; @@ -112,6 +113,7 @@ export enum Entity { SOURCE = 'Source', CONSUMPTION = 'Consumption', SMART_CHARGING = 'SmartCharging', + PAYMENT_INTENT = 'PaymentIntent', STATISTIC = 'Statistic' } @@ -184,6 +186,7 @@ export enum Action { READ_CHARGING_STATIONS_FROM_SITE_AREA = 'ReadChargingStationsFromSiteArea', EXPORT_OCPP_PARAMS = 'ExportOCPPParams', GENERATE_QR = 'GenerateQrCode', + GENERATE_QR_SCAN_PAY = 'GenerateQrCodeScanPay', MAINTAIN_PRICING_DEFINITIONS = 'MaintainPricingDefinitions', RESOLVE = 'Resolve', GET_STATUS_NOTIFICATION = 'GetStatusNotification', @@ -195,6 +198,7 @@ export enum Action { GET_OCPP_PARAMS = 'GetOCPPParams', UPDATE_CHARGING_PROFILE = 'UpdateChargingProfile', GET_CONNECTOR_QR_CODE = 'GetConnectorQRCode', + GET_CONNECTOR_QR_CODE_SCAN_PAY = 'GetConnectorQRCodeScanPay', VIEW_USER_DATA = 'ViewUserData', SYNCHRONIZE_REFUNDED_TRANSACTION = 'SynchronizeRefundedTransaction', PUSH_TRANSACTION_CDR = 'PushTransactionCDR', @@ -206,6 +210,8 @@ export enum Action { GET_REFUND_REPORT = 'GetRefundReport', EXPORT_COMPLETED_TRANSACTION = 'ExportCompletedTransaction', EXPORT_OCPI_CDR = 'ExportOcpiCdr', + SETUP = 'Setup', + RETRIEVE = 'Retrieve' } export interface AuthorizationContext { @@ -282,6 +288,7 @@ export interface SiteAreaAuthorizationActions extends AuthorizationActions { canReadChargingStations?: boolean; canExportOCPPParams?: boolean; canGenerateQrCode?: boolean; + canGenerateQrCodeScanPay?: boolean; } export interface SiteAuthorizationActions extends AuthorizationActions { @@ -289,6 +296,7 @@ export interface SiteAuthorizationActions extends AuthorizationActions { canListSiteUsers?: boolean; canExportOCPPParams?: boolean; canGenerateQrCode?: boolean; + canGenerateQrCodeScanPay?: boolean; canMaintainPricingDefinitions?: boolean; canAssignSitesToUser?: boolean; canUnassignSitesFromUser?: boolean; @@ -319,6 +327,7 @@ export interface ChargingStationAuthorizationActions extends AuthorizationAction canUnlockConnector?:boolean; canDataTransfer?:boolean; canGenerateQrCode?:boolean; + canGenerateQrCodeScanPay?:boolean; canMaintainPricingDefinitions?:boolean; canUpdateOCPPParams?:boolean; canLimitPower?:boolean; @@ -326,6 +335,7 @@ export interface ChargingStationAuthorizationActions extends AuthorizationAction canGetOCPPParams?:boolean; canUpdateChargingProfile?:boolean; canGetConnectorQRCode?:boolean; + canGetConnectorQRCodeScanPay?:boolean; canPushTransactionCDR?: boolean; canListCompletedTransactions?: boolean; canAuthorize?: boolean; diff --git a/src/types/HTTPError.ts b/src/types/HTTPError.ts index e0d035c795..cbbf007357 100644 --- a/src/types/HTTPError.ts +++ b/src/types/HTTPError.ts @@ -84,6 +84,9 @@ export enum HTTPError { SITE_AREA_TREE_ERROR_MULTIPLE_ACTIONS_NOT_SUPPORTED = 546, CANNOT_RETRIEVE_CONSUMPTION = 533, + + SCAN_PAY_HOLD_AMOUNT_MISSING = 547, + CANNOT_REMOTE_START_CONNECTOR = 548, } export enum HTTPAuthError { diff --git a/src/types/Server.ts b/src/types/Server.ts index 8dfd19aff6..f13ed4674c 100644 --- a/src/types/Server.ts +++ b/src/types/Server.ts @@ -40,6 +40,7 @@ export enum ServerAction { CHARGING_STATION_REQUEST_OCPP_PARAMETERS = 'ChargingStationRequestOcppParameters', CHARGING_STATION_CLIENT_INITIALIZATION = 'ChargingStationClientInitialization', CHARGING_STATION_DOWNLOAD_QR_CODE_PDF = 'ChargingStationDownloadQrCodePdf', + CHARGING_STATION_DOWNLOAD_QR_CODE_SCAN_PAY_PDF = 'ChargingStationDownloadQrCodeScanPayPdf', CHARGING_STATIONS_EXPORT = 'ChargingStationsExport', CHARGING_STATIONS_OCPP_PARAMS_EXPORT = 'ChargingStationsOcppParamsExport', CHARGING_STATION = 'ChargingStation', @@ -101,6 +102,7 @@ export enum ServerAction { CHARGING_PROFILE_UPDATE = 'ChargingProfileUpdate', CHARGING_PROFILE_CREATE = 'ChargingProfileCreate', GENERATE_QR_CODE_FOR_CONNECTOR = 'GenerateQrCodeForConnector', + GENERATE_QR_CODE_FOR_CONNECTOR_SCAN_PAY = 'GenerateQrCodeForConnectorScanPay', OCPP_PARAM_UPDATE = 'OcppParamUpdate', RESEND_VERIFICATION_MAIL = 'ResendVerificationEmail', END_USER_LICENSE_AGREEMENT = 'EndUserLicenseAgreement', @@ -474,6 +476,11 @@ export enum ServerAction { BILLING_TRANSFER_SEND = 'BillingTransferSend', BILLING_TRANSFER_DISPATCH_FUNDS = 'BillingTransferDispatchFunds', + SCAN_PAY_VERIFY_EMAIL = 'ScanPayVerifyEmail', + SCAN_PAY_PAYMENT_INTENT_SETUP = 'ScanPayPaymentIntentSetup', + SCAN_PAY_PAYMENT_INTENT_RETRIEVE = 'ScanPayPaymentIntentRetrieve', + SCAN_PAY_TRANSACTION_STARTED = 'ScanPayTransactionStarted', + PRICING = 'Pricing', PRICING_DEFINITION = 'PricingDefinition', PRICING_DEFINITIONS = 'PricingDefinitions', @@ -534,7 +541,9 @@ export enum RESTServerRoute { REST_CHARGING_STATIONS_DOWNLOAD_FIRMWARE = 'charging-stations/firmware/download', REST_CHARGING_STATIONS_QRCODE_GENERATE = 'charging-stations/:id/connectors/:connectorId/qrcode/generate', + REST_CHARGING_STATIONS_QRCODE_GENERATE_SCAN_PAY = 'charging-stations/:id/connectors/:connectorId/qrcode/generate/scan-pay', REST_CHARGING_STATIONS_QRCODE_DOWNLOAD = 'charging-stations/qrcode/download', + REST_CHARGING_STATIONS_QRCODE_SCAN_PAY_DOWNLOAD = 'charging-stations/qrcode/scan-pay/download', REST_CHARGING_STATION_GET_OCPP_PARAMETERS = 'charging-stations/:id/ocpp/parameters', REST_CHARGING_STATIONS_REQUEST_OCPP_PARAMETERS = 'charging-stations/ocpp/parameters', @@ -717,6 +726,11 @@ export enum RESTServerRoute { REST_BILLING_ACCOUNT_REFRESH = 'billing/accounts/:id/refresh', REST_BILLING_ACCOUNT_ACTIVATE = 'billing/accounts/:id/activate', + // BILLING / SCAN AND PAY + REST_SCAN_PAY_VERIFY_EMAIL = 'billing/scan-pay/verify-email', + REST_SCAN_PAY_PAYMENT_INTENT_SETUP = 'billing/scan-pay/setup', + REST_SCAN_PAY_PAYMENT_INTENT_RETRIEVE = 'billing/scan-pay/retrieve', + // BILLING URLs for CRUD operations on INVOICES REST_BILLING_INVOICES = 'invoices', REST_BILLING_INVOICE = 'invoices/:invoiceID', diff --git a/src/types/Setting.ts b/src/types/Setting.ts index c1fdd4faeb..1fe40020ae 100644 --- a/src/types/Setting.ts +++ b/src/types/Setting.ts @@ -21,7 +21,8 @@ export enum IntegrationSettings { BILLING_PLATFORM = 'billingPlatform', CAR = 'car', ORGANIZATION = 'organization', - STATISTICS = 'statistics' + STATISTICS = 'statistics', + SCAN_PAY = 'scanPay' } export interface Setting extends SettingAuthorizationActions, CreatedUpdatedProps { @@ -57,7 +58,8 @@ export interface SettingDBContent { | CryptoSettingsType | UserSettingsType | CarConnectorSettingsType - | TaskSettingsType; + | TaskSettingsType + | ScanPaySettingsType; ocpi?: OcpiSetting; oicp?: OicpSetting; // pricing?: PricingSetting; // TODO - reorg pricing similar to billing @@ -73,6 +75,7 @@ export interface SettingDBContent { crypto?: CryptoSetting; user?: UserSetting; task?: TaskSetting; + scanPay?: ScanPaySetting; } export enum PricingSettingsType { @@ -438,3 +441,17 @@ export interface TaskSetting { disableAllTasks?: boolean; disableTasksInEnv?: string[]; } + +export enum ScanPaySettingsType { + SCAN_PAY = 'scanPay', +} + +export interface ScanPaySettings extends Setting { + identifier: TenantComponents.SCAN_PAY; + type: ScanPaySettingsType; + scanPay?: ScanPaySetting; +} + +export interface ScanPaySetting { + amount: number; +} diff --git a/src/types/Tenant.ts b/src/types/Tenant.ts index b144281351..b9b0eedb93 100644 --- a/src/types/Tenant.ts +++ b/src/types/Tenant.ts @@ -29,6 +29,7 @@ export interface TenantComponent { asset?: TenantComponentContent; car?: TenantComponentContent; carConnector?: TenantComponentContent; + scanPay?: TenantComponentContent; } export interface TenantComponentContent { @@ -54,5 +55,6 @@ export enum TenantComponents { ASSET = 'asset', SMART_CHARGING = 'smartCharging', CAR = 'car', - CAR_CONNECTOR = 'carConnector' + CAR_CONNECTOR = 'carConnector', + SCAN_PAY = 'scanPay', } diff --git a/src/types/Transaction.ts b/src/types/Transaction.ts index 121c956e3b..d9c8210c87 100644 --- a/src/types/Transaction.ts +++ b/src/types/Transaction.ts @@ -138,6 +138,7 @@ export default interface Transaction extends AbstractCurrentConsumption, Transac refundData?: TransactionRefundData; migrationTag?: string; authorizationID?: string; + lastPaymentIntentID?: string; } export interface TransactionStats extends DatabaseCount { diff --git a/src/types/User.ts b/src/types/User.ts index 662cb77019..568e5ce048 100644 --- a/src/types/User.ts +++ b/src/types/User.ts @@ -42,6 +42,7 @@ export default interface User extends CreatedUpdatedProps, UserAuthorizationActi }; technical?: boolean; freeAccess?: boolean; + lastPaymentIntentID?: string; } export interface UserMobileData { @@ -99,6 +100,7 @@ export enum UserRole { ADMIN = 'A', BASIC = 'B', DEMO = 'D', + EXTERNAL = 'E', } export const UserRequiredImportProperties = [ diff --git a/src/types/UserNotifications.ts b/src/types/UserNotifications.ts index 2000b6bdce..685e0d7e4e 100644 --- a/src/types/UserNotifications.ts +++ b/src/types/UserNotifications.ts @@ -206,6 +206,13 @@ export interface VerificationEmailNotification extends BaseNotification { evseDashboardVerifyEmailURL: string; } +export interface ScanPayVerifyEmailNotification extends BaseNotification { + user: User; + tenantName?: string; + evseDashboardURL: string; + evseDashboardVerifyScanPayEmailURL: string; +} + export interface ChargingStationStatusErrorNotification extends BaseNotification { chargeBoxID: string; siteID: string; @@ -247,6 +254,10 @@ export interface TransactionStartedNotification extends BaseNotification { evseDashboardChargingStationURL: string; } +export interface ScanPayTransactionStartedNotification extends TransactionStartedNotification { + evseStopScanPayTransactionURL: string; +} + export interface OICPPatchChargingStationsErrorNotification extends BaseNotification { evseDashboardURL: string; } @@ -313,6 +324,7 @@ export interface BillingNewInvoiceNotification extends BaseNotification { invoiceNumber: string; invoiceAmount: string; invoiceStatus: string; + evseScanPayInvoiceDownloadURL?: string; } export interface BillingAccountCreationLinkNotification extends BaseNotification { diff --git a/src/types/requests/HttpBillingRequest.ts b/src/types/requests/HttpBillingRequest.ts index d7a741938d..2f68c31f47 100644 --- a/src/types/requests/HttpBillingRequest.ts +++ b/src/types/requests/HttpBillingRequest.ts @@ -18,7 +18,8 @@ export interface HttpBillingInvoicesRequest extends HttpDatabaseRequest { export interface HttpSetupPaymentMethod { userID: string; - paymentMethodId?: string; + paymentMethodID?: string; + paymentIntentID?: string; } export interface HttpPaymentMethods { @@ -75,3 +76,26 @@ export interface HttpBillingTransferFinalizeRequest extends HttpByIDRequest { export interface HttpBillingTransferSendRequest extends HttpByIDRequest { ID: string; } + +export interface HttpBillingScanPayRequest { + firstName?: string; + name?: string; + siteAreaID: string; + subdomain: string; + email: string; + locale: string; + paymentMethodID?: string; + paymentIntentID?: string; + connectorID?: number; + chargingStationID?: string; + verificationToken?: string; +} + +export interface HttpBillingScanPayStopTransactionRequest { + ID: number; + email: string; +} + +export interface HttpBillingScanPayTransactionRequest { + ID: number; +} diff --git a/src/types/requests/HttpUserRequest.ts b/src/types/requests/HttpUserRequest.ts index db05929f2c..521711a18a 100644 --- a/src/types/requests/HttpUserRequest.ts +++ b/src/types/requests/HttpUserRequest.ts @@ -77,6 +77,7 @@ export interface HttpLoginRequest { email: string; password: string; acceptEula: boolean; + verificationToken?: string; } export interface HttpResetPasswordRequest { @@ -85,6 +86,17 @@ export interface HttpResetPasswordRequest { password: string; hash: string; } + +export interface HttpScanPayVerifyEmailRequest { + email: string; + captcha: string; + locale: string; + name: string; + firstName: string; + connectorID: string; + chargingStationID: string; +} + export interface HttpCheckEulaRequest { Email: string; } diff --git a/src/utils/NotificationHelper.ts b/src/utils/NotificationHelper.ts index 808c6088ae..ae3490048b 100644 --- a/src/utils/NotificationHelper.ts +++ b/src/utils/NotificationHelper.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/member-ordering */ -import { ChargingStationRegisteredNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, NotificationSeverity, NotificationSource, OptimalChargeReachedNotification, TransactionStartedNotification } from '../types/UserNotifications'; +import { ChargingStationRegisteredNotification, EndOfChargeNotification, EndOfSessionNotification, EndOfSignedSessionNotification, NotificationSeverity, NotificationSource, OptimalChargeReachedNotification, ScanPayTransactionStartedNotification, TransactionStartedNotification } from '../types/UserNotifications'; import User, { UserRole } from '../types/User'; import ChargingStation from '../types/ChargingStation'; @@ -36,6 +36,14 @@ export default class NotificationHelper { } } + public static notifyScanPayStartTransaction(tenant: Tenant, transaction: Transaction, chargingStation: ChargingStation, user: User) { + if (user?.notificationsActive) { + setTimeout(() => { + NotificationHelper.getSessionNotificationHelper(tenant, transaction, chargingStation, user).notifyScanPayStartTransaction(); + }, 500); + } + } + public static notifyStopTransaction(tenant: Tenant, transaction: Transaction, chargingStation: ChargingStation, user: User, alternateUser?: User) { if (user?.notificationsActive && user.notifications.sendEndOfSession) { setTimeout(() => { @@ -244,6 +252,32 @@ export class SessionNotificationHelper extends ChargerNotificationHelper { }); } + public notifyScanPayStartTransaction() { + const tenant = this.tenant; + const transaction = this.transaction; + const chargingStation = this.chargingStation; + const user = this.user; + // Notification data + const data: ScanPayTransactionStartedNotification = { + user, + transactionId: transaction.id, + chargeBoxID: chargingStation.id, + siteID: chargingStation.siteID, + siteAreaID: chargingStation.siteAreaID, + companyID: chargingStation.companyID, + connectorId: Utils.getConnectorLetterFromConnectorID(transaction.connectorId), + evseDashboardURL: Utils.buildEvseURL(tenant.subdomain), + evseDashboardChargingStationURL: Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#inprogress'), + evseStopScanPayTransactionURL: Utils.buildEvseScanPayStopTransactionURL(tenant.subdomain, transaction.id, user.email, user.verificationToken) + }; + // Do it + NotificationHelper.notifySingleUser((channel: NotificationSource) => { + channel.notificationTask.sendScanPaySessionStarted(data, user, tenant, NotificationSeverity.INFO).catch((error) => { + Logging.logPromiseError(error, tenant?.id); + }); + }); + } + public notifyEndOfCharge() { const tenant = this.tenant; const transaction = this.transaction; @@ -263,7 +297,7 @@ export class SessionNotificationHelper extends ChargerNotificationHelper { totalConsumption: i18nManager.formatNumber(Math.round(transaction.currentTotalConsumptionWh / 10) / 100), stateOfCharge: transaction.currentStateOfCharge, totalDuration: Utils.transactionDurationToString(transaction), - evseDashboardChargingStationURL: Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#inprogress'), + evseDashboardChargingStationURL: user.role === UserRole.EXTERNAL ? Utils.buildEvseScanPayStopTransactionURL(tenant.subdomain, transaction.id, user.email, user.verificationToken) : Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#inprogress'), evseDashboardURL: Utils.buildEvseURL(tenant.subdomain) }; // Do it @@ -300,7 +334,7 @@ export class SessionNotificationHelper extends ChargerNotificationHelper { connectorId: Utils.getConnectorLetterFromConnectorID(transaction.connectorId), totalConsumption: i18nManager.formatNumber(Math.round(transaction.currentTotalConsumptionWh / 10) / 100), stateOfCharge: transaction.currentStateOfCharge, - evseDashboardChargingStationURL: Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#inprogress'), + evseDashboardChargingStationURL: user.role === UserRole.EXTERNAL ? Utils.buildEvseScanPayStopTransactionURL(tenant.subdomain, transaction.id, user.email, user.verificationToken) : Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#inprogress'), evseDashboardURL: Utils.buildEvseURL(tenant.subdomain) }; // Do it @@ -340,7 +374,7 @@ export class SessionNotificationHelper extends ChargerNotificationHelper { totalDuration: Utils.transactionDurationToString(transaction), totalInactivity: this.transactionInactivityToString(), stateOfCharge: transaction.stop.stateOfCharge, - evseDashboardChargingStationURL: Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#history'), + evseDashboardChargingStationURL: user.role === UserRole.EXTERNAL ? Utils.buildEvseScanPayStopTransactionURL(tenant.subdomain, transaction.id, user.email, user.verificationToken) : Utils.buildEvseTransactionURL(tenant.subdomain, transaction.id, '#history'), evseDashboardURL: Utils.buildEvseURL(tenant.subdomain) }; // Do it diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 7413c5a114..aa513aef1f 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -1,4 +1,4 @@ -import { AnalyticsSettingsType, AssetSettingsType, BillingSettingsType, CarConnectorSettingsType, CryptoKeyProperties, PricingSettingsType, RefundSettingsType, RoamingSettingsType, SettingDBContent, SmartChargingContentType } from '../types/Setting'; +import { AnalyticsSettingsType, AssetSettingsType, BillingSettingsType, CarConnectorSettingsType, CryptoKeyProperties, PricingSettingsType, RefundSettingsType, RoamingSettingsType, ScanPaySettingsType, SettingDBContent, SmartChargingContentType } from '../types/Setting'; import { Car, CarCatalog } from '../types/Car'; import ChargingStation, { ChargePoint, ChargingStationEndpoint, Connector, ConnectorCurrentLimitSource, CurrentType, Voltage } from '../types/ChargingStation'; import { OCPPProtocol, OCPPVersion, OCPPVersionURLPath } from '../types/ocpp/OCPPServer'; @@ -985,10 +985,22 @@ export default class Utils { return `${Utils.buildEvseURL(tenant.subdomain)}/auth/account-onboarding?TenantID=${tenant.id}&AccountID=${billingAccountID}`; } + public static buildEvseScanPayStopTransactionURL(tenantSubdomain: string, transactionID: number, email: string, verificationToken: string,): string { + return `${Utils.buildEvseURL(tenantSubdomain)}/auth/scan-pay/stop/${transactionID}/${encodeURIComponent(email)}/${encodeURIComponent(verificationToken)}`; + } + + public static buildEvseScanPayInvoiceDownloadURL(tenantSubdomain: string, billingInvoiceID: string, user: User): string { + return `${Utils.buildEvseURL(tenantSubdomain) + '/auth/scan-pay/invoice/' + billingInvoiceID + '/download?VerificationToken=' + encodeURIComponent(user.verificationToken) + '&email=' + encodeURIComponent(user.email)}`; + } + public static buildEvseUserToVerifyURL(tenantSubdomain: string, userId: string): string { return `${Utils.buildEvseURL(tenantSubdomain)}/users/${userId}`; } + public static buildEvseScanPayConnectorURL(tenantSubdomain: string, chargingStation: ChargingStation, connectorID: number): string { + return `${Utils.buildEvseURL(tenantSubdomain)}/auth/scan-pay/${chargingStation.id}/${connectorID}`; + } + public static getRequestIP(request: http.IncomingMessage | Partial): string | string[] { if (request['ip']) { return request['ip']; @@ -1318,6 +1330,16 @@ export default class Utils { } as SettingDBContent; } break; + // Scan & Pay + case TenantComponents.SCAN_PAY: + if (!currentSettingContent) { + // Only scanPay + return { + 'type': ScanPaySettingsType.SCAN_PAY, + 'scanPay': {} + } as SettingDBContent; + } + break; } } @@ -1787,6 +1809,12 @@ export default class Utils { return this.hashCode(str) + 2147483647 + 1; } + public static buildAliasEmail(email: string): string { + const userName = email.split('@').slice(0,1)[0]; + const domain = email.split('@').slice(1,2)[0]; + const alias = '+' + Utils.generateUUID(); + return (userName + alias + '@' + domain); + } public static getRateLimiters(): Map { const shieldConfiguration = Configuration.getShieldConfig();