billing platform - prepare transfer
ADD
src/async-task/tasks/PrepareInvoiceTransferAsyncTask.ts
+import AbstractAsyncTask from '../AsyncTask';
+import BillingFactory from '../../integration/billing/BillingFactory';
+import { BillingInvoiceStatus } from '../../types/Billing';
+import BillingStorage from '../../storage/mongodb/BillingStorage';
+import LockingHelper from '../../locking/LockingHelper';
+import LockingManager from '../../locking/LockingManager';
+import Logging from '../../utils/Logging';
+import { ServerAction } from '../../types/Server';
+import { TenantComponents } from '../../types/Tenant';
+import TenantStorage from '../../storage/mongodb/TenantStorage';
+import Utils from '../../utils/Utils';
+
+export default class TransferInvoiceAsyncTask extends AbstractAsyncTask {
+ protected async executeAsyncTask(): Promise<void> {
+ const tenant = await TenantStorage.getTenant(this.getAsyncTask().tenantID);
+ // Check if OCPI component is active
+ if (Utils.isTenantComponentActive(tenant, TenantComponents.BILLING_PLATFORM)) {
+ try {
+ const billingImpl = await BillingFactory.getBillingImpl(tenant);
+ if (billingImpl) {
+ // Get the Transaction to bill
+ const invoiceID: string = this.getAsyncTask().parameters.invoiceID;
+ if (!invoiceID) {
+ throw new Error('Unexpected situation - some required parameters are missing');
+ }
+ const lock = await LockingHelper.acquireBillingPrepareInvoiceTransferLock(tenant.id, invoiceID);
+ if (lock) {
+ try {
+ const invoice = await BillingStorage.getInvoice(tenant, invoiceID);
+ if (!invoice) {
+ throw new Error(`Unknown Invoice ID '${invoiceID}'`);
+ }
+ if (invoice.status === BillingInvoiceStatus.PAID) {
+ throw new Error(`Unexpected situation - invoice '${invoiceID}' is not yet PAID`);
+ }
+ // Prepare a transfer per CPO
+ await billingImpl.prepareInvoiceTransfer(invoice);
+ } finally {
+ // Release the lock
+ await LockingManager.release(lock);
+ }
+ } else {
+ throw new Error(`Unexpected situation - concurrent invoice transfer - invoice ID: '${invoiceID}'`);
+ }
+ }
+ } catch (error) {
+ await Logging.logActionExceptionMessage(tenant.id, ServerAction.BILLING_PREPARE_TRANSFER, error);
+ }
+ }
+ }
+}

MODIFY
src/integration/billing/BillingIntegration.ts
src/integration/billing/BillingIntegration.ts
-import { BillingAccount, BillingChargeInvoiceAction, BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingStatus, BillingTax, BillingUser } from '../../types/Billing';
+import { AsyncTaskType, AsyncTasks } from '../../types/AsyncTask';
+import { BillingAccount, BillingChargeInvoiceAction, BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingSessionAccountData, BillingStatus, BillingTax, BillingUser } from '../../types/Billing';
import Tenant, { TenantComponents } from '../../types/Tenant';
import Transaction, { StartTransactionErrorCode } from '../../types/Transaction';
import User, { UserStatus } from '../../types/User';
+import AsyncTaskBuilder from '../../async-task/AsyncTaskBuilder';
import BackendError from '../../exception/BackendError';
import { BillingPeriodicOperationTaskConfig } from '../../types/TaskConfig';
import { BillingSettings } from '../../types/Setting';
@@ -18,7 +20,7 @@ import { Promise } from 'bluebird';
import { Request } from 'express';
import { ServerAction } from '../../types/Server';
import SiteArea from '../../types/SiteArea';
-import TenantStorage from '../../storage/mongodb/TenantStorage';
+import SiteStorage from '../../storage/mongodb/SiteStorage';
import TransactionStorage from '../../storage/mongodb/TransactionStorage';
import UserStorage from '../../storage/mongodb/UserStorage';
import Utils from '../../utils/Utils';
@@ -171,7 +173,6 @@ export default abstract class BillingIntegration {
// Do not send notifications for invoices that are not yet finalized!
if (billingInvoice.status === BillingInvoiceStatus.OPEN || billingInvoice.status === BillingInvoiceStatus.PAID) {
// Send link to the user using our notification framework (link to the front-end + download)
- const tenant = await TenantStorage.getTenant(this.tenant.id);
// Stripe saves amount in cents
const invoiceAmountAsDecimal = new Decimal(billingInvoice.amount).div(100);
// Format amount with currency symbol depending on locale
@@ -183,9 +184,9 @@ export default abstract class BillingIntegration {
billingInvoice.user,
{
user: billingInvoice.user,
- evseDashboardInvoiceURL: Utils.buildEvseBillingInvoicesURL(tenant.subdomain),
- evseDashboardURL: Utils.buildEvseURL(tenant.subdomain),
- invoiceDownloadUrl: Utils.buildEvseBillingDownloadInvoicesURL(tenant.subdomain, billingInvoice.id),
+ evseDashboardInvoiceURL: Utils.buildEvseBillingInvoicesURL(this.tenant.subdomain),
+ evseDashboardURL: Utils.buildEvseURL(this.tenant.subdomain),
+ invoiceDownloadUrl: Utils.buildEvseBillingDownloadInvoicesURL(this.tenant.subdomain, billingInvoice.id),
// Empty url allows to decide wether to display "pay" button in the email
payInvoiceUrl: billingInvoice.status === BillingInvoiceStatus.OPEN ? billingInvoice.payInvoiceUrl : '',
invoiceAmount: invoiceAmount,
@@ -290,6 +291,25 @@ export default abstract class BillingIntegration {
return true;
}
+ protected async triggerTransferPreparation(billingInvoice: BillingInvoice): Promise<void> {
+ // The invoice may include several sessions - let's check if at least one of these needs a transfer
+ const sessions = billingInvoice.sessions.filter((session) => session?.accountData?.withTransferActive);
+ if (sessions.length > 0) {
+ // Create async task to proceed with the transfer of funds
+ await AsyncTaskBuilder.createAndSaveAsyncTasks({
+ name: AsyncTasks.PREPARE_INVOICE_TRANSFER,
+ action: ServerAction.BILLING_PREPARE_TRANSFER,
+ type: AsyncTaskType.TASK,
+ tenantID: this.tenant.id,
+ parameters: {
+ invoiceID: billingInvoice.id
+ },
+ module: MODULE_NAME,
+ method: 'triggerTransferPreparation',
+ });
+ }
+ }
+
protected async updateTransactionsBillingData(billingInvoice: BillingInvoice): Promise<void> {
if (!billingInvoice.sessions) {
// This should not happen - but it happened once!
@@ -369,6 +389,24 @@ export default abstract class BillingIntegration {
return totalDuration;
}
+ protected async retrieveAccountData(transaction: Transaction) : Promise<BillingSessionAccountData> {
+ if (Utils.isTenantComponentActive(this.tenant, TenantComponents.BILLING_PLATFORM)) {
+ const site = await SiteStorage.getSite(this.tenant, transaction.siteID, { withCompany: true });
+ let billingData = site.billingData;
+ if (billingData?.accountID) {
+ billingData = site.company?.billingData;
+ }
+ if (billingData?.accountID) {
+ return {
+ withTransferActive: true,
+ accountID: billingData.accountID,
+ platformFeeStrategy: billingData.platformFeeStrategy
+ };
+ }
+ }
+ return null;
+ }
+
private async _synchronizeUser(user: User, forceMode = false): Promise<BillingUser> {
// Check if we need to create or update a STRIPE customer
let billingUser: BillingUser = null;
@@ -687,4 +725,7 @@ export default abstract class BillingIntegration {
abstract precheckStartTransactionPrerequisites(user: User): Promise<StartTransactionErrorCode[]>;
abstract createSubAccount(): Promise<BillingAccount>;
+
+ abstract prepareInvoiceTransfer(invoice: BillingInvoice): Promise<void>;
+
}

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 { BillingAccount, BillingAccountStatus, BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingStatus, BillingTax, BillingUser, BillingUserData } from '../../../types/Billing';
+import { BillingAccount, BillingAccountStatus, BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingSessionAccountData, BillingStatus, BillingTax, BillingUser, BillingUserData } from '../../../types/Billing';
import { DimensionType, PricedConsumptionData, PricedDimensionData } from '../../../types/Pricing';
import FeatureToggles, { Feature } from '../../../utils/FeatureToggles';
import StripeHelpers, { StripeChargeOperationResult } from './StripeHelpers';
@@ -448,6 +448,10 @@ export default class StripeBillingIntegration extends BillingIntegration {
// Send a notification to the user
void this.sendInvoiceNotification(billingInvoice);
await this.updateTransactionsBillingData(billingInvoice);
+ // Trigger transfer preparation task
+ if (billingInvoice.status === BillingInvoiceStatus.PAID) {
+ await this.triggerTransferPreparation(billingInvoice);
+ }
return billingInvoice;
}
@@ -998,8 +1002,10 @@ export default class StripeBillingIntegration extends BillingIntegration {
module: MODULE_NAME, method: 'billTransaction',
message: `Billing process is about to start - transaction ID: ${transaction.id}`,
});
- // ACHTUNG: a single transaction may generate several lines in the invoice
- const invoiceItem: BillingInvoiceItem = this.convertToBillingInvoiceItem(transaction);
+ // Retrieve billing sub-account settings from the company or the site
+ 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);
// Send a notification to the user
void this.sendInvoiceNotification(billingInvoice);
@@ -1057,28 +1063,26 @@ export default class StripeBillingIntegration extends BillingIntegration {
private shrinkInvoiceItem(fatInvoiceItem: BillingInvoiceItem): BillingInvoiceItem {
// The initial invoice item includes redundant transaction data
- const { transactionID, currency, pricingData } = fatInvoiceItem;
+ const { transactionID, currency, pricingData, accountData = null } = fatInvoiceItem;
// Let's return only essential information
const lightInvoiceItem: BillingInvoiceItem = {
transactionID,
currency,
- pricingData
+ pricingData,
+ accountData
};
return lightInvoiceItem;
}
- private convertToBillingInvoiceItem(transaction: Transaction) : BillingInvoiceItem {
- return this.convertPricingDataToBillingInvoiceItem(transaction);
- }
-
- private convertPricingDataToBillingInvoiceItem(transaction: Transaction) : BillingInvoiceItem {
+ private convertToBillingInvoiceItem(transaction: Transaction, accountData: BillingSessionAccountData) : BillingInvoiceItem {
const transactionID = transaction.id;
const currency = transaction.stop.priceUnit;
- const pricingData: PricedConsumptionData[] = this.extractTransactionPricingData(transaction);
+ const pricingData = this.extractTransactionPricingData(transaction);
const billingInvoiceItem: BillingInvoiceItem = {
transactionID,
currency,
pricingData,
+ accountData,
metadata: {
// Let's keep track of the initial data for troubleshooting purposes
tenantID: this.tenant.id,
@@ -1201,6 +1205,10 @@ export default class StripeBillingIntegration extends BillingIntegration {
}
// We have now a Billing Invoice - Let's update it with details about the last operation result
await StripeHelpers.updateInvoiceAdditionalData(this.tenant, billingInvoice, operationResult, billingInvoiceItem);
+ // Trigger transfer preparation task
+ if (billingInvoice.status === BillingInvoiceStatus.PAID) {
+ await this.triggerTransferPreparation(billingInvoice);
+ }
// Return the billing invoice
return billingInvoice;
}
@@ -1662,10 +1670,10 @@ export default class StripeBillingIntegration extends BillingIntegration {
transaction.billingData.stop.invoiceStatus = billingInvoice.status;
transaction.billingData.stop.invoiceNumber = billingInvoice.number;
transaction.billingData.lastUpdate = new Date();
- // Add pricing data
+ // Add pricing data when built-in pricing engine was used
if (!transaction.billingData.stop.invoiceItem && transaction.pricingModel) {
- // Only set when the built-in pricing model was used
- const invoiceItem = this.convertToBillingInvoiceItem(transaction);
+ const accountData = await this.retrieveAccountData(transaction);
+ const invoiceItem = this.convertToBillingInvoiceItem(transaction, accountData);
transaction.billingData.stop.invoiceItem = this.shrinkInvoiceItem(invoiceItem);
}
// Save repaired billing data
@@ -1725,4 +1733,9 @@ export default class StripeBillingIntegration extends BillingIntegration {
status: BillingAccountStatus.IDLE
};
}
+
+ public async prepareInvoiceTransfer(invoice: BillingInvoice): Promise<void> {
+ // do it ;)
+ }
+
}

MODIFY
src/integration/billing/stripe/StripeHelpers.ts
src/integration/billing/stripe/StripeHelpers.ts
@@ -33,6 +33,7 @@ export default class StripeHelpers {
session = {
transactionID: billingInvoiceItem.transactionID,
pricingData: billingInvoiceItem.pricingData,
+ accountData: billingInvoiceItem.accountData,
};
}
// Is there anything to update?

MODIFY
src/locking/LockingHelper.ts
src/locking/LockingHelper.ts
@@ -72,22 +72,23 @@ export default class LockingHelper {
return lock;
}
- public static async acquireBillingSyncInvoicesLock(tenantID: string): Promise<Lock | null> {
- const lock = LockingManager.createExclusiveLock(tenantID, LockEntity.INVOICE, 'synchronize-billing-invoices');
+ public static async acquireBillingPeriodicOperationLock(tenantID: string): Promise<Lock | null> {
+ const lock = LockingManager.createExclusiveLock(tenantID, LockEntity.INVOICE, 'periodic-billing');
if (!(await LockingManager.acquire(lock))) {
return null;
}
return lock;
}
- public static async acquireBillingPeriodicOperationLock(tenantID: string): Promise<Lock | null> {
- const lock = LockingManager.createExclusiveLock(tenantID, LockEntity.INVOICE, 'periodic-billing');
+ public static async acquireBillingPrepareInvoiceTransferLock(tenantID: string, invoiceID: string): Promise<Lock | null> {
+ const lock = LockingManager.createExclusiveLock(tenantID, LockEntity.INVOICE, `prepare-invoice-transfer-${invoiceID}`);
if (!(await LockingManager.acquire(lock))) {
return null;
}
return lock;
}
+
public static async acquireAssetRetrieveConsumptionsLock(tenantID: string, asset: Asset): Promise<Lock | null> {
const lock = LockingManager.createExclusiveLock(tenantID, LockEntity.ASSET, `${asset.id}-consumptions`);
if (!(await LockingManager.acquire(lock))) {

MODIFY
src/types/AsyncTask.ts
src/types/AsyncTask.ts
@@ -32,6 +32,7 @@ export enum AsyncTaskStatus {
export enum AsyncTasks {
BILL_TRANSACTION = 'BillTransactionAsyncTask',
+ PREPARE_INVOICE_TRANSFER = 'PrepareInvoiceTransferAsyncTask',
TAGS_IMPORT = 'TagsImportAsyncTask',
USERS_IMPORT = 'UsersImportAsyncTask',
OCPI_PUSH_TOKENS = 'OCPIPushTokensAsyncTask',

MODIFY
src/types/Billing.ts
src/types/Billing.ts
@@ -88,6 +88,7 @@ export interface BillingInvoiceItem {
transactionID: number;
currency: string;
pricingData: PricedConsumptionData[]
+ accountData: BillingSessionAccountData; // Each session may target a distinct sub-account - but the 4 pricing dimensions MUST share the same info
headerDescription?: string,
metadata?: {
// Just a flat list of key/value pairs!
@@ -98,7 +99,7 @@ export interface BillingInvoiceItem {
export interface BillingSessionData {
transactionID: number;
pricingData: PricedConsumptionData[];
- transferData?: BillingTransferData; // Each session may target a distinct sub-account
+ accountData: BillingSessionAccountData; // Each session may target a distinct sub-account
}
export enum BillingInvoiceStatus {
@@ -175,13 +176,11 @@ export interface BillingAccount extends AuthorizationActions {
export interface BillingAccountData {
accountID: string;
- platformFee?: BillingPlatformFeeStrategy;
+ platformFeeStrategy?: BillingPlatformFeeStrategy;
}
-export interface BillingTransferData extends BillingAccountData {
+export interface BillingSessionAccountData extends BillingAccountData {
withTransferActive: boolean
- transferID?: string;
- lastUpdate?: Date;
}
export enum BillingTransferStatus {

MODIFY
src/types/Server.ts
src/types/Server.ts
@@ -437,6 +437,7 @@ export enum ServerAction {
BILLING = 'Billing',
BILLING_TRANSACTION = 'BillingTransaction',
+ BILLING_PREPARE_TRANSFER = 'BillingPrepareTransfer',
BILLING_SYNCHRONIZE_USER = 'BillingSynchronizeUser',
BILLING_FORCE_SYNCHRONIZE_USER = 'BillingForceSynchronizeUser',
CHECK_BILLING_CONNECTION = 'CheckBillingConnection',

billing platform - prepare transfer