pricing - test energy billed using two different tariff for a simgle transaction
MODIFY
src/integration/billing/stripe/StripeBillingIntegration.ts
src/integration/billing/stripe/StripeBillingIntegration.ts
@@ -855,13 +855,14 @@ export default class StripeBillingIntegration extends BillingIntegration {
// session on friday night and keeps charging during the week-end, the two tariffs are used.
// The invoice will show the detail for each tariff and for each billed dimension
// ---------------------------------------------------------------------------------------------------------
+ let counter = 0;
for (const pricingConsumptionData of billingInvoiceItem.pricingData) {
- await this._createStripeInvoiceItems4PricingConsumptionData(customerID, billingInvoiceItem, pricingConsumptionData, invoiceID);
+ await this._createStripeInvoiceItems4PricingConsumptionData(customerID, billingInvoiceItem, pricingConsumptionData, ++counter, invoiceID);
}
}
private async _createStripeInvoiceItems4PricingConsumptionData(customerID: string,
- billingInvoiceItem: BillingInvoiceItem, pricedData: PricedConsumptionData, invoiceID?: string): Promise<void> {
+ billingInvoiceItem: BillingInvoiceItem, pricedData: PricedConsumptionData, counter: number, invoiceID?: string): Promise<void> {
/* --------------------------------------------------------------------------------
Convert pricing information to STRIPE expected data
-----------------------------------------------------------------------------------
@@ -874,14 +875,14 @@ export default class StripeBillingIntegration extends BillingIntegration {
unit_amount_decimal: '004.00' (in Cents, with 2 decimals, as a string)
----------------------------------------------------------------------------------- */
// A stripe invoice item per dimension
- await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.FLAT_FEE, billingInvoiceItem, pricedData, invoiceID);
- await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.CHARGING_TIME, billingInvoiceItem, pricedData, invoiceID);
- await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.ENERGY, billingInvoiceItem, pricedData, invoiceID);
- await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.PARKING_TIME, billingInvoiceItem, pricedData, invoiceID);
+ await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.FLAT_FEE, billingInvoiceItem, pricedData, counter, invoiceID);
+ await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.CHARGING_TIME, billingInvoiceItem, pricedData, counter, invoiceID);
+ await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.ENERGY, billingInvoiceItem, pricedData, counter, invoiceID);
+ await this._createStripeInvoiceItem4Dimension(customerID, DimensionType.PARKING_TIME, billingInvoiceItem, pricedData, counter, invoiceID);
}
private async _createStripeInvoiceItem4Dimension(customerID: string, dimension: string,
- billingInvoiceItem: BillingInvoiceItem, pricedData: PricedConsumptionData, invoiceID?: string): Promise<Stripe.InvoiceItemCreateParams> {
+ billingInvoiceItem: BillingInvoiceItem, pricedData: PricedConsumptionData, counter: number, invoiceID?: string): Promise<Stripe.InvoiceItemCreateParams> {
// data for the current dimension (energy | parkingTime, etc)
const dimensionData: PricedDimensionData = pricedData[dimension];
if (!dimensionData || !dimensionData.amount || !dimensionData.quantity) {
@@ -906,7 +907,8 @@ export default class StripeBillingIntegration extends BillingIntegration {
// STRIPE throws an exception when invoice is set to null.
delete parameters.invoice;
}
- await this._createStripeInvoiceItem(parameters, this.buildIdemPotencyKey(billingInvoiceItem.transactionID, 'invoice', dimension));
+ // Make sure to generate a unique idem potency key per pricing definition and dimension
+ await this._createStripeInvoiceItem(parameters, this.buildIdemPotencyKey(billingInvoiceItem.transactionID, 'invoice', dimension + '-' + counter));
return parameters;
}
@@ -1088,7 +1090,7 @@ export default class StripeBillingIntegration extends BillingIntegration {
}
const energy = pricingConsumptionData.energy;
if (energy) {
- energy.itemDescription = this.buildLineItemDescription(transaction);
+ energy.itemDescription = this.buildLineItemDescription4PricedDimension(transaction, energy);
energy.taxes = taxes;
}
const chargingTime = pricingConsumptionData.chargingTime;
@@ -1215,6 +1217,26 @@ export default class StripeBillingIntegration extends BillingIntegration {
return null;
}
+ private buildLineItemDescription4PricedDimension(transaction: Transaction, pricedData: PricedDimensionData) {
+ const chargeBox = transaction.chargeBox;
+ const i18nManager = I18nManager.getInstanceForLocale(transaction.user.locale);
+ const sessionID = String(transaction?.id);
+ const startDate = i18nManager.formatDateTime(transaction.timestamp, 'LL', transaction.timezone);
+ const startTime = i18nManager.formatDateTime(transaction.timestamp, 'LT', transaction.timezone);
+ const formattedConsumptionkWh = Utils.createDecimal(pricedData.quantity).toNumber().toLocaleString(this.getUserLocale(transaction));
+ const descriptionPattern = (chargeBox?.siteArea?.name) ? 'billing.chargingAtSiteArea' : 'billing.chargingAtChargeBox';
+ // Get the translated line item description
+ const description = i18nManager.translate(descriptionPattern, {
+ sessionID,
+ startDate,
+ startTime,
+ totalConsumption: formattedConsumptionkWh,
+ siteAreaName: chargeBox?.siteArea?.name,
+ chargeBoxID: transaction?.chargeBoxID
+ });
+ return description;
+ }
+
private buildLineItemDescription(transaction: Transaction) {
const chargeBox = transaction.chargeBox;
const i18nManager = I18nManager.getInstanceForLocale(transaction.user.locale);

MODIFY
src/integration/pricing/PricingEngine.ts
src/integration/pricing/PricingEngine.ts
@@ -38,10 +38,8 @@ export default class PricingEngine {
let actualPricingDefinitions = pricingModel.pricingDefinitions.filter((pricingDefinition) =>
PricingEngine.checkPricingDefinitionRestrictions(pricingDefinition, consumptionData)
);
- // 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) {
- // TODO - to be clarified! - Shall we mix several pricing definitions for a single transaction?
+ // It does not make sense to apply several tariffs to a single consumption
actualPricingDefinitions = [ actualPricingDefinitions?.[0] ];
}
// Build the consumption data for each dimension
@@ -182,6 +180,7 @@ export default class PricingEngine {
if (Utils.createDecimal(consumptionData.totalDurationSecs).lessThan(restrictions.minDurationSecs)) {
return false;
}
+ return true;
}
return true;
}

MODIFY
test/api/BillingTest.ts
test/api/BillingTest.ts
@@ -4,7 +4,7 @@ import { BillingChargeInvoiceAction, BillingDataTransactionStop, BillingInvoiceS
import { BillingSettings, BillingSettingsType, SettingDB } from '../../src/types/Setting';
import ChargingStation, { ConnectorType } from '../../src/types/ChargingStation';
import FeatureToggles, { Feature } from '../../src/utils/FeatureToggles';
-import PricingDefinition, { PricingDimensions, PricingEntity } from '../../src/types/Pricing';
+import PricingDefinition, { PricingDimensions, PricingEntity, PricingRestriction } from '../../src/types/Pricing';
import chai, { assert, expect } from 'chai';
import AsyncTaskStorage from '../../src/storage/mongodb/AsyncTaskStorage';
@@ -112,7 +112,7 @@ class TestData {
this.siteAreaContext = this.siteContext.getSiteAreaContext(ContextDefinition.SITE_AREA_CONTEXTS.WITH_SMART_CHARGING_THREE_PHASED);
this.chargingStationContext = this.siteAreaContext.getChargingStationContext(ContextDefinition.CHARGING_STATION_CONTEXTS.ASSIGNED_OCPP16 + '-' + ContextDefinition.SITE_CONTEXTS.SITE_BASIC + '-' + ContextDefinition.SITE_AREA_CONTEXTS.WITH_SMART_CHARGING_THREE_PHASED + '-singlePhased');
assert(!!this.chargingStationContext, 'Charging station context should not be null');
- await this.createTariff4ChargingStation(this.chargingStationContext.getChargingStation(), {
+ await this.createTariff4ChargingStation('test CT', this.chargingStationContext.getChargingStation(), {
flatFee: {
price: 1,
active: true
@@ -130,7 +130,7 @@ class TestData {
this.siteAreaContext = this.siteContext.getSiteAreaContext(ContextDefinition.SITE_AREA_CONTEXTS.WITH_SMART_CHARGING_THREE_PHASED);
this.chargingStationContext = this.siteAreaContext.getChargingStationContext(ContextDefinition.CHARGING_STATION_CONTEXTS.ASSIGNED_OCPP16 + '-' + ContextDefinition.SITE_CONTEXTS.SITE_BASIC + '-' + ContextDefinition.SITE_AREA_CONTEXTS.WITH_SMART_CHARGING_THREE_PHASED);
assert(!!this.chargingStationContext, 'Charging station context should not be null');
- await this.createTariff4ChargingStation(this.chargingStationContext.getChargingStation(), {
+ await this.createTariff4ChargingStation('test FF+E', this.chargingStationContext.getChargingStation(), {
flatFee: {
price: 2,
active: true
@@ -154,6 +154,7 @@ class TestData {
assert(!!this.chargingStationContext, 'Charging station context should not be null');
let dimensions: PricingDimensions;
+ let restrictions: PricingRestriction;
if (testMode === 'CT') {
dimensions = {
chargingTime: {
@@ -178,6 +179,21 @@ class TestData {
active: true
}
};
+ } else if (testMode === 'E-After30mins') {
+ // Create a second tariff with a different pricing strategy
+ dimensions = {
+ energy: {
+ price: 0.30,
+ active: true
+ },
+ parkingTime: {
+ price: 20, // Euro per hour
+ active: true
+ }
+ };
+ restrictions = {
+ minDurationSecs: 30 * 60 // Apply this tariff after 30 minutes
+ };
} else {
dimensions = {
energy: {
@@ -186,7 +202,7 @@ class TestData {
}
};
}
- await this.createTariff4ChargingStation(this.chargingStationContext.getChargingStation(), dimensions, ConnectorType.COMBO_CCS);
+ await this.createTariff4ChargingStation(testMode, this.chargingStationContext.getChargingStation(), dimensions, ConnectorType.COMBO_CCS, restrictions);
return this.chargingStationContext;
}
@@ -522,20 +538,27 @@ class TestData {
// assert(response?.data?.id === pricingDefinitionId, 'The ID should be: ' + pricingDefinitionId);
// }
- public async createTariff4ChargingStation(chargingStation: ChargingStation, dimensions: PricingDimensions, connectorType: ConnectorType = null): Promise<void> {
+ public async createTariff4ChargingStation(
+ testMode: string,
+ chargingStation: ChargingStation,
+ dimensions: PricingDimensions,
+ connectorType: ConnectorType = null,
+ restrictions: PricingRestriction = null): Promise<void> {
+
// Set a default value
connectorType = connectorType || ConnectorType.TYPE_2;
const tariff: Partial<PricingDefinition> = {
entityID: chargingStation.id, // a pricing model for the site
entityType: PricingEntity.CHARGING_STATION,
- name: 'CS Tariff - ' + chargingStation.id,
- description: 'Tariff for CS -' + chargingStation.id,
+ name: 'CS Tariff - ' + testMode + ' - ' + chargingStation.id,
+ description: 'Tariff for CS - ' + testMode + ' - ' + connectorType,
staticRestrictions: {
connectorType,
validFrom: new Date(),
validTo: moment().add(10, 'minutes').toDate()
},
+ restrictions,
dimensions
};
@@ -1235,9 +1258,7 @@ describe('Billing Service', function() {
});
it('should bill the Energy on COMBO CCS - DC', async () => {
- // Initialize the charging station context
await testData.initChargingStationContext2TestFastCharger();
-
await testData.userService.billingApi.forceSynchronizeUser({ id: testData.userContext.id });
const userWithBillingData = await testData.billingImpl.getUser(testData.userContext);
await testData.assignPaymentMethod(userWithBillingData, 'tok_fr');
@@ -1248,9 +1269,7 @@ describe('Billing Service', function() {
});
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');
@@ -1261,9 +1280,22 @@ describe('Billing Service', function() {
});
it('should bill the CT with 1EUR Step on COMBO CCS - DC', async () => {
- // Initialize the charging station context
await testData.initChargingStationContext2TestFastCharger('CT+STEP');
+ 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 ENERGY with 2 tariffs on COMBO CCS - DC', async () => {
+ // A first Tariff for the ENERGY Only
+ await testData.initChargingStationContext2TestFastCharger();
+ // A second Tariff applied after 30 mins!
+ await testData.initChargingStationContext2TestFastCharger('E-After30mins');
+ // A tariff applied immediately
await testData.userService.billingApi.forceSynchronizeUser({ id: testData.userContext.id });
const userWithBillingData = await testData.billingImpl.getUser(testData.userContext);
await testData.assignPaymentMethod(userWithBillingData, 'tok_fr');

pricing - test energy billed using two different tariff for a simgle transaction