pricing - step on charging time
MODIFY
src/integration/pricing/PricingEngine.ts
src/integration/pricing/PricingEngine.ts
import FeatureToggles, { Feature } from '../../utils/FeatureToggles';
/* eslint-disable @typescript-eslint/member-ordering */
-import PricingDefinition, { DimensionType, PricedConsumptionData, PricedDimensionData, PricingDimension, PricingRestriction, PricingStaticRestriction, ResolvedPricingModel } from '../../types/Pricing';
+import PricingDefinition, { CurrentContext, DimensionType, PricedConsumptionData, PricedDimensionData, PricingDimension, PricingRestriction, PricingStaticRestriction, ResolvedPricingModel } from '../../types/Pricing';
import ChargingStation from '../../types/ChargingStation';
import Constants from '../../utils/Constants';
@@ -39,7 +39,10 @@ export default class PricingEngine {
pricingDefinitions.push(...await PricingEngine.getPricingDefinitions4Entity(tenant, transaction, chargingStation, transaction.companyID.toString()));
// TODO - No pricing definition? => Throw an exception ? or create dynamically a simple one based on the simple pricing settings?
const resolvedPricingModel: ResolvedPricingModel = {
+ currentContext: {
flatFeeAlreadyPriced: false,
+ startDate: transaction.timestamp
+ },
pricingDefinitions
};
return Promise.resolve(resolvedPricingModel);
@@ -189,15 +192,6 @@ export default class PricingEngine {
const activePricingDefinition = PricingEngine.getActiveDefinition4Dimension(pricingDefinitions, DimensionType.ENERGY);
if (activePricingDefinition) {
const dimensionToPrice = activePricingDefinition.dimensions.energy;
- if (FeatureToggles.isFeatureActive(Feature.PRICING_PRICE_ACCUMULATED_WH)) {
- // POC - Price accumulated consumption
- const pricedData = PricingEngine.priceCumulatedEnergyDimension(dimensionToPrice, consumptionData?.cumulatedConsumptionWh || 0);
- if (pricedData) {
- pricedData.sourceName = activePricingDefinition.name;
- }
- return pricedData;
- }
- // Let's price each consumption individually
const pricedData = PricingEngine.priceEnergyDimension(dimensionToPrice, consumptionData?.consumptionWh || 0);
if (pricedData) {
pricedData.sourceName = activePricingDefinition.name;
@@ -210,13 +204,6 @@ export default class PricingEngine {
const activePricingDefinition = PricingEngine.getActiveDefinition4Dimension(pricingDefinitions, DimensionType.PARKING_TIME);
if (activePricingDefinition) {
const dimensionToPrice = activePricingDefinition.dimensions.parkingTime;
- if (FeatureToggles.isFeatureActive(Feature.PRICING_PRICE_ACCUMULATED_TIME)) {
- const pricedData = PricingEngine.priceTimeBasedDimension(dimensionToPrice, consumptionData?.totalInactivitySecs || 0);
- if (pricedData) {
- pricedData.sourceName = activePricingDefinition.name;
- }
- return pricedData;
- }
const pricedData = PricingEngine.priceParkingTimeDimension(dimensionToPrice, consumptionData);
if (pricedData) {
pricedData.sourceName = activePricingDefinition.name;
@@ -250,48 +237,75 @@ export default class PricingEngine {
quantity: hours
};
// Add the consumption to the previous data (if any) - for the billing
- const previousData = pricingDimension.pricedData;
- if (previousData) {
- previousData.amount += pricedData.amount;
- previousData.quantity += pricedData.quantity;
- previousData.roundedAmount = Utils.truncTo(previousData.amount, 2);
- } else {
- pricingDimension.pricedData = pricedData;
- }
+ PricingEngine.addPricedData(pricingDimension, pricedData);
// Return the current consumption!
return pricedData;
}
- private static priceChargingTimeConsumption(pricingDefinitions: PricingDefinition[], consumptionData: Consumption): PricedDimensionData {
+ private static priceChargingTimeConsumption(pricingDefinitions: PricingDefinition[], consumptionData: Consumption, currentContext: CurrentContext): PricedDimensionData {
const activePricingDefinition = PricingEngine.getActiveDefinition4Dimension(pricingDefinitions, DimensionType.CHARGING_TIME);
if (activePricingDefinition) {
const dimensionToPrice = activePricingDefinition.dimensions.chargingTime;
- if (FeatureToggles.isFeatureActive(Feature.PRICING_PRICE_ACCUMULATED_TIME)) {
- const pricedData = PricingEngine.priceTimeBasedDimension(dimensionToPrice, consumptionData?.totalDurationSecs || 0);
+ const pricedData = PricingEngine.priceChargingTimeDimension(dimensionToPrice, consumptionData, currentContext);
if (pricedData) {
pricedData.sourceName = activePricingDefinition.name;
}
return pricedData;
}
- const pricedData = PricingEngine.priceChargingTimeDimension(dimensionToPrice, consumptionData);
- if (pricedData) {
- pricedData.sourceName = activePricingDefinition.name;
}
- return pricedData;
+
+ private static priceChargingTimeDimensionByStep(pricingDimension: PricingDimension, consumptionData: Consumption, currentContext: CurrentContext): PricedDimensionData {
+ const consumptionWh = consumptionData?.consumptionWh || 0;
+ if (consumptionWh > 0) {
+ const lastStepDate = currentContext.lastStepDate || currentContext.startDate;
+ // Price the charging time only when charging!
+ const timeSpent = moment(consumptionData.endedAt).diff(moment(lastStepDate), 'seconds');
+ const nbSteps = Utils.createDecimal(timeSpent).divToInt(pricingDimension.stepSize).toNumber();
+ if (nbSteps > 0) {
+ currentContext.lastStepDate = consumptionData.endedAt;
+ return this.priceStepForTimeDimension(pricingDimension, nbSteps);
+ }
}
}
- private static priceChargingTimeDimension(pricingDimension: PricingDimension, consumptionData: Consumption): PricedDimensionData {
- const consumptionWh = consumptionData?.consumptionWh || 0;
+ private static priceStepForTimeDimension(pricingDimension: PricingDimension, steps: number): PricedDimensionData {
+ const unitPrice = pricingDimension.price || 0;
+ const amount = Utils.createDecimal(unitPrice).times(steps).times(pricingDimension.stepSize).div(3600).toNumber();
+ // Price the consumption
+ const pricedData: PricedDimensionData = {
+ unitPrice: unitPrice,
+ amount,
+ roundedAmount: Utils.truncTo(amount, 2),
+ quantity: steps
+ };
+ // Add the consumption to the previous data (if any) - for the billing
+ PricingEngine.addPricedData(pricingDimension, pricedData);
+ // Return the current consumption!
+ return pricedData;
+ }
+ private static priceChargingTimeDimension(pricingDimension: PricingDimension, consumptionData: Consumption, currentContext: CurrentContext): PricedDimensionData {
+ const consumptionWh = consumptionData?.consumptionWh || 0;
+ // Price the charging time only when charging!
if (consumptionWh > 0) {
+ // Is there a step size
+ if (pricingDimension.stepSize) {
+ const lastStepDate = currentContext.lastStepDate || currentContext.startDate;
// Price the charging time only when charging!
+ const timeSpent = moment(consumptionData.endedAt).diff(moment(lastStepDate), 'seconds');
+ const nbSteps = Utils.createDecimal(timeSpent).divToInt(pricingDimension.stepSize).toNumber();
+ if (nbSteps > 0) {
+ currentContext.lastStepDate = consumptionData.endedAt;
+ return this.priceStepForTimeDimension(pricingDimension, nbSteps);
+ }
+ } else {
const seconds = moment(consumptionData.endedAt).diff(moment(consumptionData.startedAt), 'seconds');
if (seconds > 0) {
return this.priceTimeDimension(pricingDimension, seconds);
}
}
}
+ }
static getActiveDefinition4Dimension(actualPricingDefinitions: PricingDefinition[], dimensionType: string): PricingDefinition {
// Search for the first pricing definition matching the current dimension type
@@ -351,100 +365,11 @@ export default class PricingEngine {
quantity: consumptionkWh
};
// Add the consumption to the previous data (if any) - for the billing
- const previousData = pricingDimension.pricedData;
- if (previousData) {
- previousData.amount += pricedData.amount;
- previousData.quantity += pricedData.quantity;
- previousData.roundedAmount = Utils.truncTo(previousData.amount, 2);
- } else {
- pricingDimension.pricedData = pricedData;
- }
+ PricingEngine.addPricedData(pricingDimension, pricedData);
// Return the current consumption!
return pricedData;
}
- static priceCumulatedEnergyDimension(pricingDimension: PricingDimension, cumulatedConsumptionWh: number): PricedDimensionData {
- let amount: number;
- let consumptionkWh: number;
- const unitPrice = pricingDimension.price || 0;
- if (pricingDimension.stepSize) { // In kWh
- // TODO - clarify the .plus(1) below - shall we bill the first step?
- const nbSteps = Utils.createDecimal(cumulatedConsumptionWh).div(1000).divToInt(unitPrice).plus(1).toNumber();
- consumptionkWh = Utils.createDecimal(nbSteps).mul(pricingDimension.stepSize).toNumber();
- amount = Utils.createDecimal(unitPrice).mul(consumptionkWh).toNumber();
- } else {
- // Convert to kWh
- consumptionkWh = Utils.createDecimal(cumulatedConsumptionWh).div(1000).toNumber();
- amount = Utils.createDecimal(unitPrice).times(consumptionkWh).toNumber();
- }
- const previousData = pricingDimension.pricedData;
- if (previousData) {
- // The new priced data is the delta
- const delta = Utils.createDecimal(amount).minus(previousData?.amount || 0).toNumber();
- const newData : PricedDimensionData = {
- unitPrice: unitPrice,
- amount: delta,
- roundedAmount: Utils.truncTo(delta, 2),
- quantity: Utils.createDecimal(consumptionkWh).minus(previousData?.quantity || 0).toNumber(),
- };
- // Update the previous data
- previousData.amount = amount;
- previousData.roundedAmount = Utils.truncTo(amount, 2);
- previousData.quantity = consumptionkWh;
- // return the delta
- return newData;
- }
- // first call for this dimension
- pricingDimension.pricedData = {
- unitPrice: unitPrice,
- amount,
- roundedAmount: Utils.truncTo(amount, 2),
- quantity: consumptionkWh
- };
- return pricingDimension.pricedData;
- }
-
- static priceTimeBasedDimension(pricingDimension: PricingDimension, seconds: number): PricedDimensionData {
- let amount: number;
- let hours: number;
- const unitPrice = pricingDimension.price || 0;
- if (pricingDimension.stepSize) { // stepSize is in second
- // bill at least one step
- const nbSteps = Utils.createDecimal(seconds).divToInt(pricingDimension.stepSize).plus(1).toNumber();
- const nbSeconds = Utils.createDecimal(nbSteps).mul(pricingDimension.stepSize).toNumber();
- hours = Utils.createDecimal(nbSeconds).div(3600).toNumber();
- amount = Utils.createDecimal(unitPrice).mul(nbSeconds).div(3600).toNumber();
- } else {
- hours = Utils.createDecimal(seconds).div(3600).toNumber();
- amount = Utils.createDecimal(unitPrice).mul(seconds).div(3600).toNumber();
- }
- const previousData = pricingDimension.pricedData;
- if (previousData) {
- // The new priced data is the delta
- const delta = Utils.createDecimal(amount).minus(previousData?.amount || 0).toNumber();
- const newData : PricedDimensionData = {
- unitPrice: unitPrice,
- roundedAmount: Utils.truncTo(delta, 2),
- amount: delta,
- quantity: Utils.createDecimal(hours).minus(previousData?.quantity || 0).toNumber(),
- };
- // Update the previous data
- previousData.amount = amount;
- previousData.roundedAmount = Utils.truncTo(amount, 2);
- previousData.quantity = hours;
- // return the delta
- return newData;
- }
- // first call for this dimension
- pricingDimension.pricedData = {
- unitPrice: unitPrice,
- amount,
- roundedAmount: Utils.truncTo(amount, 2),
- quantity: hours
- };
- return pricingDimension.pricedData;
- }
-
static priceConsumption(tenant: Tenant, pricingModel: ResolvedPricingModel, consumptionData: Consumption): PricedConsumptionData {
// Check the restrictions to find the pricing definition matching the current context
let actualPricingDefinitions = pricingModel.pricingDefinitions.filter((pricingDefinition) => {
@@ -453,8 +378,7 @@ export default class PricingEngine {
}
// Dynamic restrictions check are not yet supported
return pricingDefinition;
- }
- );
+ });
// Having more than one pricing definition this NOT a normal situation.
// This means that two different tariff matches the same criteria. This should not happen!
if (actualPricingDefinitions.length > 1) {
@@ -462,16 +386,15 @@ export default class PricingEngine {
actualPricingDefinitions = [ actualPricingDefinitions?.[0] ];
}
let flatFee: PricedDimensionData = null;
- if (!pricingModel.flatFeeAlreadyPriced) {
+ if (!pricingModel.currentContext.flatFeeAlreadyPriced) {
// Flat fee must not be priced only once
flatFee = PricingEngine.priceFlatFeeConsumption(actualPricingDefinitions, consumptionData);
- pricingModel.flatFeeAlreadyPriced = !!flatFee;
+ pricingModel.currentContext.flatFeeAlreadyPriced = !!flatFee;
}
// Build the consumption data for each dimension
const energy: PricedDimensionData = PricingEngine.priceEnergyConsumption(actualPricingDefinitions, consumptionData);
- const chargingTime: PricedDimensionData = PricingEngine.priceChargingTimeConsumption(actualPricingDefinitions, consumptionData);
+ const chargingTime: PricedDimensionData = PricingEngine.priceChargingTimeConsumption(actualPricingDefinitions, consumptionData, pricingModel.currentContext);
const parkingTime: PricedDimensionData = PricingEngine.priceParkingTimeConsumption(actualPricingDefinitions, consumptionData);
-
// Return all dimensions
const pricingConsumptionData: PricedConsumptionData = {
flatFee,
@@ -482,6 +405,18 @@ export default class PricingEngine {
return pricingConsumptionData;
}
+ private static addPricedData(pricingDimension: PricingDimension, pricedData: PricedDimensionData): void {
+ // Add the consumption to the previous data (if any) - for the billing
+ const previousData = pricingDimension.pricedData;
+ if (previousData) {
+ previousData.amount += pricedData.amount;
+ previousData.quantity += pricedData.quantity;
+ previousData.roundedAmount = Utils.truncTo(previousData.amount, 2);
+ } else {
+ pricingDimension.pricedData = pricedData;
+ }
+ }
+
static extractFinalPricingData(pricingModel: ResolvedPricingModel): PricedConsumptionData[] {
// Iterate throw the list of pricing definitions
const pricedData: PricedConsumptionData[] = pricingModel.pricingDefinitions.map((pricingDefinition) =>

MODIFY
src/types/Pricing.ts
src/types/Pricing.ts
@@ -21,7 +21,13 @@ export enum DimensionType {
export interface ResolvedPricingModel {
// Put there only the information that is to be kept with the Transaction
pricingDefinitions: PricingDefinition[];
- flatFeeAlreadyPriced: boolean
+ currentContext: CurrentContext;
+}
+
+export interface CurrentContext {
+ flatFeeAlreadyPriced: boolean,
+ startDate: Date,
+ lastStepDate?: Date,
}
export default interface PricingDefinition extends CreatedUpdatedProps, AuthorizationActions {

MODIFY
src/utils/FeatureToggles.ts
src/utils/FeatureToggles.ts
@@ -5,8 +5,6 @@ export enum Feature {
BILLING_CHECK_THRESHOLD_ON_STOP,
BILLING_PREVENT_CUSTOMER_DELETION,
PRICING_NEW_MODEL,
- PRICING_PRICE_ACCUMULATED_WH,
- PRICING_PRICE_ACCUMULATED_TIME,
PRICING_TEST_PARKING_TIME,
PRICING_TEST_ENERGY_WITH_STEP_SIZE,
PRICING_WITH_RESTRICTION_CHECKS,
@@ -20,8 +18,6 @@ export default class FeatureToggles {
Feature.BILLING_PREVENT_CUSTOMER_DELETION,
Feature.PRICING_NEW_MODEL,
Feature.PRICING_WITH_RESTRICTION_CHECKS,
- // Feature.PRICING_PRICE_ACCUMULATED_WH,
- // Feature.PRICING_PRICE_ACCUMULATED_TIME,
// Feature.PRICING_TEST_PARKING_TIME
// Feature.PRICING_TEST_ENERGY_WITH_STEP_SIZE
];

MODIFY
test/api/BillingTest.ts
test/api/BillingTest.ts
@@ -147,14 +147,14 @@ class TestData {
return this.chargingStationContext;
}
- public async initChargingStationContext2TestFastCharger(billChargingTime = false) : Promise<ChargingStationContext> {
+ public async initChargingStationContext2TestFastCharger(testMode = 'E') : Promise<ChargingStationContext> {
this.siteContext = this.tenantContext.getSiteContext(ContextDefinition.SITE_CONTEXTS.SITE_BASIC);
this.siteAreaContext = this.siteContext.getSiteAreaContext(ContextDefinition.SITE_AREA_CONTEXTS.WITH_SMART_CHARGING_DC);
this.chargingStationContext = this.siteAreaContext.getChargingStationContext(ContextDefinition.CHARGING_STATION_CONTEXTS.ASSIGNED_OCPP16 + '-' + ContextDefinition.SITE_CONTEXTS.SITE_BASIC + '-' + ContextDefinition.SITE_AREA_CONTEXTS.WITH_SMART_CHARGING_DC);
assert(!!this.chargingStationContext, 'Charging station context should not be null');
let dimensions: PricingDimensions;
- if (billChargingTime) {
+ if (testMode === 'CT') {
dimensions = {
chargingTime: {
price: 5, // Euro per hour
@@ -165,6 +165,18 @@ class TestData {
active: true
}
};
+ } else if (testMode === 'CT+STEP') {
+ dimensions = {
+ chargingTime: {
+ price: 12, // Euro per hour
+ stepSize: 300, // 300 seconds == 5 minutes
+ active: true
+ },
+ parkingTime: {
+ price: 10, // Euro per hour
+ active: true
+ }
+ };
} else {
dimensions = {
energy: {
@@ -1234,9 +1246,22 @@ describe('Billing Service', function() {
await testData.checkTransactionBillingData(transactionID, BillingInvoiceStatus.PAID);
});
- it('should bill the ChargingTime on COMBO CCS - DC', async () => {
+ it('should bill the CT + PT on COMBO CCS - DC', async () => {
+ // Initialize the charging station context
+ await testData.initChargingStationContext2TestFastCharger('CT');
+
+ await testData.userService.billingApi.forceSynchronizeUser({ id: testData.userContext.id });
+ const userWithBillingData = await testData.billingImpl.getUser(testData.userContext);
+ await testData.assignPaymentMethod(userWithBillingData, 'tok_fr');
+ const transactionID = await testData.generateTransaction(testData.userContext);
+ assert(transactionID, 'transactionID should not be null');
+ // Check that we have a new invoice with an invoiceID and an invoiceNumber
+ await testData.checkTransactionBillingData(transactionID, BillingInvoiceStatus.PAID);
+ });
+
+ it('should bill the CT with 1EUR Step on COMBO CCS - DC', async () => {
// Initialize the charging station context
- await testData.initChargingStationContext2TestFastCharger(true);
+ await testData.initChargingStationContext2TestFastCharger('CT+STEP');
await testData.userService.billingApi.forceSynchronizeUser({ id: testData.userContext.id });
const userWithBillingData = await testData.billingImpl.getUser(testData.userContext);

pricing - step on charging time