Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@
],
"plugin": "@salesforce/plugin-bre-to-cml"
},
{
"alias": [],
"command": "cml:convert:surcharge-rules",
"flagAliases": [],
"flagChars": ["c", "d", "f", "o", "s"],
"flags": [
"api-version",
"cml-api",
"flags-dir",
"json",
"surcharge-file",
"surcharge-ids",
"target-org",
"workspace-dir"
],
"plugin": "@salesforce/plugin-bre-to-cml"
},
{
"alias": [],
"command": "cml:convert:underwriting-rules",
"flagAliases": [],
"flagChars": ["c", "d", "f", "o", "s"],
"flags": ["api-version", "cml-api", "flags-dir", "json", "target-org", "uw-file", "uw-ids", "workspace-dir"],
"plugin": "@salesforce/plugin-bre-to-cml"
},
{
"alias": [],
"command": "cml:import:as-expression-set",
Expand Down
34 changes: 34 additions & 0 deletions messages/cml.convert.surcharge-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# summary

Converts BRE-based Product Surcharge dynamic rules to CML eligibility constraints.

# description

Reads ProductSurcharge records from the org (or a JSON file), parses their RuleDefinition, and generates CML constraints that evaluate surcharge eligibility. Each surcharge rule becomes a named constraint that returns true/false.

The command outputs:

- A .cml file with the constraint model
- An \_Associations.csv file for ExpressionSetConstraintObj records
- A \_RuleKeyMapping.json with the ProductSurcharge ID to RuleKey mapping for updating records

# examples

- <%= config.bin %> <%= command.id %> --cml-api SURCHARGE_CML --target-org myOrg
- <%= config.bin %> <%= command.id %> --cml-api SURCHARGE_CML --surcharge-file data/surcharges.json --workspace-dir data --target-org myOrg

# flags.cml-api.summary

Unique CML API Name to be created.

# flags.workspace-dir.summary

Directory where output files will be written.

# flags.surcharge-file.summary

Optional JSON file with pre-exported ProductSurcharge records. If omitted, records are queried from the org.

# flags.surcharge-ids.summary

Comma-separated list of ProductSurcharge record IDs to convert. If omitted, all records with BRE rules are converted.
34 changes: 34 additions & 0 deletions messages/cml.convert.underwriting-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# summary

Converts BRE-based Insurance Underwriting dynamic rules to CML eligibility constraints.

# description

Reads UnderwritingRule records from the org (or a JSON file), parses their DynamicRuleDefinition, and generates CML constraints that evaluate underwriting eligibility. Each rule becomes a named constraint that returns true/false.

The command outputs:

- A .cml file with the constraint model
- An \_Associations.csv file for ExpressionSetConstraintObj records
- A \_RuleKeyMapping.json with the UnderwritingRule ID to RuleKey mapping for updating records

# examples

- <%= config.bin %> <%= command.id %> --cml-api UW_CML --target-org myOrg
- <%= config.bin %> <%= command.id %> --cml-api UW_CML --uw-file data/underwriting.json --workspace-dir data --target-org myOrg

# flags.cml-api.summary

Unique CML API Name to be created.

# flags.workspace-dir.summary

Directory where output files will be written.

# flags.uw-file.summary

Optional JSON file with pre-exported UnderwritingRule records. If omitted, records are queried from the org.

# flags.uw-ids.summary

Comma-separated list of UnderwritingRule record IDs to convert. If omitted, all records with dynamic rules are converted.
196 changes: 196 additions & 0 deletions src/commands/cml/convert/surcharge-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from 'node:fs/promises';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages, Connection } from '@salesforce/core';
import { CmlModel } from '../../../shared/types/types.js';
import { generateCsvForAssociations } from '../../../shared/utils/association.utils.js';
import {
ParsedRuleDefinition,
RuleRecord,
RuleKeyEntry,
fetchProductCodes,
buildCmlModel,
} from '../../../shared/insurance-rule-converter.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-bre-to-cml', 'cml.convert.surcharge-rules');

type ProductSurchargeRecord = RuleRecord & {
RuleApiName: string | null;
RuleDefinition: string | null;
};

export type CmlConvertSurchargeRulesResult = {
cmlFile: string;
associationsFile: string;
ruleKeyMapping: RuleKeyEntry[];
};

export default class CmlConvertSurchargeRules extends SfCommand<CmlConvertSurchargeRulesResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');

public static readonly flags = {
'target-org': Flags.requiredOrg(),
'api-version': Flags.orgApiVersion(),
'cml-api': Flags.string({
summary: messages.getMessage('flags.cml-api.summary'),
char: 'c',
required: true,
}),
'workspace-dir': Flags.directory({
summary: messages.getMessage('flags.workspace-dir.summary'),
char: 'd',
exists: true,
}),
'surcharge-file': Flags.file({
summary: messages.getMessage('flags.surcharge-file.summary'),
char: 'f',
exists: true,
}),
'surcharge-ids': Flags.string({
summary: messages.getMessage('flags.surcharge-ids.summary'),
char: 's',
}),
};

public async run(): Promise<CmlConvertSurchargeRulesResult> {
const { flags } = await this.parse(CmlConvertSurchargeRules);

const api = flags['cml-api'];
const workspaceDir = flags['workspace-dir'] ?? '.';
const targetOrg = flags['target-org'];
const safeApi = api.replace(/[^a-zA-Z0-9_-]/g, '_');

const records = await this.loadRecords(flags, targetOrg);
if (records.length === 0) {
this.log('No surcharge rules to convert.');
return { cmlFile: '', associationsFile: '', ruleKeyMapping: [] };
}

const ruleDefs = this.parseRuleDefinitions(records);
const productIdToCode = await this.resolveProductCodes(ruleDefs, targetOrg, flags);
const { cmlModel, ruleKeyMapping } = buildCmlModel(ruleDefs, productIdToCode, 'SC', 'Surcharge eligibility');
ruleKeyMapping.forEach((m) => this.log(` -> ${m.name} => ${m.ruleKey}`));

return this.writeOutputFiles(cmlModel, ruleKeyMapping, safeApi, workspaceDir, api);
}

private async loadRecords(
flags: Record<string, unknown>,
targetOrg: { getConnection: (v?: string) => Connection }
): Promise<ProductSurchargeRecord[]> {
const surchargeFile = flags['surcharge-file'] as string | undefined;
if (surchargeFile) {
this.log(`Reading surcharges from file: ${surchargeFile}`);
const contents = await fs.readFile(surchargeFile, 'utf8');
return JSON.parse(contents) as ProductSurchargeRecord[];
}

this.log('Querying ProductSurcharge records from org...');
const conn = targetOrg.getConnection(flags['api-version'] as string | undefined);
const surchargeIds = flags['surcharge-ids'] as string | undefined;
let soql =
'SELECT Id, Name, RuleApiName, RuleDefinition, ProductPath FROM ProductSurcharge WHERE RuleApiName != null';
if (surchargeIds) {
const idList = surchargeIds
.split(',')
.map((id) => `'${id.trim()}'`)
.join(',');
soql += ` AND Id IN (${idList})`;
}
const result = await conn.query<ProductSurchargeRecord>(soql);
this.log(`Found ${result.records.length} ProductSurcharge records with BRE rules`);
return result.records;
}

private parseRuleDefinitions(
records: ProductSurchargeRecord[]
): Array<{ record: RuleRecord; ruleDef: ParsedRuleDefinition }> {
const parsed: Array<{ record: RuleRecord; ruleDef: ParsedRuleDefinition }> = [];
for (const record of records) {
if (!record.RuleDefinition) {
this.warn(`Skipping ${record.Name}: no RuleDefinition`);
continue;
}
try {
const raw = JSON.parse(record.RuleDefinition) as { ruleApiName?: string; ruleCriteria?: unknown[] };
parsed.push({
record,
ruleDef: {
...raw,
name: record.Name,
apiName: raw.ruleApiName ?? record.Name,
productPath: record.ProductPath,
} as ParsedRuleDefinition,
});
} catch {
this.warn(`Failed to parse RuleDefinition for ${record.Name}`);
}
}
this.log(`Parsed ${parsed.length} valid rule definitions`);
return parsed;
}

private async resolveProductCodes(
ruleDefs: Array<{ record: RuleRecord }>,
targetOrg: { getConnection: (v?: string) => Connection },
flags: Record<string, unknown>
): Promise<Map<string, string>> {
const productIds = new Set<string>();
for (const { record } of ruleDefs) {
productIds.add(record.ProductPath.split('/')[0]);
}
try {
const conn = targetOrg.getConnection(flags['api-version'] as string | undefined);
return await fetchProductCodes(conn, productIds);
} catch (e) {
this.warn(`Could not fetch product codes: ${(e as Error).message}. Using product IDs instead.`);
return new Map<string, string>();
}
}

private async writeOutputFiles(
cmlModel: CmlModel,
ruleKeyMapping: RuleKeyEntry[],
safeApi: string,
workspaceDir: string,
api: string
): Promise<CmlConvertSurchargeRulesResult> {
const cmlPath = `${workspaceDir}/${safeApi}.cml`;
const associationsPath = `${workspaceDir}/${safeApi}_Associations.csv`;
const mappingPath = `${workspaceDir}/${safeApi}_RuleKeyMapping.json`;

await fs.writeFile(cmlPath, cmlModel.generateCml(), 'utf8');
await fs.writeFile(associationsPath, generateCsvForAssociations(safeApi, cmlModel.associations), 'utf8');
await fs.writeFile(mappingPath, JSON.stringify(ruleKeyMapping, null, 2), 'utf8');

this.log(`\nCML written to: ${cmlPath}`);
this.log(`Associations written to: ${associationsPath}`);
this.log(`Rule key mapping written to: ${mappingPath}`);
this.log(`\nConverted ${ruleKeyMapping.length} rules to CML`);
this.log('\nNext steps:');
this.log(' 1. Review the generated .cml file');
this.log(
` 2. Import: sf cml import as-expression-set --cml-api ${api} --context-definition <CD_NAME> --target-org <org>`
);
this.log(' 3. Update records with RuleEngineType and RuleKey from mapping file');

return { cmlFile: cmlPath, associationsFile: associationsPath, ruleKeyMapping };
}
}
Loading