billing settings - TODOs + additional tests
MODIFY
src/integration/billing/stripe/StripeBillingIntegration.ts
src/integration/billing/stripe/StripeBillingIntegration.ts
@@ -16,7 +16,6 @@ import I18nManager from '../../../utils/I18nManager';
import Logging from '../../../utils/Logging';
import { Request } from 'express';
import { ServerAction } from '../../../types/Server';
-import { StatusCodes } from 'http-status-codes';
import Stripe from 'stripe';
import StripeHelpers from './StripeHelpers';
import Transaction from '../../../types/Transaction';
@@ -77,7 +76,7 @@ export default class StripeBillingIntegration extends BillingIntegration {
throw new BackendError({
source: Constants.CENTRAL_SERVER,
module: MODULE_NAME, method: 'checkConnection',
- action: ServerAction.CHECK_CONNECTION,
+ action: ServerAction.CHECK_BILLING_CONNECTION,
message: 'Failed to connect to Stripe',
detailedMessages: { error: error.message, stack: error.stack }
});

MODIFY
src/server/rest/v1/service/BillingService.ts
src/server/rest/v1/service/BillingService.ts
@@ -764,7 +764,7 @@ export default class BillingService {
next();
return;
}
- BillingService.hashSensitiveData(req.user.tenantID, billingSettings);
+ UtilsService.hashSensitiveData(req.user.tenantID, billingSettings);
// Ok
res.json(billingSettings);
next();
@@ -792,7 +792,7 @@ export default class BillingService {
next();
return;
}
- await BillingService.processSensitiveData(req.user.tenantID, billingSettings, newBillingProperties);
+ await UtilsService.processSensitiveData(req.user.tenantID, billingSettings, newBillingProperties);
// Billing properties to preserve
const { usersLastSynchronizedOn } = billingSettings.billing;
const previousTransactionBillingState = !!billingSettings.billing.isTransactionBillingActivated;
@@ -855,11 +855,32 @@ export default class BillingService {
// Post-process the activation of the billing feature
if (postponeTransactionBillingActivation) {
// Checks all pre-requisites
- await this.checkActivationPrerequisites(action, req);
+ let prerequisitesOk = false;
+ try {
+ // Check
+ await BillingService.checkActivationPrerequisites(action, req);
// Well - everything was Ok, activation is possible
billingSettings.billing.isTransactionBillingActivated = true;
// Save it again now that we are sure
await BillingStorage.saveBillingSetting(req.user.tenantID, billingSettings);
+ prerequisitesOk = true;
+ } catch (error) {
+ // Ko
+ await Logging.logError({
+ tenantID: req.user.tenantID,
+ user: req.user,
+ module: MODULE_NAME, method: 'handleUpdateBillingSetting',
+ message: 'Failed to activate the billing of transactions',
+ action: action,
+ detailedMessages: { error: error.message, stack: error.stack }
+ });
+ }
+ if (!prerequisitesOk) {
+ // Ko
+ res.sendStatus(StatusCodes.METHOD_NOT_ALLOWED);
+ next();
+ return;
+ }
}
// Ok
res.json(Constants.REST_RESPONSE_SUCCESS);
@@ -873,7 +894,7 @@ export default class BillingService {
source: Constants.CENTRAL_SERVER,
errorCode: HTTPError.GENERAL_ERROR,
message: 'Billing service is not configured',
- module: MODULE_NAME, method: 'handlePreCheckBillingConnection',
+ module: MODULE_NAME, method: 'checkActivationPrerequisites',
action: action,
user: req.user
});
@@ -883,78 +904,4 @@ export default class BillingService {
// Let's validate the new settings before activating
await billingImpl.checkActivationPrerequisites();
}
-
- private static async processSensitiveData(tenantID: string, billingSettings: BillingSettings, newBillingProperties: Partial<BillingSettings>) {
- // Process the sensitive data (if any)
- if (billingSettings.sensitiveData) {
- if (!Array.isArray(billingSettings.sensitiveData)) {
- throw new AppError({
- source: Constants.CENTRAL_SERVER,
- errorCode: HTTPError.CYPHER_INVALID_SENSITIVE_DATA_ERROR,
- message: `The property 'sensitiveData' for Setting with ID '${newBillingProperties.id}' is not an array`,
- module: MODULE_NAME,
- method: 'processSensitiveData',
- });
- }
- // Process sensitive properties
- for (const propertyName of billingSettings.sensitiveData) {
- // TODO - find a better way - HACK to be backward compatible with SettingDB
- const normalizedPropertyName = propertyName.replace('content.', '');
- // Get the sensitive property from the request
- const valueInRequest = _.get(newBillingProperties, normalizedPropertyName);
- if (valueInRequest && valueInRequest.length > 0) {
- // Get the sensitive property from the DB
- const valueInDb = _.get(billingSettings, normalizedPropertyName);
- if (valueInDb && valueInDb.length > 0) {
- const hashedValueInDB = Cypher.hash(valueInDb);
- if (valueInRequest !== hashedValueInDB) {
- // Yes: Encrypt
- _.set(newBillingProperties, normalizedPropertyName, await Cypher.encrypt(tenantID, valueInRequest));
- } else {
- // No: Put back the encrypted value
- _.set(newBillingProperties, normalizedPropertyName, valueInDb);
- }
- } else {
- // Value in db is empty then encrypt
- _.set(newBillingProperties, normalizedPropertyName, await Cypher.encrypt(tenantID, valueInRequest));
- }
- } else {
- throw new AppError({
- source: Constants.CENTRAL_SERVER,
- errorCode: HTTPError.CYPHER_INVALID_SENSITIVE_DATA_ERROR,
- message: `The property '${propertyName}' for Setting with ID '${newBillingProperties.id}' is not set`,
- module: MODULE_NAME,
- method: 'processSensitiveData',
- });
- }
- }
- }
- }
-
- private static hashSensitiveData(tenantID: string, billingSettings: BillingSettings): BillingSettings {
- if (billingSettings.sensitiveData) {
- // Check that sensitive data is an array
- if (!Array.isArray(billingSettings.sensitiveData)) {
- throw new BackendError({
- source: Constants.CENTRAL_SERVER,
- module: MODULE_NAME,
- method: 'hashSensitiveDataInJSON',
- message: 'The property \'sensitiveData\' is not an array'
- });
- }
- for (const propertyName of billingSettings.sensitiveData) {
- // TODO - find a better way - HACK to be backward compatible with SettingDB
- const normalizedPropertyName = propertyName.replace('content.', '');
- // Check that the property does exist otherwise skip to the next property
- if (_.has(billingSettings, normalizedPropertyName)) {
- const value = _.get(billingSettings, normalizedPropertyName);
- // If the value is undefined, null or empty then do nothing and skip to the next property
- if (value && value.length > 0) {
- _.set(billingSettings, normalizedPropertyName, Cypher.hash(value));
- }
- }
- }
- }
- return billingSettings;
- }
}

MODIFY
src/server/rest/v1/service/UtilsService.ts
src/server/rest/v1/service/UtilsService.ts
@@ -1518,45 +1518,45 @@ export default class UtilsService {
}
}
- public static async processSensitiveData(tenantID: string, settings: Setting, newProperties: Partial<Setting>) {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ public static async processSensitiveData(tenantID: string, currentProperties: object, newProperties: object): Promise<void> {
// Process the sensitive data (if any)
- if (settings.sensitiveData) {
- if (!Array.isArray(settings.sensitiveData)) {
+ const sensitivePropertyNames: string [] = _.get(currentProperties, 'sensitiveData');
+ if (sensitivePropertyNames) {
+ if (!Array.isArray(sensitivePropertyNames)) {
throw new AppError({
source: Constants.CENTRAL_SERVER,
errorCode: HTTPError.CYPHER_INVALID_SENSITIVE_DATA_ERROR,
- message: `The property 'sensitiveData' for Setting with ID '${newProperties.id}' is not an array`,
+ message: 'Unexpected situation - sensitiveData is not an array',
module: MODULE_NAME,
- method: 'processSensitiveData',
+ method: 'processSensitiveData'
});
}
// Process sensitive properties
- for (const propertyName of settings.sensitiveData) {
- // TODO - find a better way - HACK to be backward compatible with SettingDB
- const normalizedPropertyName = propertyName.replace('content.', '');
+ for (const propertyName of sensitivePropertyNames) {
// Get the sensitive property from the request
- const valueInRequest = _.get(newProperties, normalizedPropertyName);
- if (valueInRequest && valueInRequest.length > 0) {
+ const newValue = _.get(newProperties, propertyName);
+ if (newValue && typeof newValue === 'string') {
// Get the sensitive property from the DB
- const valueInDb = _.get(settings, normalizedPropertyName);
- if (valueInDb && valueInDb.length > 0) {
- const hashedValueInDB = Cypher.hash(valueInDb);
- if (valueInRequest !== hashedValueInDB) {
+ const currentValue = _.get(currentProperties, propertyName);
+ if (currentValue && typeof currentValue === 'string') {
+ const currentHash = Cypher.hash(currentValue);
+ if (newValue !== currentHash) {
// Yes: Encrypt
- _.set(newProperties, normalizedPropertyName, await Cypher.encrypt(tenantID, valueInRequest));
+ _.set(newProperties, propertyName, await Cypher.encrypt(tenantID, newValue));
} else {
// No: Put back the encrypted value
- _.set(newProperties, normalizedPropertyName, valueInDb);
+ _.set(newProperties, propertyName, currentValue);
}
} else {
// Value in db is empty then encrypt
- _.set(newProperties, normalizedPropertyName, await Cypher.encrypt(tenantID, valueInRequest));
+ _.set(newProperties, propertyName, await Cypher.encrypt(tenantID, newValue));
}
} else {
throw new AppError({
source: Constants.CENTRAL_SERVER,
errorCode: HTTPError.CYPHER_INVALID_SENSITIVE_DATA_ERROR,
- message: `The property '${propertyName}' for Setting with ID '${newProperties.id}' is not set`,
+ message: `The property '${propertyName}' is not set`,
module: MODULE_NAME,
method: 'processSensitiveData',
});
@@ -1565,30 +1565,31 @@ export default class UtilsService {
}
}
- public static hashSensitiveData(tenantID: string, settings: Setting): Setting {
- if (settings.sensitiveData) {
- // Check that sensitive data is an array
- if (!Array.isArray(settings.sensitiveData)) {
- throw new BackendError({
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ public static hashSensitiveData(tenantID: string, properties: object): unknown {
+ const sensitivePropertyNames: string [] = _.get(properties, 'sensitiveData');
+ if (sensitivePropertyNames) {
+ if (!Array.isArray(sensitivePropertyNames)) {
+ throw new AppError({
source: Constants.CENTRAL_SERVER,
+ errorCode: HTTPError.CYPHER_INVALID_SENSITIVE_DATA_ERROR,
+ message: 'Unexpected situation - sensitiveData is not an array',
module: MODULE_NAME,
- method: 'hashSensitiveDataInJSON',
- message: 'The property \'sensitiveData\' is not an array'
+ method: 'hashSensitiveData'
});
}
- for (const propertyName of settings.sensitiveData) {
- // TODO - find a better way - HACK to be backward compatible with SettingDB
- const normalizedPropertyName = propertyName.replace('content.', '');
+ for (const propertyName of sensitivePropertyNames) {
// Check that the property does exist otherwise skip to the next property
- if (_.has(settings, normalizedPropertyName)) {
- const value = _.get(settings, normalizedPropertyName);
+ if (_.has(properties, propertyName)) {
+ const value = _.get(properties, propertyName);
// If the value is undefined, null or empty then do nothing and skip to the next property
- if (value && value.length > 0) {
- _.set(settings, normalizedPropertyName, Cypher.hash(value));
+ if (value && typeof value === 'string') {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ _.set(properties, propertyName, Cypher.hash(value));
}
}
}
}
- return settings;
+ return properties;
}
}

MODIFY
src/storage/mongodb/BillingStorage.ts
src/storage/mongodb/BillingStorage.ts
@@ -252,37 +252,42 @@ export default class BillingStorage {
const { createdBy, createdOn, lastChangedBy, lastChangedOn } = billingSettings;
const { type, billing, stripe } = billingSettings;
const setting: SettingDB = {
- id, identifier, sensitiveData, backupSensitiveData, category,
- createdBy, createdOn, lastChangedBy, lastChangedOn,
+ id, identifier, sensitiveData, backupSensitiveData,
content: {
type,
billing,
- stripe,
},
+ category, createdBy, createdOn, lastChangedBy, lastChangedOn,
};
+ if (billingSettings.type === BillingSettingsType.STRIPE) {
+ setting.sensitiveData = [ 'content.stripe.secretKey' ];
+ setting.content.stripe = stripe;
+ }
return SettingStorage.saveSettings(tenantID, setting);
}
private static convertToBillingSettings(setting: SettingDB): BillingSettings {
- const { id, sensitiveData, backupSensitiveData, category } = setting;
+ const { id, backupSensitiveData, category } = setting;
const { createdBy, createdOn, lastChangedBy, lastChangedOn } = setting;
const { content } = setting;
- let billingSettings: BillingSettings = null;
- if (content.type === BillingSettingsType.STRIPE) {
- billingSettings = {
- identifier: TenantComponents.BILLING,
- type: BillingSettingsType.STRIPE,
+ const billingSettings: BillingSettings = {
id,
- sensitiveData,
+ identifier: TenantComponents.BILLING,
+ type: content.type as BillingSettingsType,
backupSensitiveData,
+ billing: content.billing,
category,
createdBy,
createdOn,
lastChangedBy,
lastChangedOn,
- billing: content.billing,
- stripe: content.stripe
};
+ switch (content.type) {
+ // Only STRIPE so far
+ case BillingSettingsType.STRIPE:
+ billingSettings.stripe = content.stripe;
+ billingSettings.sensitiveData = [ 'stripe.secretKey' ];
+ break;
}
return billingSettings;
}

MODIFY
test/api/BillingTest.ts
test/api/BillingTest.ts
@@ -72,7 +72,7 @@ class TestData {
public async setBillingSystemValidCredentials(activateTransactionBilling = true) : Promise<StripeBillingIntegration> {
const billingSettings = this.getLocalSettings(false);
- // Here we activate the billing of charging sessions
+ // Here we switch ON or OFF the billing of charging sessions
billingSettings.billing.isTransactionBillingActivated = activateTransactionBilling;
// Invoke the generic setting service API to properly persist this information
await this.saveBillingSettings(billingSettings);
@@ -210,18 +210,17 @@ describe('Billing Settings', function() {
testData.adminUserContext
);
expect(testData.userContext).to.not.be.null;
- // Initialize the Billing module with transaction billing ON
- testData.billingImpl = await testData.setBillingSystemValidCredentials();
});
- describe('As an admin', () => {
+ describe('As an admin - with transaction billing OFF', () => {
// eslint-disable-next-line @typescript-eslint/require-await
before(async () => {
testData.userContext = testData.adminUserContext;
assert(testData.userContext, 'User context cannot be null');
testData.userService = testData.adminUserService;
assert(!!testData.userService, 'User service cannot be null');
- // await testData.setBillingSystemValidCredentials();
+ // Initialize the Billing module with transaction billing ON
+ testData.billingImpl = await testData.setBillingSystemValidCredentials(false);
});
it('Should be able to invoke Billing Settings endpoints', async () => {
@@ -241,21 +240,85 @@ describe('Billing Settings', function() {
assert(response.data.connectionIsValid === true, 'Connection should be valid');
});
- it('Should not be able to switch OFF the transaction billing activation', async () => {
+ it('Should be able to update the secret key', async () => {
// Get the Billing settings
let response = await testData.userService.billingApi.getBillingSetting();
assert(response.status === StatusCodes.OK, 'Response status should be 200');
let billingSettings = response.data as BillingSettings ;
assert(billingSettings.billing, 'Billing Properties should not be null');
- assert(billingSettings.billing.isTransactionBillingActivated, 'Transaction Billing should be ON');
+ assert(!billingSettings.billing.isTransactionBillingActivated, 'Transaction Billing should be OFF');
assert(billingSettings.stripe.secretKey, 'Hash of the secret key should not be null');
const keyHash = billingSettings.stripe.secretKey;
- // Let's attempt to update the billing settings
+ // Let's attempt to update the secret key
+ billingSettings.stripe.secretKey = config.get('stripe.secretKey'),
+ response = await testData.userService.billingApi.updateBillingSetting(billingSettings);
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ // Check that the hash is still correct
+ response = await testData.userService.billingApi.getBillingSetting();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ billingSettings = response.data as BillingSettings ;
+ assert(billingSettings.billing, 'Billing Properties should not be null');
+ assert(keyHash !== billingSettings.stripe.secretKey, 'Hash of the secret key should be different');
+ });
+
+ it('Should check prerequisites when switching Transaction Billing ON', async () => {
+ let response = await testData.userService.billingApi.getBillingSetting();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ const billingSettings = response.data as BillingSettings ;
+ // Let's attempt to switch ON the billing of transactions
+ billingSettings.billing.isTransactionBillingActivated = true;
response = await testData.userService.billingApi.updateBillingSetting(billingSettings);
+ // taxID is not set - so the prerequisites are not met
+ assert(response.status !== StatusCodes.OK, 'Response status should not be 200');
+ // Check again the billing connection to STRIPE
+ response = await testData.userService.billingApi.checkBillingConnection();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ assert(response.data, 'Response data should not be null');
+ assert(response.data.connectionIsValid === true, 'Connection should be valid');
+ });
+ });
+
+ describe('As an admin - with transaction billing ON', () => {
+ // eslint-disable-next-line @typescript-eslint/require-await
+ before(async () => {
+ testData.userContext = testData.adminUserContext;
+ assert(testData.userContext, 'User context cannot be null');
+ testData.userService = testData.adminUserService;
+ assert(!!testData.userService, 'User service cannot be null');
+ // Initialize the Billing module with transaction billing ON
+ testData.billingImpl = await testData.setBillingSystemValidCredentials();
+ });
+
+ it('Should be able to invoke Billing Settings endpoints', async () => {
+ // Get the Billing settings
+ let response = await testData.userService.billingApi.getBillingSetting();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ const billingSettings = response.data as BillingSettings ;
+ assert(billingSettings.billing, 'Billing Properties should not be null');
+ assert(billingSettings.type === BillingSettingsType.STRIPE, 'Billing Setting Type should not be set to STRIPE');
+ assert(billingSettings.stripe, 'Stripe Properties should not be null');
+ assert(billingSettings.stripe.secretKey, 'Secret Key should not be null');
+ assert(billingSettings.id, 'ID should not be null');
+ // Check billing connection to STRIPE
+ response = await testData.userService.billingApi.checkBillingConnection();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ assert(response.data, 'Response data should not be null');
+ assert(response.data.connectionIsValid === true, 'Connection should be valid');
+ });
+
+ it('Should not be able to alter the secretKey when transaction billing is ON', async () => {
+ // Get the Billing settings
+ let response = await testData.userService.billingApi.getBillingSetting();
assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ let billingSettings = response.data as BillingSettings ;
+ assert(billingSettings.billing, 'Billing Properties should not be null');
+ assert(billingSettings.billing.isTransactionBillingActivated, 'Transaction Billing should be ON');
+ assert(billingSettings.stripe.secretKey, 'Hash of the secret key should not be null');
+ const keyHash = billingSettings.stripe.secretKey;
// Let's attempt to alter the secret key while transaction billing is ON
billingSettings.stripe.secretKey = '1234567890';
response = await testData.userService.billingApi.updateBillingSetting(billingSettings);
+ // Here it does not fail - but the initial secret key should have been preserved!
assert(response.status === StatusCodes.OK, 'Response status should be 200');
// Check that the secret key was preserved
response = await testData.userService.billingApi.getBillingSetting();
@@ -270,6 +333,23 @@ describe('Billing Settings', function() {
assert(response.data.connectionIsValid === true, 'Connection should be valid');
});
+ it('Should not be able to switch the transaction billing OFF', async () => {
+ // Get the Billing settings
+ let response = await testData.userService.billingApi.getBillingSetting();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ const billingSettings = response.data as BillingSettings ;
+ assert(billingSettings.billing, 'Billing Properties should not be null');
+ assert(billingSettings.billing.isTransactionBillingActivated, 'Transaction Billing should be ON');
+ // Let's attempt to switch the transaction billing OFF
+ billingSettings.billing.isTransactionBillingActivated = false;
+ response = await testData.userService.billingApi.updateBillingSetting(billingSettings);
+ assert(response.status === HTTPError.MISSING_SETTINGS, 'Response status should be 505');
+ // Check again the billing connection to STRIPE
+ response = await testData.userService.billingApi.checkBillingConnection();
+ assert(response.status === StatusCodes.OK, 'Response status should be 200');
+ assert(response.data, 'Response data should not be null');
+ assert(response.data.connectionIsValid === true, 'Connection should be valid');
+ });
});
});
});

billing settings - TODOs + additional tests