pricing/billing = the missing link
MODIFY
src/integration/billing/stripe/StripeBillingIntegration.ts
src/integration/billing/stripe/StripeBillingIntegration.ts
import { AsyncTaskType, AsyncTasks } from '../../../types/AsyncTask';
/* eslint-disable @typescript-eslint/member-ordering */
-import { BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingStatus, BillingTax, BillingUser, BillingUserData, DimensionPricingData } from '../../../types/Billing';
+import { BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingStatus, BillingTax, BillingUser, BillingUserData } from '../../../types/Billing';
import FeatureToggles, { Feature } from '../../../utils/FeatureToggles';
import StripeHelpers, { StripeChargeOperationResult } from './StripeHelpers';
import Transaction, { StartTransactionErrorCode } from '../../../types/Transaction';
@@ -16,6 +16,7 @@ import Constants from '../../../utils/Constants';
import Cypher from '../../../utils/Cypher';
import I18nManager from '../../../utils/I18nManager';
import Logging from '../../../utils/Logging';
+import { PricingDimensionData } from '../../../types/Pricing';
import { Request } from 'express';
import { ServerAction } from '../../../types/Server';
import SettingStorage from '../../../storage/mongodb/SettingStorage';
@@ -879,7 +880,7 @@ export default class StripeBillingIntegration extends BillingIntegration {
billingInvoiceItem: BillingInvoiceItem, invoiceID?: string): Promise<Stripe.InvoiceItemCreateParams> {
const pricingData = billingInvoiceItem.pricingData;
// data for the current dimension (energy | parkingTime, etc)
- const dimensionData: DimensionPricingData = pricingData[dimension];
+ const dimensionData: PricingDimensionData = pricingData[dimension];
if (!dimensionData || !dimensionData.amount || !dimensionData.quantity) {
// Do not bill that dimension
return null;

ADD
src/integration/pricing/PricingEngine.ts
+import { PricingConsumptionData, PricingDimensionData, ResolvedPricingModel } from '../../types/Pricing';
+
+import PricingStorage from '../../storage/mongodb/PricingStorage';
+import Tenant from '../../types/Tenant';
+import Transaction from '../../types/Transaction';
+import Utils from '../../utils/Utils';
+
+// --------------------------------------------------------------------------------------------------
+// TODO - POC - PricingEngine is hidden behind a feature toggle
+// --------------------------------------------------------------------------------------------------
+export default class PricingEngine {
+
+ static async resolvePricingContext(tenant: Tenant, transaction: Transaction): Promise<ResolvedPricingModel> {
+
+ // -----------------------------------------------------------------------------------------
+ // TODO - We need to find the pricing model to apply by resolving the hierarchy of contexts
+ // that may override (or extend) the pricing definitions.
+ // Forseen hierarchy is:
+ // - Tenant/Organization
+ // - Company
+ // - Site
+ // - Site Area
+ // - Charging Station
+ // - User Group
+ // - User
+ // Of course, the date has an impact as well
+ // -----------------------------------------------------------------------------------------
+ // First implementation:
+ // - we only have a single pricing model which is defined for the tenant
+ // - we simply get the latest created one
+ // -----------------------------------------------------------------------------------------
+ let pricingModel: ResolvedPricingModel = null;
+ const pricingModelResults = await PricingStorage.getPricingModels(tenant, {}, { limit: 1, skip: 0, sort: { createdOn: -1 } });
+ if (pricingModelResults.count > 0) {
+ const { pricingDefinitions } = pricingModelResults.result[0];
+ pricingModel = {
+ pricingDefinitions
+ };
+ }
+ // TODO - No pricing definition? => Throw an exception ?
+ return Promise.resolve(pricingModel);
+ }
+
+ static async priceFinalConsumption(tenant: Tenant, transaction: Transaction): Promise<PricingConsumptionData> {
+ // -------------------------------------------------------------------------------
+ // TODO - POC - We cannot get data from transaction.stop - it is not yet set!
+ // const { roundedPrice, totalConsumptionWh } = transaction.stop;
+ // -------------------------------------------------------------------------------
+ const roundedPrice = Utils.truncTo(transaction.currentCumulatedPrice, 2);
+ const totalConsumptionWh = transaction.currentTotalConsumptionWh;
+ const quantity = Utils.createDecimal(totalConsumptionWh).dividedBy(1000).toNumber(); // Total consumption in kW.h
+ const amount = roundedPrice; // Total amount for the line item
+ // TODO - POC - take the pricingModel into consideration
+ // Build the consumption data for each dimension
+ const energy: PricingDimensionData = {
+ amount,
+ quantity
+ };
+ // const parkingTime: PricingDimensionData = {
+ // itemDescription,
+ // amount,
+ // quantity
+ // };
+ // For now we can have up to 4 dimensions
+ const pricingConsumptionData: PricingConsumptionData = {
+ // flatFee,
+ energy,
+ // chargingTime,
+ // parkingTime,
+ };
+ return Promise.resolve(pricingConsumptionData);
+ }
+}
+

MODIFY
src/integration/pricing/PricingFactory.ts
src/integration/pricing/PricingFactory.ts
@@ -15,7 +15,7 @@ export default class PricingFactory {
static async getPricingImpl(tenant: Tenant): Promise<PricingIntegration<PricingSetting>> {
// Check if the pricing is active
if (Utils.isTenantComponentActive(tenant, TenantComponents.PRICING)) {
- // Get the pricing's settings
+ // Get the pricing settings
const pricingSetting = await SettingStorage.getPricingSettings(tenant);
// Check
if (pricingSetting) {

MODIFY
src/integration/pricing/simple-pricing/BuiltInPricingIntegration.ts
src/integration/pricing/simple-pricing/BuiltInPricingIntegration.ts
-import { BuiltInPricedConsumption, PricedConsumption, PricingSource, ResolvedPricingModel } from '../../../types/Pricing';
+import { PricedConsumption, PricingSource } from '../../../types/Pricing';
import Consumption from '../../../types/Consumption';
+import PricingEngine from '../PricingEngine';
import PricingIntegration from '../PricingIntegration';
-import PricingStorage from '../../../storage/mongodb/PricingStorage';
import { SimplePricingSetting } from '../../../types/Setting';
import Tenant from '../../../types/Tenant';
import Transaction from '../../../types/Transaction';
import Utils from '../../../utils/Utils';
+// --------------------------------------------------------------------------------------------------
+// TODO - POC - BuiltInPricingIntegration is hidden behind a feature toggle
+// This concrete implementation of the PricingIntegration still relies on the SimplePricingSettings
+// --------------------------------------------------------------------------------------------------
export default class BuiltInPricingIntegration extends PricingIntegration<SimplePricingSetting> {
constructor(tenant: Tenant, readonly settings: SimplePricingSetting) {
super(tenant, settings);
@@ -15,12 +19,8 @@ export default class BuiltInPricingIntegration extends PricingIntegration<Simple
public async startSession(transaction: Transaction, consumptionData: Consumption): Promise<PricedConsumption> {
const pricedConsumption = await this.computePrice(transaction, consumptionData);
- const pricingModel = await this.resolvePricingContext(transaction);
- const builtInPricedConsumption: BuiltInPricedConsumption = {
- ...pricedConsumption,
- pricingModel
- };
- return Promise.resolve(builtInPricedConsumption);
+ pricedConsumption.pricingModel = await PricingEngine.resolvePricingContext(this.tenant, transaction);
+ return Promise.resolve(pricedConsumption);
}
public async updateSession(transaction: Transaction, consumptionData: Consumption): Promise<PricedConsumption> {
@@ -28,7 +28,9 @@ export default class BuiltInPricingIntegration extends PricingIntegration<Simple
}
public async stopSession(transaction: Transaction, consumptionData: Consumption): Promise<PricedConsumption> {
- return this.computePrice(transaction, consumptionData);
+ const pricedConsumption = await this.computePrice(transaction, consumptionData);
+ pricedConsumption.pricingConsumptionData = await PricingEngine.priceFinalConsumption(this.tenant, transaction);
+ return Promise.resolve(pricedConsumption);
}
private async computePrice(transaction: Transaction, consumptionData: Consumption): Promise<PricedConsumption> {
@@ -50,34 +52,4 @@ export default class BuiltInPricingIntegration extends PricingIntegration<Simple
};
return Promise.resolve(pricedConsumption);
}
-
- private async resolvePricingContext(transaction: Transaction): Promise<ResolvedPricingModel> {
- // -----------------------------------------------------------------------------------------
- // TODO - We need to find the pricing model to apply by resolving the hierarchy of contexts
- // that may override (or extend) the pricing definitions.
- // Forseen hierarchy is:
- // - Tenant/Organization
- // - Company
- // - Site
- // - Site Area
- // - Charging Station
- // - User Group
- // - User
- // Of course, the date has an impact as well
- // -----------------------------------------------------------------------------------------
- // First implementation:
- // - we only have a single pricing model which is defined for the tenant
- // - we simply get the latest created one
- // -----------------------------------------------------------------------------------------
- let pricingModel: ResolvedPricingModel = null;
- const pricingModelResults = await PricingStorage.getPricingModels(this.tenant, {}, { limit: 1, skip: 0, sort: { createdOn: -1 } });
- if (pricingModelResults.count > 0) {
- const { pricingDefinitions } = pricingModelResults.result[0];
- pricingModel = {
- pricingDefinitions
- };
- }
- // TODO - No pricing definition? => Throw an exception ?
- return Promise.resolve(pricingModel);
- }
}

MODIFY
src/server/ocpp/utils/OCPPUtils.ts
src/server/ocpp/utils/OCPPUtils.ts
import { BillingDataTransactionStart, BillingDataTransactionStop } from '../../../types/Billing';
-import { BuiltInPricedConsumption, PricedConsumption } from '../../../types/Pricing';
import { ChargingProfile, ChargingProfilePurposeType } from '../../../types/ChargingProfile';
import ChargingStation, { ChargingStationCapabilities, ChargingStationOcppParameters, ChargingStationTemplate, Connector, ConnectorCurrentLimitSource, CurrentType, OcppParameter, SiteAreaLimitSource, StaticLimitAmps, TemplateUpdate, TemplateUpdateResult } from '../../../types/ChargingStation';
import FeatureToggles, { Feature } from '../../../utils/FeatureToggles';
@@ -30,6 +29,7 @@ import OCPPStorage from '../../../storage/mongodb/OCPPStorage';
import OICPClientFactory from '../../../client/oicp/OICPClientFactory';
import { OICPRole } from '../../../types/oicp/OICPRole';
import OICPUtils from '../../oicp/OICPUtils';
+import { PricedConsumption } from '../../../types/Pricing';
import PricingFactory from '../../../integration/pricing/PricingFactory';
import { PricingSettingsType } from '../../../types/Setting';
import RegistrationToken from '../../../types/RegistrationToken';
@@ -254,7 +254,8 @@ export default class OCPPUtils {
transaction.pricingSource = pricedConsumption.pricingSource;
transaction.currentCumulatedPrice = pricedConsumption.amount;
if (FeatureToggles.isFeatureActive(Feature.PRICING_NEW_MODEL)) {
- transaction.pricingModel = (pricedConsumption as BuiltInPricedConsumption)?.pricingModel;
+ // TODO - To be clarified - check for the PricingSource instead?
+ transaction.pricingModel = pricedConsumption.pricingModel;
}
}
break;
@@ -284,6 +285,10 @@ export default class OCPPUtils {
consumption.pricingSource = pricedConsumption.pricingSource;
consumption.cumulatedAmount = pricedConsumption.cumulatedAmount;
transaction.currentCumulatedPrice = consumption.cumulatedAmount;
+ if (FeatureToggles.isFeatureActive(Feature.PRICING_NEW_MODEL)) {
+ // TODO - To be clarified - check for the PricingSource instead?
+ transaction.pricingConsumptionData = pricedConsumption.pricingConsumptionData;
+ }
}
break;
}

MODIFY
src/storage/mongodb/TransactionStorage.ts
src/storage/mongodb/TransactionStorage.ts
@@ -70,6 +70,7 @@ export default class TransactionStorage {
priceUnit: transactionToSave.priceUnit,
pricingSource: transactionToSave.pricingSource,
pricingModel: transactionToSave.pricingModel,
+ pricingConsumptionData: transactionToSave.pricingConsumptionData,
stateOfCharge: transactionToSave.stateOfCharge,
timezone: transactionToSave.timezone,
signedData: transactionToSave.signedData,

MODIFY
src/types/Billing.ts
src/types/Billing.ts
import { ActionsResponse } from './GlobalType';
+import { PricingConsumptionData } from './Pricing';
import User from './User';
export interface TransactionBillingData {
@@ -83,7 +84,7 @@ export interface BillingInvoice {
export interface BillingInvoiceItem {
transactionID: number;
currency: string;
- pricingData: BillingPricingData
+ pricingData: PricingConsumptionData
metadata?: {
// Just a flat list of key/value pairs!
[name: string]: string | number | null;
@@ -92,7 +93,7 @@ export interface BillingInvoiceItem {
export interface BillingSessionData {
transactionID: number;
- pricingData: BillingPricingData;
+ pricingData: PricingConsumptionData;
}
export enum BillingInvoiceStatus {
@@ -148,20 +149,3 @@ export interface BillingAdditionalData {
lastError?: BillingError,
}
-export interface BillingPricingData {
- flatFee?: DimensionPricingData,
- energy?: DimensionPricingData,
- parkingTime?: DimensionPricingData,
- chargingTime?: DimensionPricingData,
-}
-
-export interface DimensionPricingData {
- itemDescription: string,
- amount: number;
- quantity: number;
- cumulatedAmount?: number;
- roundedAmount?: number;
- // Each invoice item may have a different tax rate
- // And STRIPE allows more than one for each of them, so this is an array!
- taxes?: string[];
-}

MODIFY
src/types/Pricing.ts
src/types/Pricing.ts
@@ -61,10 +61,8 @@ export interface PricedConsumption {
roundedAmount: number;
currencyCode: string;
pricingSource: PricingSource;
-}
-
-export interface BuiltInPricedConsumption extends PricedConsumption {
- pricingModel: ResolvedPricingModel
+ pricingModel?: ResolvedPricingModel
+ pricingConsumptionData?: PricingConsumptionData
}
export enum PricingSource {
@@ -72,3 +70,20 @@ export enum PricingSource {
CONVERGENT_CHARGING = 'convergentCharging',
OCPI = 'ocpi',
}
+
+export interface PricingConsumptionData {
+ flatFee?: PricingDimensionData,
+ energy?: PricingDimensionData,
+ parkingTime?: PricingDimensionData,
+ chargingTime?: PricingDimensionData,
+}
+
+export interface PricingDimensionData {
+ amount: number;
+ quantity: number;
+ // The item description is generated while billing the transaction
+ itemDescription?: string,
+ // Each dimension have a different tax rate
+ taxes?: string[];
+}
+

MODIFY
src/types/Transaction.ts
src/types/Transaction.ts
import { Car, CarCatalog } from './Car';
import { ChargePointStatus, OCPP15TransactionData, OCPPMeterValue } from './ocpp/OCPPServer';
import Consumption, { AbstractCurrentConsumption } from './Consumption';
-import PricingModel, { ResolvedPricingModel } from './Pricing';
+import PricingModel, { PricingConsumptionData, ResolvedPricingModel } from './Pricing';
import ChargingStation from '../types/ChargingStation';
import Company from './Company';
@@ -83,6 +83,7 @@ export default interface Transaction extends AbstractCurrentConsumption {
priceUnit?: string;
pricingSource?: string;
pricingModel?: ResolvedPricingModel,
+ pricingConsumptionData?: PricingConsumptionData,
stateOfCharge: number;
timezone: string;
currentTimestamp?: Date;

MODIFY
test/api/BillingStripeTestData.ts
test/api/BillingStripeTestData.ts
-import { BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPricingData, BillingUser, BillingUserData } from '../../src/types/Billing';
+import { BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingUser, BillingUserData } from '../../src/types/Billing';
import { BillingSettings, BillingSettingsType, SettingDB } from '../../src/types/Setting';
import FeatureToggles, { Feature } from '../../src/utils/FeatureToggles';
-import PricingModel, { PricingDefinition, PricingDimension, PricingRestriction } from '../../src/types/Pricing';
+import PricingModel, { PricingConsumptionData, PricingDefinition, PricingDimension, PricingRestriction } from '../../src/types/Pricing';
import chai, { assert, expect } from 'chai';
import BillingStorage from '../../src/storage/mongodb/BillingStorage';
@@ -277,7 +277,7 @@ export default class StripeIntegrationTestData {
// array of tax ids to apply to the line item
const taxes = (consumptionTestData.taxId) ? [ consumptionTestData.taxId ] : [];
// Pricing/Consumption Data
- const billingPricingData: BillingPricingData = {
+ const pricingConsumptionData: PricingConsumptionData = {
energy: {
itemDescription: `Energy consumption - ${consumptionTestData.energyConsumptionkWh} kWh * ${consumptionTestData.energyAmount / consumptionTestData.energyConsumptionkWh} Eur`,
amount: consumptionTestData.energyAmount, // total amount to bill - not yet in cents
@@ -295,7 +295,7 @@ export default class StripeIntegrationTestData {
const invoiceItem:BillingInvoiceItem = {
currency: 'EUR',
transactionID: Utils.getRandomIntSafe(),
- pricingData: billingPricingData
+ pricingData: pricingConsumptionData
};
// Let's attempt to bill the line item
const billingInvoice: BillingInvoice = await this.billingImpl.billInvoiceItem(this.dynamicUser, invoiceItem);

pricing/billing = the missing link