返回 Skills
jaganpro/sf-skills· MIT 内容可用

sf-industry-commoncore-callable-apex

Salesforce Industries Common Core (OmniStudio/Vlocity) Apex callable generation and review with 120-point scoring. TRIGGER when: user creates or reviews System.Callable classes, migrates `VlocityOpenInterface` / `VlocityOpenInterface2`, or builds Industries callable extensions used by OmniStudio, Integration Procedures, or DataRaptors. DO NOT TRIGGER when: generic Apex classes/triggers (use sf-apex), building Integration Procedures (use sf-industry-commoncore-integration-procedure), authoring OmniScripts (use sf-industry-commoncore-omniscript), configuring Data Mappers (use sf-industry-commoncore-datamapper), or analyzing namespace/dependency issues (use sf-industry-commoncore-omnistudio-analyze).

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: sf-industry-commoncore-callable-apex description: > Salesforce Industries Common Core (OmniStudio/Vlocity) Apex callable generation and review with 120-point scoring. TRIGGER when: user creates or reviews System.Callable classes, migrates VlocityOpenInterface / VlocityOpenInterface2, or builds Industries callable extensions used by OmniStudio, Integration Procedures, or DataRaptors. DO NOT TRIGGER when: generic Apex classes/triggers (use sf-apex), building Integration Procedures (use sf-industry-commoncore-integration-procedure), authoring OmniScripts (use sf-industry-commoncore-omniscript), configuring Data Mappers (use sf-industry-commoncore-datamapper), or analyzing namespace/dependency issues (use sf-industry-commoncore-omnistudio-analyze). license: MIT metadata: version: "1.0.0" author: "Shreyas Dhond" scoring: "120 points across 7 categories"

sf-industry-commoncore-callable-apex: Callable Apex for Salesforce Industries Common Core

Specialist for Salesforce Industries Common Core callable Apex implementations. Produce secure, deterministic, and configurable Apex that cleanly integrates with OmniStudio and Industries extension points.

Core Responsibilities

  1. Callable Generation: Build System.Callable classes with safe action dispatch
  2. Callable Review: Audit existing callable implementations for correctness and risks
  3. Validation & Scoring: Evaluate against the 120-point rubric
  4. Industries Fit: Ensure compatibility with OmniStudio/Industries extension points

Workflow (4-Phase Pattern)

Phase 1: Requirements Gathering

Ask for:

  • Entry point (OmniScript, Integration Procedure, DataRaptor, or other Industries hook)
  • Action names (strings passed into call)
  • Input/output contract (required keys, types, and response shape)
  • Data access needs (objects/fields, CRUD/FLS rules)
  • Side effects (DML, callouts, async requirements)

Then:

  1. Scan for existing callable classes: Glob: **/*Callable*.cls
  2. Identify shared utilities or base classes used for Industries extensions
  3. Create a task list

Phase 2: Design & Contract Definition

Define the callable contract:

  • Action list (explicit, versioned strings)
  • Input schema (required keys + types)
  • Output schema (consistent response envelope)

Recommended response envelope:

{
  "success": true|false,
  "data": {...},
  "errors": [ { "code": "...", "message": "..." } ]
}

Action dispatch rules:

  • Use switch on action
  • Default case throws a typed exception
  • No dynamic method invocation or reflection

VlocityOpenInterface / VlocityOpenInterface2 contract mapping:

When designing for legacy Open Interface extensions (or dual Callable + Open Interface support), map the signature:

invokeMethod(String methodName, Map<String, Object> inputMap, Map<String, Object> outputMap, Map<String, Object> options)
ParameterRoleCallable equivalent
methodNameAction selector (same semantics as action)action in call(action, args)
inputMapPrimary input data (required keys, types)args.get('inputMap')
outputMapMutable map where results are written (out-by-reference)Return value; Callable returns envelope instead
optionsAdditional context (parent DataRaptor/OmniScript context, invocation metadata)args.get('options')

Design rules for Open Interface contracts:

  • Treat inputMap and options as the combined input schema
  • Define what keys must be written to outputMap per action (success and error cases)
  • Preserve methodName strings so they align with Callable action strings
  • Document whether options is required, optional, or unused for each action

Phase 3: Implementation Pattern

Vanilla System.Callable (flat args, no Open Interface coupling):

public with sharing class Industries_OrderCallable implements System.Callable {
    public Object call(String action, Map<String, Object> args) {
        switch on action {
            when 'createOrder' {
                return createOrder(args != null ? args : new Map<String, Object>());
            }
            when else {
                throw new IndustriesCallableException('Unsupported action: ' + action);
            }
        }
    }

    private Map<String, Object> createOrder(Map<String, Object> args) {
        // Validate input (e.g. args.get('orderId')), run business logic, return response envelope
        return new Map<String, Object>{ 'success' => true };
    }
}

Use the vanilla pattern when callers pass flat args and no VlocityOpenInterface integration is required.

Callable skeleton (same inputs as VlocityOpenInterface):

Use inputMap and options keys in args when integrating with Open Interface or when callers pass that structure:

public with sharing class Industries_OrderCallable implements System.Callable {
    public Object call(String action, Map<String, Object> args) {
        Map<String, Object> inputMap = (args != null && args.containsKey('inputMap'))
            ? (Map<String, Object>) args.get('inputMap') : (args != null ? args : new Map<String, Object>());
        Map<String, Object> options  = (args != null && args.containsKey('options'))
            ? (Map<String, Object>) args.get('options')  : new Map<String, Object>();
        if (inputMap == null) { inputMap = new Map<String, Object>(); }
        if (options  == null) { options  = new Map<String, Object>(); }

        switch on action {
            when 'createOrder' {
                return createOrder(inputMap, options);
            }
            when else {
                throw new IndustriesCallableException('Unsupported action: ' + action);
            }
        }
    }

    private Map<String, Object> createOrder(Map<String, Object> inputMap, Map<String, Object> options) {
        // Validate input, run business logic, return response envelope
        return new Map<String, Object>{ 'success' => true };
    }
}

Input format: Callers pass args as { 'inputMap' => Map<String, Object>, 'options' => Map<String, Object> }. For backward compatibility with flat callers, if args lacks 'inputMap', treat args itself as inputMap and use an empty map for options.

Implementation rules:

  1. Keep call() thin; delegate to private methods or service classes
  2. Validate and coerce input types early (null-safe)
  3. Enforce CRUD/FLS and sharing (with sharing, Security.stripInaccessible())
  4. Bulkify when args include record collections
  5. Use WITH USER_MODE for SOQL when appropriate

VlocityOpenInterface / VlocityOpenInterface2 implementation:

When implementing omnistudio.VlocityOpenInterface or omnistudio.VlocityOpenInterface2, use the signature:

global Boolean invokeMethod(String methodName, Map<String, Object> inputMap,
                           Map<String, Object> outputMap, Map<String, Object> options)

Open Interface skeleton:

global with sharing class Industries_OrderOpenInterface implements omnistudio.VlocityOpenInterface2 {
    global Boolean invokeMethod(String methodName, Map<String, Object> inputMap,
                                Map<String, Object> outputMap, Map<String, Object> options) {
        switch on methodName {
            when 'createOrder' {
                Map<String, Object> result = createOrder(inputMap, options);
                outputMap.putAll(result);
                return true;
            }
            when else {
                outputMap.put('success', false);
                outputMap.put('errors', new List<Map<String, Object>>{
                    new Map<String, Object>{ 'code' => 'UNSUPPORTED_ACTION', 'message' => 'Unsupported action: ' + methodName }
                });
                return false;
            }
        }
    }

    private Map<String, Object> createOrder(Map<String, Object> inputMap, Map<String, Object> options) {
        // Validate input, run business logic, return response envelope
        return new Map<String, Object>{ 'success' => true, 'data' => new Map<String, Object>() };
    }
}

Open Interface implementation rules:

  • Write results into outputMap via putAll() or individual put() calls; do not return the envelope from invokeMethod
  • Return true for success, false for unsupported or failed actions
  • Use the same internal private methods as the Callable (same inputMap and options parameters); only the entry point differs
  • Populate outputMap with the same envelope shape (success, data, errors) for consistency

Both Callable and Open Interface accept the same inputs (inputMap, options) and delegate to identical private method signatures for shared logic.


Phase 4: Testing & Validation

Minimum tests:

  • Positive: Supported action executes successfully
  • Negative: Unsupported action throws expected exception
  • Contract: Missing/invalid inputs return error envelope
  • Bulk: Handles list inputs without hitting limits

Example test class:

@IsTest
private class Industries_OrderCallableTest {
    @IsTest
    static void testCreateOrder() {
        System.Callable svc = new Industries_OrderCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{ 'orderId' => '001000000000001' },
            'options'  => new Map<String, Object>()
        };
        Map<String, Object> result =
            (Map<String, Object>) svc.call('createOrder', args);
        Assert.isTrue((Boolean) result.get('success'));
    }

    @IsTest
    static void testUnsupportedAction() {
        try {
            System.Callable svc = new Industries_OrderCallable();
            svc.call('unknownAction', new Map<String, Object>());
            Assert.fail('Expected IndustriesCallableException');
        } catch (IndustriesCallableException e) {
            Assert.isTrue(e.getMessage().contains('Unsupported action'));
        }
    }
}

Migration: VlocityOpenInterface to System.Callable

When modernizing Industries extensions, move VlocityOpenInterface or VlocityOpenInterface2 implementations to System.Callable and keep the action contract stable. Use the Salesforce guidance as the source of truth. Salesforce Help

Guidance:

  • Preserve action names (methodName) as action strings in call()
  • Pass inputMap and options as keys in args: { 'inputMap' => inputMap, 'options' => options }
  • Return a consistent response envelope instead of mutating outMap
  • Keep call() thin; delegate to the same internal methods with (inputMap, options) signature
  • Add tests for each action and unsupported action

Example migration (pattern):

// BEFORE: VlocityOpenInterface2
global class OrderOpenInterface implements omnistudio.VlocityOpenInterface2 {
    global Boolean invokeMethod(String methodName, Map<String, Object> input,
                                Map<String, Object> output,
                                Map<String, Object> options) {
        if (methodName == 'createOrder') {
            output.putAll(createOrder(input, options));
            return true;
        }
        return false;
    }
}

// AFTER: System.Callable (same inputs: inputMap, options)
public with sharing class OrderCallable implements System.Callable {
    public Object call(String action, Map<String, Object> args) {
        Map<String, Object> inputMap = args != null ? (Map<String, Object>) args.get('inputMap') : new Map<String, Object>();
        Map<String, Object> options  = args != null ? (Map<String, Object>) args.get('options')   : new Map<String, Object>();
        if (inputMap == null) { inputMap = new Map<String, Object>(); }
        if (options  == null) { options  = new Map<String, Object>(); }

        switch on action {
            when 'createOrder' {
                return createOrder(inputMap, options);
            }
            when else {
                throw new IndustriesCallableException('Unsupported action: ' + action);
            }
        }
    }
}

Best Practices (120-Point Scoring)

CategoryPointsKey Rules
Contract & Dispatch20Explicit action list; switch on; versioned action strings
Input Validation20Required keys validated; types coerced safely; null guards
Security20with sharing; CRUD/FLS checks; Security.stripInaccessible()
Error Handling15Typed exceptions; consistent error envelope; no empty catch
Bulkification & Limits20No SOQL/DML in loops; supports list inputs
Testing15Positive/negative/contract/bulk tests
Documentation10ApexDoc for class and action methods

Thresholds: ✅ 90+ (Ready) | ⚠️ 70-89 (Review) | ❌ <70 (Block)


⛔ Guardrails (Mandatory)

Stop and ask the user if any of these would be introduced:

  • Dynamic method execution based on user input (no reflection)
  • SOQL/DML inside loops
  • without sharing on callable classes
  • Silent failures (empty catch, swallowed exceptions)
  • Inconsistent response shapes across actions

Common Anti-Patterns

  • call() contains business logic instead of delegating
  • Action names are unversioned or not documented
  • Input maps assumed to have keys without checks
  • Mixed response types (sometimes Map, sometimes String)
  • No tests for unsupported actions

Cross-Skill Integration

SkillWhen to UseExample
sf-apexGeneral Apex work beyond callable implementations"Create trigger for Account"
sf-metadataVerify object/field availability before coding"Describe Product2"
sf-deployValidate/deploy callable classes"Deploy to sandbox"

Reference Skill

Use the core Apex standards, testing patterns, and guardrails in:


Bundled Examples

Notes

  • Prefer deterministic, side-effect-aware callable actions
  • Keep action contracts stable; introduce new actions for breaking changes
  • Avoid long-running work in synchronous callables; use async when needed

附带文件

CREDITS.md
# Credits & Acknowledgments

This skill was built upon the collective wisdom of the Salesforce Industries and
Salesforce Apex communities. We gratefully acknowledge the following authors and
resources whose ideas, patterns, and best practices have shaped this skill.

---

## Authors & Contributors

### Primary Contributor
**Shreyas Dhond (CTA)**

Key contributions:
- Contributed the `sf-industry-commoncore-callable-apex` skill
- Strengthened callable guidance, migration patterns, and bundled examples
- Expanded coverage for `System.Callable` and legacy `VlocityOpenInterface` / `VlocityOpenInterface2` scenarios

### Salesforce Industries Documentation Team
**[Callable Implementations](https://help.salesforce.com/s/articleView?id=ind.v_dev_t_callable_implementations_651821.htm&type=5)**

Key contributions:
- Recommended patterns for Industries callable implementations
- Migration guidance from `VlocityOpenInterface`/`VlocityOpenInterface2`
- Action contract and response design guidance

---

## Frameworks & Libraries

### OmniStudio / Vlocity Open Interface (Legacy)
- **Provider**: Salesforce Industries
- **Interface signatures**: `VlocityOpenInterface` and `VlocityOpenInterface2` with `invokeMethod(String methodName, Map<String, Object> inputMap, Map<String, Object> outputMap, Map<String, Object> options)`
- **Reference**: https://help.salesforce.com/s/articleView?id=ind.v_dev_t_callable_implementations_651821.htm&type=5

### System.Callable (Salesforce Platform)
- **Provider**: Salesforce
- **Reference**: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/

---

## Official Salesforce Resources

- **Salesforce Help: Callable Implementations**: https://help.salesforce.com/s/articleView?id=ind.v_dev_t_callable_implementations_651821.htm&type=5
- **Apex Developer Guide**: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/
- **Trailhead**: https://trailhead.salesforce.com

---

## Community Resources

### Salesforce Stack Exchange
**[salesforce.stackexchange.com](https://salesforce.stackexchange.com/)**
- Community Q&A and solutions
- Industry-specific callable implementation discussions

---

examples/Test_QuoteByProductCallable/Industries_QuoteByProductCallable.cls
/**
 * @description Industries callable that finds Quotes on an Account that have at least one
 *              QuoteLineItem with a specified Product2.ProductCode.
 *              Integrates with OmniStudio/Industries extension points.
 * @author      sf-industry-commoncore-callable-apex
 *
 * Actions:
 *   - findQuotesByProductCode: Returns Quotes for an Account with QuoteLineItems matching productCode
 *
 * Input (findQuotesByProductCode):
 *   - accountId (String, required): Id of the Account
 *   - productCode (String, required): Product2.ProductCode to match on QuoteLineItems
 *
 * Output envelope:
 *   - success (Boolean)
 *   - data (Map): quotes (List<Map> with Id, Name, QuoteNumber)
 *   - errors (List<Map>)
 */
public with sharing class Industries_QuoteByProductCallable implements System.Callable {

    private static final String ACTION_FIND_QUOTES = 'findQuotesByProductCode';

    /**
     * @description Dispatches to the appropriate action handler.
     * @param action Action name (e.g. 'findQuotesByProductCode')
     * @param args   Input map with accountId and productCode
     * @return      Response envelope: success, data, errors
     */
    public Object call(String action, Map<String, Object> args) {
        switch on action {
            when ACTION_FIND_QUOTES {
                return findQuotesByProductCode(args);
            }
            when else {
                throw new IndustriesCallableException('Unsupported action: ' + action);
            }
        }
    }

    /**
     * @description Finds all Quotes on an Account that have at least one QuoteLineItem
     *              with the specified Product2.ProductCode.
     * @param args  Map with keys: accountId (String), productCode (String)
     * @return     Response envelope with quotes list or errors
     */
    private Map<String, Object> findQuotesByProductCode(Map<String, Object> args) {
        List<Map<String, Object>> errors = new List<Map<String, Object>>();

        Object accountIdObj = args != null ? args.get('accountId') : null;
        Object productCodeObj = args != null ? args.get('productCode') : null;

        String accountId = accountIdObj != null ? String.valueOf(accountIdObj).trim() : null;
        String productCode = productCodeObj != null ? String.valueOf(productCodeObj).trim() : null;

        if (String.isBlank(accountId)) {
            errors.add(new Map<String, Object>{
                'code' => 'MISSING_ACCOUNT_ID',
                'message' => 'accountId is required'
            });
        }
        if (String.isBlank(productCode)) {
            errors.add(new Map<String, Object>{
                'code' => 'MISSING_PRODUCT_CODE',
                'message' => 'productCode is required'
            });
        }
        if (!errors.isEmpty()) {
            return new Map<String, Object>{
                'success' => false,
                'data' => new Map<String, Object>{ 'quotes' => new List<Map<String, Object>>() },
                'errors' => errors
            };
        }

        try {
            List<Quote> quotes = [
                SELECT Id, Name, QuoteNumber, OpportunityId
                FROM Quote
                WITH USER_MODE
                WHERE Opportunity.AccountId = :accountId
                AND Id IN (
                    SELECT QuoteId
                    FROM QuoteLineItem
                    WHERE Product2.ProductCode = :productCode
                )
            ];

            List<Map<String, Object>> quoteMaps = new List<Map<String, Object>>();
            for (Quote q : quotes) {
                quoteMaps.add(new Map<String, Object>{
                    'Id' => q.Id,
                    'Name' => q.Name,
                    'QuoteNumber' => q.QuoteNumber
                });
            }

            return new Map<String, Object>{
                'success' => true,
                'data' => new Map<String, Object>{ 'quotes' => quoteMaps },
                'errors' => new List<Map<String, Object>>()
            };
        } catch (Exception e) {
            return new Map<String, Object>{
                'success' => false,
                'data' => new Map<String, Object>{ 'quotes' => new List<Map<String, Object>>() },
                'errors' => new List<Map<String, Object>>{
                    new Map<String, Object>{
                        'code' => 'QUERY_ERROR',
                        'message' => e.getMessage()
                    }
                }
            };
        }
    }
}
examples/Test_QuoteByProductCallable/Industries_QuoteByProductCallableTest.cls
/**
 * @description Test class for Industries_QuoteByProductCallable.
 *              Covers positive, negative, contract, and unsupported action scenarios.
 * @author      sf-industry-commoncore-callable-apex
 */
@IsTest
private class Industries_QuoteByProductCallableTest {

    // ═══════════════════════════════════════════════════════════════════════════
    // TEST DATA SETUP
    // ═══════════════════════════════════════════════════════════════════════════

    @TestSetup
    static void setupTestData() {
        Account acc = new Account(Name = 'Test Account');
        insert acc;

        Pricebook2 pb = [SELECT Id FROM Pricebook2 WHERE IsStandard = true LIMIT 1];

        Opportunity opp = new Opportunity(
            Name = 'Test Opp',
            AccountId = acc.Id,
            Pricebook2Id = pb.Id,
            StageName = 'Prospecting',
            CloseDate = Date.today().addMonths(1)
        );
        insert opp;

        Product2 prod = new Product2(
            Name = 'Test Product',
            ProductCode = 'PROD-001',
            IsActive = true
        );
        insert prod;

        PricebookEntry pbe = new PricebookEntry(
            Pricebook2Id = pb.Id,
            Product2Id = prod.Id,
            UnitPrice = 100,
            IsActive = true
        );
        insert pbe;

        Quote q = new Quote(
            Name = 'Test Quote',
            OpportunityId = opp.Id,
            Pricebook2Id = pb.Id
        );
        insert q;

        QuoteLineItem qli = new QuoteLineItem(
            QuoteId = q.Id,
            PricebookEntryId = pbe.Id,
            Quantity = 1,
            UnitPrice = 100
        );
        insert qli;
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // POSITIVE TESTS
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests findQuotesByProductCode returns Quotes with matching productCode
     */
    @IsTest
    static void testFindQuotesByProductCode_Success() {
        Account acc = [SELECT Id FROM Account LIMIT 1];
        System.Callable svc = new Industries_QuoteByProductCallable();
        Map<String, Object> args = new Map<String, Object>{
            'accountId' => acc.Id,
            'productCode' => 'PROD-001'
        };

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('findQuotesByProductCode', args);
        Test.stopTest();

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        List<Object> quotes = (List<Object>) data.get('quotes');
        Assert.isNotNull(quotes, 'quotes list should not be null');
        Assert.areEqual(1, quotes.size(), 'Should return 1 Quote');
    }

    /**
     * @description Tests findQuotesByProductCode returns empty list when no match
     */
    @IsTest
    static void testFindQuotesByProductCode_NoMatch_ReturnsEmpty() {
        Account acc = [SELECT Id FROM Account LIMIT 1];
        System.Callable svc = new Industries_QuoteByProductCallable();
        Map<String, Object> args = new Map<String, Object>{
            'accountId' => acc.Id,
            'productCode' => 'NONEXISTENT'
        };

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('findQuotesByProductCode', args);
        Test.stopTest();

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed with empty result');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        List<Object> quotes = (List<Object>) data.get('quotes');
        Assert.areEqual(0, quotes.size(), 'Should return 0 Quotes');
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // CONTRACT / INPUT VALIDATION TESTS
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests missing accountId returns error envelope
     */
    @IsTest
    static void testFindQuotesByProductCode_MissingAccountId_ReturnsError() {
        Map<String, Object> args = new Map<String, Object>{
            'productCode' => 'PROD-001'
        };
        System.Callable svc = new Industries_QuoteByProductCallable();

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('findQuotesByProductCode', args);
        Test.stopTest();

        Assert.isFalse((Boolean) result.get('success'), 'Should fail');
        List<Object> errors = (List<Object>) result.get('errors');
        Assert.isTrue(errors.size() >= 1, 'Should have at least one error');
    }

    /**
     * @description Tests missing productCode returns error envelope
     */
    @IsTest
    static void testFindQuotesByProductCode_MissingProductCode_ReturnsError() {
        Account acc = [SELECT Id FROM Account LIMIT 1];
        Map<String, Object> args = new Map<String, Object>{
            'accountId' => acc.Id
        };
        System.Callable svc = new Industries_QuoteByProductCallable();

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('findQuotesByProductCode', args);
        Test.stopTest();

        Assert.isFalse((Boolean) result.get('success'), 'Should fail');
        List<Object> errors = (List<Object>) result.get('errors');
        Assert.isTrue(errors.size() >= 1, 'Should have at least one error');
    }

    /**
     * @description Tests null args returns error envelope
     */
    @IsTest
    static void testFindQuotesByProductCode_NullArgs_ReturnsError() {
        System.Callable svc = new Industries_QuoteByProductCallable();

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('findQuotesByProductCode', null);
        Test.stopTest();

        Assert.isFalse((Boolean) result.get('success'), 'Should fail');
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // NEGATIVE TESTS (Unsupported Action)
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests unsupported action throws IndustriesCallableException
     */
    @IsTest
    static void testUnsupportedAction_ThrowsException() {
        System.Callable svc = new Industries_QuoteByProductCallable();

        Test.startTest();
        try {
            svc.call('unknownAction', new Map<String, Object>());
            Assert.fail('Expected IndustriesCallableException');
        } catch (IndustriesCallableException e) {
            Assert.isTrue(
                e.getMessage().contains('Unsupported action'),
                'Error message should mention unsupported action: ' + e.getMessage()
            );
        }
        Test.stopTest();
    }
}
examples/Test_QuoteByProductCallable/IndustriesCallableException.cls
/**
 * @description Custom exception for Industries callable implementations.
 *              Thrown when an unsupported or invalid action is requested.
 * @author      sf-industry-commoncore-callable-apex
 */
public class IndustriesCallableException extends Exception {
}
examples/Test_QuoteByProductCallable/TRANSCRIPT.md
# Reasoning Transcript: Industries Quote-by-Product Callable

This document records the reasoning process and skills used to generate `Industries_QuoteByProductCallable.cls` and `Industries_QuoteByProductCallableTest.cls`.

---

## Task Summary

Create a callable Apex class that finds all Quotes on an Account that have at least one QuoteLineItem with a specified Product2.ProductCode, plus a supporting test class.

---

## Skills Applied

### 1. **sf-industry-commoncore-callable-apex** (Primary)

**Path:** `~/.claude/skills/sf-industry-commoncore-callable-apex/SKILL.md`  
**Trigger:** Callable implementations, OmniStudio, Vlocity, Industries Apex extensions

**Application:**

- **Phase 1 – Requirements Gathering:**  
  Entry point: Integration Procedure / OmniScript. Action: `findQuotesByProductCode`.  
  Inputs: `accountId` (String), `productCode` (String).  
  Data: Quote, QuoteLineItem, Product2. No side effects (read-only).

- **Phase 2 – Contract Definition:**  
  Response envelope: `{ success, data: { quotes }, errors }`.  
  Action dispatch via `switch on action` with typed exception for unsupported actions.

- **Phase 3 – Implementation Pattern:**  
  - `call()` kept thin; logic delegated to `findQuotesByProductCode(args)`  
  - Input validation for `accountId` and `productCode` (blank checks)  
  - `with sharing` on the callable class  
  - SOQL with `WITH USER_MODE` for CRUD/FLS  
  - No SOQL/DML in loops  
  - Consistent envelope shape

- **Phase 4 – Testing:**  
  Positive, negative, contract, and unsupported-action tests per skill requirements.

- **Exception Class:**  
  `IndustriesCallableException` used per skill pattern for unsupported actions.

---

### 2. **sf-apex** (Reference)

**Path:** `~/.claude/skills/sf-apex/SKILL.md`  
**Trigger:** Apex classes, code quality

**Application:**

- ApexDoc on the class and action method  
- Naming: `Industries_QuoteByProductCallable` (callable pattern)  
- Null-safe input handling  
- Clear separation of concerns

---

### 3. **sf-testing** (Reference)

**Path:** `~/.claude/skills/sf-testing/SKILL.md`  
**Trigger:** Apex tests

**Application:**

- `@TestSetup` for shared data (Account, Opportunity, Product2, PricebookEntry, Quote, QuoteLineItem)  
- Positive: success case and empty-result case  
- Contract: missing `accountId`, missing `productCode`, null args  
- Negative: unsupported action throws `IndustriesCallableException`  
- GIVEN/WHEN/THEN comments (adapted from basic-test template)

---

### 4. **sf-soql / sf-data** (Reference)

**Paths:** `skills/sf-data/assets/soql/`, `skills/sf-diagram-mermaid/assets/datamodel/`  
**Trigger:** SOQL and data model

**Application:**

- Quote–Opportunity–Account: `Quote.Opportunity.AccountId`  
- QuoteLineItem–Product2: `QuoteLineItem.Product2.ProductCode`  
- Subquery: `Id IN (SELECT QuoteId FROM QuoteLineItem WHERE Product2.ProductCode = :productCode)`  
- Confirmation that Product2.ProductCode is queryable from QuoteLineItem

---

## Design Decisions

| Decision | Rationale |
|----------|-----------|
| **Response envelope** | Aligns with Industries pattern: `success`, `data`, `errors` for consistent handling by Integration Procedures |
| **Error envelope for validation** | Return structured errors instead of throwing; callers can handle without try/catch |
| **Exception for unsupported action** | Follows skill pattern; `IndustriesCallableException` makes misuse explicit |
| **`IndustriesCallableException` in separate class** | Reusable across callable classes, matches skill examples |
| **`WITH USER_MODE` on SOQL** | Enforces CRUD/FLS; requires API 59+ |
| **Quote ↔ Account path** | Standard model: `Quote.Opportunity.AccountId` (no direct Quote → Account) |
| **Product code on Product2** | Standard `Product2.ProductCode` via `QuoteLineItem.Product2` relationship |

---

## Artifacts Produced

1. **IndustriesCallableException.cls** – Custom exception for unsupported actions  
2. **Industries_QuoteByProductCallable.cls** – Callable implementation  
3. **Industries_QuoteByProductCallableTest.cls** – Test class (6 methods)  
4. **TRANSCRIPT.md** – This reasoning transcript  

---

## Deployment Notes

Copy the `.cls` files into your Salesforce project under `force-app/main/default/classes/` and add the corresponding `-meta.xml` files with `apiVersion` 59.0 or higher (for `WITH USER_MODE`). If your org uses an older API, remove `WITH USER_MODE` from the SOQL query.
examples/Test_VlocityOpenInterface2Conversion/IndustriesCallableException.cls
/**
 * @description Custom exception for Industries callable implementations.
 *              Thrown when an unsupported or invalid action is requested.
 * @author      sf-industry-commoncore-callable-apex
 */
public class IndustriesCallableException extends Exception {
}
examples/Test_VlocityOpenInterface2Conversion/MyCustomCallable.cls
/**
 * @description Callable implementation for calculateTotal (converted from VlocityOpenInterface2).
 *              Accepts price and quantity from inputMap, returns total in response envelope.
 *              Original: MyCustomRemoteClass implements omnistudio.VlocityOpenInterface2.
 * @author      sf-industry-commoncore-callable-apex
 */
public with sharing class MyCustomCallable implements System.Callable {

    public Object call(String action, Map<String, Object> args) {
        Map<String, Object> inputMap = (args != null && args.containsKey('inputMap'))
            ? (Map<String, Object>) args.get('inputMap') : (args != null ? args : new Map<String, Object>());
        Map<String, Object> options  = (args != null && args.containsKey('options'))
            ? (Map<String, Object>) args.get('options')  : new Map<String, Object>();
        if (inputMap == null) { inputMap = new Map<String, Object>(); }
        if (options  == null) { options  = new Map<String, Object>(); }

        switch on action {
            when 'calculateTotal' {
                return calculateTotal(inputMap, options);
            }
            when else {
                throw new IndustriesCallableException('Unsupported action: ' + action);
            }
        }
    }

    private Map<String, Object> calculateTotal(Map<String, Object> inputMap, Map<String, Object> options) {
        Decimal price = toDecimal(inputMap.get('price'));
        Integer qty   = toInteger(inputMap.get('quantity'));

        if (price == null || qty == null) {
            return new Map<String, Object>{
                'success' => false,
                'data'    => new Map<String, Object>(),
                'errors'  => new List<Map<String, Object>>{
                    new Map<String, Object>{
                        'code'    => 'INVALID_INPUT',
                        'message' => 'price and quantity are required and must be numeric'
                    }
                }
            };
        }

        Decimal total = price * qty;

        return new Map<String, Object>{
            'success' => true,
            'data'    => new Map<String, Object>{ 'total' => total },
            'errors'  => new List<Map<String, Object>>()
        };
    }

    private Decimal toDecimal(Object o) {
        if (o == null) return null;
        if (o instanceof Decimal) return (Decimal) o;
        if (o instanceof Integer) return Decimal.valueOf((Integer) o);
        if (o instanceof Double)  return Decimal.valueOf((Double) o);
        if (o instanceof String) {
            try { return Decimal.valueOf((String) o); } catch (TypeException e) { return null; }
        }
        return null;
    }

    private Integer toInteger(Object o) {
        if (o == null) return null;
        if (o instanceof Integer) return (Integer) o;
        if (o instanceof Long)    return ((Long) o).intValue();
        if (o instanceof Decimal) return ((Decimal) o).intValue();
        if (o instanceof String) {
            try { return Integer.valueOf((String) o); } catch (TypeException e) { return null; }
        }
        return null;
    }
}
examples/Test_VlocityOpenInterface2Conversion/MyCustomCallableTest.cls
/**
 * @description Test class for MyCustomCallable (converted from MyCustomRemoteClass VlocityOpenInterface2).
 *              Covers positive, negative, contract, and unsupported action scenarios.
 * @author      sf-industry-commoncore-callable-apex
 */
@IsTest
private class MyCustomCallableTest {

    // ═══════════════════════════════════════════════════════════════════════════
    // POSITIVE TESTS
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests calculateTotal returns total with inputMap/options format
     */
    @IsTest
    static void testCalculateTotal_Success() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{
                'price'    => 10.50,
                'quantity' => 3
            },
            'options'  => new Map<String, Object>()
        };

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);
        Test.stopTest();

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        Assert.areEqual(31.50, (Decimal) data.get('total'), 'total should be 10.50 * 3');
    }

    /**
     * @description Tests calculateTotal with flat args (backward compatibility)
     */
    @IsTest
    static void testCalculateTotal_FlatArgs_Success() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'price'    => 100,
            'quantity' => 5
        };

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);
        Test.stopTest();

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        Assert.areEqual(500, (Decimal) data.get('total'), 'total should be 100 * 5');
    }

    /**
     * @description Tests calculateTotal with string inputs (type coercion from OmniScript)
     */
    @IsTest
    static void testCalculateTotal_StringInputs_Success() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{
                'price'    => '25.00',
                'quantity' => '4'
            },
            'options'  => new Map<String, Object>()
        };

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        Assert.areEqual(100, (Decimal) data.get('total'), 'total should be 25 * 4');
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // NEGATIVE / CONTRACT TESTS
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests unsupported action throws IndustriesCallableException
     */
    @IsTest
    static void testUnsupportedAction_Throws() {
        try {
            System.Callable svc = new MyCustomCallable();
            svc.call('unknownAction', new Map<String, Object>{
                'inputMap' => new Map<String, Object>(),
                'options'  => new Map<String, Object>()
            });
            Assert.fail('Expected IndustriesCallableException');
        } catch (IndustriesCallableException e) {
            Assert.isTrue(e.getMessage().contains('Unsupported action'), 'Message should mention unsupported action');
        }
    }

    /**
     * @description Tests missing price returns error envelope
     */
    @IsTest
    static void testCalculateTotal_MissingPrice_ReturnsError() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{ 'quantity' => 2 },
            'options'  => new Map<String, Object>()
        };

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);

        Assert.isFalse((Boolean) result.get('success'), 'Should fail');
        List<Object> errors = (List<Object>) result.get('errors');
        Assert.isNotNull(errors, 'errors should not be null');
        Assert.areEqual(1, errors.size(), 'Should have one error');
    }

    /**
     * @description Tests missing quantity returns error envelope
     */
    @IsTest
    static void testCalculateTotal_MissingQuantity_ReturnsError() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{ 'price' => 50 },
            'options'  => new Map<String, Object>()
        };

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);

        Assert.isFalse((Boolean) result.get('success'), 'Should fail');
        List<Object> errors = (List<Object>) result.get('errors');
        Assert.isNotNull(errors, 'errors should not be null');
    }

    /**
     * @description Tests null args handled gracefully
     */
    @IsTest
    static void testCalculateTotal_NullArgs_ReturnsError() {
        System.Callable svc = new MyCustomCallable();

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', null);

        Assert.isFalse((Boolean) result.get('success'), 'Should fail with null inputMap');
    }
}
examples/Test_VlocityOpenInterface2Conversion/MyCustomRemoteClass.cls
global with sharing class MyCustomRemoteClass implements omnistudio.VlocityOpenInterface2 {
    
    global Boolean invokeMethod(String methodName, Map<String,Object> inputMap, Map<String,Object> outputMap, Map<String,Object> options) {
        if (methodName.equals('calculateTotal')) {
            calculateTotal(inputMap, outputMap);
            return true;
        }
        return false;
    }

    private void calculateTotal(Map<String,Object> inputMap, Map<String,Object> outputMap) {
        Decimal price = (Decimal)inputMap.get('price');
        Decimal qty = (Decimal)inputMap.get('quantity');
        outputMap.put('total', price * qty);
    }
}
examples/Test_VlocityOpenInterface2Conversion/TRANSCRIPT.md
# Reasoning Transcript: VlocityOpenInterface2 → Callable Conversion (MyCustomRemoteClass)

This document records the reasoning process and skills used to convert `MyCustomRemoteClass.cls` (VlocityOpenInterface2) to `MyCustomCallable.cls` and create `MyCustomCallableTest.cls`.

---

## Task Summary

Convert a VlocityOpenInterface2 implementation (`MyCustomRemoteClass`) to `System.Callable`, add a test class, and document reasoning and skills used.

---

## Source Analysis

**Original class:** `MyCustomRemoteClass.cls`

- **Implements:** `omnistudio.VlocityOpenInterface2`
- **Action:** `calculateTotal`
- **Input contract:** `inputMap` with keys `price` (Decimal), `quantity` (Decimal)
- **Output contract:** `outputMap.put('total', price * qty)`
- **Behavior:** Single action; returns `true` on success, `false` for unsupported methods

---

## Skills Applied

### 1. **sf-industry-commoncore-callable-apex** (Primary)

**Path:** `skills/sf-industry-commoncore-callable-apex/SKILL.md`  
**Trigger:** Callable implementations, VlocityOpenInterface, migration to System.Callable

**Application:**

- **Migration guidance (SKILL § Migration: VlocityOpenInterface to System.Callable):**
  - Preserved action name: `calculateTotal` → `action` in `call()`
  - Pass `inputMap` and `options` as keys in `args`: `{ 'inputMap' => inputMap, 'options' => options }`
  - Return response envelope instead of mutating `outputMap`
  - Keep `call()` thin; delegate to `calculateTotal(inputMap, options)`
  - Backward compatibility: if `args` lacks `inputMap`, treat `args` as `inputMap`

- **Callable skeleton (SKILL § Phase 3 – Callable skeleton):**
  - Extracted `inputMap` and `options` from `args` with null guards
  - `switch on action` with `when else` throwing `IndustriesCallableException`

- **Response envelope:**
  ```
  { success, data: { total }, errors }
  ```

- **Input validation:** Added `toDecimal()` and `toInteger()` type coercion (OmniScript often passes strings); return error envelope when `price` or `quantity` is null or invalid.

- **Contract & Dispatch:** Explicit action list; typed exception for unsupported actions.

---

### 2. **sf-apex** (Reference)

**Path:** `~/.claude/skills/sf-apex/SKILL.md`  
**Trigger:** Apex classes, code quality

**Application:**

- ApexDoc on class and key methods
- `with sharing` on the callable class
- Null-safe input handling; no direct casts without validation
- Naming: `MyCustomCallable` (callable pattern; preserves "MyCustom" from source)

---

### 3. **sf-testing** (Reference)

**Path:** `~/.claude/skills/sf-testing/SKILL.md`  
**Trigger:** Apex tests

**Application:**

- **Positive:** Success with `inputMap`/`options`, flat args, and string inputs (type coercion)
- **Contract:** Missing `price`, missing `quantity`, null args → error envelope
- **Negative:** Unsupported action throws `IndustriesCallableException`
- GIVEN/WHEN/THEN implied via clear assertions and method names

---

## Design Decisions

| Decision | Rationale |
|----------|-----------|
| **Preserve lowercase keys** (`price`, `quantity`, `total`) | Original `MyCustomRemoteClass` used lowercase; maintains contract for existing Integration Procedures / OmniScripts |
| **Response envelope** | Aligns with Industries pattern; callers get consistent `success`, `data`, `errors` shape |
| **Error envelope for validation** | Return structured errors instead of throwing; callers can handle without try/catch |
| **Exception for unsupported action** | Skill pattern; `IndustriesCallableException` makes misuse explicit |
| **Type coercion helpers** | OmniScript/Integration Procedures often pass strings; `toDecimal`/`toInteger` allow `'25.00'`, `'4'` etc. |
| **Flat args fallback** | When `args` lacks `inputMap`, treat `args` as `inputMap` for backward compatibility |

---

## Artifacts Produced

1. **IndustriesCallableException.cls** – Custom exception for unsupported actions  
2. **MyCustomCallable.cls** – Callable implementation (converted from MyCustomRemoteClass)  
3. **MyCustomCallableTest.cls** – Test class (7 methods)  
4. **TRANSCRIPT.md** – This reasoning transcript  

---

## Contract Mapping (Before → After)

| VlocityOpenInterface2 | System.Callable |
|----------------------|-----------------|
| `methodName` = `'calculateTotal'` | `action` = `'calculateTotal'` |
| `inputMap.get('price')`, `inputMap.get('quantity')` | Same keys in `args.inputMap` or flat `args` |
| `outputMap.put('total', ...)` | `return { success => true, data => { total => ... } }` |
| `return false` for unsupported | `throw IndustriesCallableException` |
| `options` (unused in original) | Passed through for future use |

---

## Deployment Notes

Copy the `.cls` files into your Salesforce project under `force-app/main/default/classes/` and add the corresponding `-meta.xml` files. No SOQL/DML; no special API version requirements.
examples/Test_VlocityOpenInterfaceConversion/IndustriesCallableException.cls
/**
 * @description Custom exception for Industries callable implementations.
 *              Thrown when an unsupported or invalid action is requested.
 * @author      sf-industry-commoncore-callable-apex
 */
public class IndustriesCallableException extends Exception {
}
examples/Test_VlocityOpenInterfaceConversion/MyCustomCallable.cls
/**
 * @description Callable implementation for calculateTotal (converted from VlocityOpenInterface).
 *              Accepts Price and Quantity, returns TotalAmount in response envelope.
 * @author      sf-industry-commoncore-callable-apex
 */
public with sharing class MyCustomCallable implements System.Callable {

    public Object call(String action, Map<String, Object> args) {
        Map<String, Object> inputMap = (args != null && args.containsKey('inputMap'))
            ? (Map<String, Object>) args.get('inputMap') : (args != null ? args : new Map<String, Object>());
        Map<String, Object> options  = (args != null && args.containsKey('options'))
            ? (Map<String, Object>) args.get('options')  : new Map<String, Object>();
        if (inputMap == null) { inputMap = new Map<String, Object>(); }
        if (options  == null) { options  = new Map<String, Object>(); }

        switch on action {
            when 'calculateTotal' {
                return calculateTotal(inputMap, options);
            }
            when else {
                throw new IndustriesCallableException('Unsupported action: ' + action);
            }
        }
    }

    private Map<String, Object> calculateTotal(Map<String, Object> inputMap, Map<String, Object> options) {
        Decimal price = toDecimal(inputMap.get('Price'));
        Integer qty   = toInteger(inputMap.get('Quantity'));

        if (price == null || qty == null) {
            return new Map<String, Object>{
                'success' => false,
                'data'    => new Map<String, Object>(),
                'errors'  => new List<Map<String, Object>>{
                    new Map<String, Object>{
                        'code'    => 'INVALID_INPUT',
                        'message' => 'Price and Quantity are required and must be numeric'
                    }
                }
            };
        }

        Decimal total = price * qty;

        return new Map<String, Object>{
            'success' => true,
            'data'    => new Map<String, Object>{ 'TotalAmount' => total },
            'errors'  => new List<Map<String, Object>>()
        };
    }

    private Decimal toDecimal(Object o) {
        if (o == null) return null;
        if (o instanceof Decimal) return (Decimal) o;
        if (o instanceof Integer) return Decimal.valueOf((Integer) o);
        if (o instanceof Double)  return Decimal.valueOf((Double) o);
        if (o instanceof String) {
            try { return Decimal.valueOf((String) o); } catch (TypeException e) { return null; }
        }
        return null;
    }

    private Integer toInteger(Object o) {
        if (o == null) return null;
        if (o instanceof Integer) return (Integer) o;
        if (o instanceof Long)    return ((Long) o).intValue();
        if (o instanceof Decimal) return ((Decimal) o).intValue();
        if (o instanceof String) {
            try { return Integer.valueOf((String) o); } catch (TypeException e) { return null; }
        }
        return null;
    }
}
examples/Test_VlocityOpenInterfaceConversion/MyCustomCallableTest.cls
/**
 * @description Test class for MyCustomCallable.
 *              Covers positive, negative, contract, and unsupported action scenarios.
 * @author      sf-industry-commoncore-callable-apex
 */
@IsTest
private class MyCustomCallableTest {

    // ═══════════════════════════════════════════════════════════════════════════
    // POSITIVE TESTS
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests calculateTotal returns TotalAmount with inputMap/options format
     */
    @IsTest
    static void testCalculateTotal_Success() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{
                'Price'    => 10.50,
                'Quantity' => 3
            },
            'options'  => new Map<String, Object>()
        };

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);
        Test.stopTest();

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        Assert.areEqual(31.50, (Decimal) data.get('TotalAmount'), 'TotalAmount should be 10.50 * 3');
    }

    /**
     * @description Tests calculateTotal with flat args (backward compatibility)
     */
    @IsTest
    static void testCalculateTotal_FlatArgs_Success() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'Price'    => 100,
            'Quantity' => 5
        };

        Test.startTest();
        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);
        Test.stopTest();

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        Assert.areEqual(500, (Decimal) data.get('TotalAmount'), 'TotalAmount should be 100 * 5');
    }

    /**
     * @description Tests calculateTotal with string inputs (type coercion from OmniScript)
     */
    @IsTest
    static void testCalculateTotal_StringInputs_Success() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{
                'Price'    => '25.00',
                'Quantity' => '4'
            },
            'options'  => new Map<String, Object>()
        };

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);

        Assert.isTrue((Boolean) result.get('success'), 'Should succeed');
        Map<String, Object> data = (Map<String, Object>) result.get('data');
        Assert.areEqual(100, (Decimal) data.get('TotalAmount'), 'TotalAmount should be 25 * 4');
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // NEGATIVE / CONTRACT TESTS
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * @description Tests unsupported action throws IndustriesCallableException
     */
    @IsTest
    static void testUnsupportedAction_Throws() {
        try {
            System.Callable svc = new MyCustomCallable();
            svc.call('unknownAction', new Map<String, Object>{
                'inputMap' => new Map<String, Object>(),
                'options'  => new Map<String, Object>()
            });
            Assert.fail('Expected IndustriesCallableException');
        } catch (IndustriesCallableException e) {
            Assert.isTrue(e.getMessage().contains('Unsupported action'), 'Message should mention unsupported action');
        }
    }

    /**
     * @description Tests missing Price returns error envelope
     */
    @IsTest
    static void testCalculateTotal_MissingPrice_ReturnsError() {
        System.Callable svc = new MyCustomCallable();
        Map<String, Object> args = new Map<String, Object>{
            'inputMap' => new Map<String, Object>{ 'Quantity' => 2 },
            'options'  => new Map<String, Object>()
        };

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', args);

        Assert.isFalse((Boolean) result.get('success'), 'Should fail');
        List<Object> errors = (List<Object>) result.get('errors');
        Assert.isNotNull(errors, 'errors should not be null');
        Assert.areEqual(1, errors.size(), 'Should have one error');
    }

    /**
     * @description Tests null args handled gracefully
     */
    @IsTest
    static void testCalculateTotal_NullArgs_ReturnsError() {
        System.Callable svc = new MyCustomCallable();

        Map<String, Object> result = (Map<String, Object>) svc.call('calculateTotal', null);

        Assert.isFalse((Boolean) result.get('success'), 'Should fail with null inputMap');
    }
}
examples/Test_VlocityOpenInterfaceConversion/MyCustomVlocityOpenInterface2.cls
global class MyCustomClass implements vlocity_cmt.VlocityOpenInterface {
    
    global Boolean invokeMethod(String methodName, Map<String, Object> inputs, 
                                Map<String, Object> output, Map<String, Object> options) {
        
        if (methodName == 'calculateTotal') {
            calculateTotal(inputs, output);
        }
        return true;
    }

    private void calculateTotal(Map<String, Object> inputs, Map<String, Object> output) {
        // Retrieve inputs from OmniScript/IP
        Decimal price = (Decimal)inputs.get('Price');
        Integer qty = (Integer)inputs.get('Quantity');
        
        // Perform calculation
        Decimal total = price * qty;
        
        // Return result to JSON
        output.put('TotalAmount', total);
    }
}
examples/Test_VlocityOpenInterfaceConversion/TRANSCRIPT.md
# TRANSCRIPT: VlocityOpenInterface → System.Callable Conversion

## Task

Convert `MyCustomVlocityOpenInterface2.cls` (VlocityOpenInterface) to System.Callable and create a test class. Keep a transcript of reasoning and skills used.

---

## Skills Used

| Skill | Purpose |
|-------|---------|
| **sf-industry-commoncore-callable-apex** | Migration pattern, response envelope, action dispatch, inputMap/options contract, test patterns |
| **sf-apex** (implicit) | ApexDoc, type coercion, null safety, exception handling |

---

## Reasoning

### 1. Source Analysis

**Original class**: `MyCustomClass` implements `vlocity_cmt.VlocityOpenInterface`

- **Action**: `calculateTotal`
- **Inputs**: `Price` (Decimal), `Quantity` (Integer) from `inputs` map
- **Output**: Mutates `output` map with `TotalAmount`
- **Observation**: Original returns `true` for all method names (no unsupported-action handling)

### 2. Design Decisions

| Decision | Rationale |
|----------|------------|
| **Open Interface–compatible args** | Use `inputMap` and `options` keys per skill Phase 3 so OmniScript/IP callers can pass the same structure |
| **Flat args fallback** | Skill allows flat args when `inputMap` is absent; supports both calling styles |
| **Response envelope** | Return `{ success, data, errors }` instead of mutating `outputMap` per migration guidance |
| **Input validation** | Skill requires null-safe type coercion; OmniScript can send numbers as String |
| **Unsupported action** | Skill: use `switch on action` and throw `IndustriesCallableException` |
| **`with sharing`** | Skill guardrail: enforce sharing on callable classes |

### 3. Implementation Pattern

- **Entry point**: Extract `inputMap` and `options` from `args` (or treat flat `args` as `inputMap`)
- **Action dispatch**: `switch on action` with `when 'calculateTotal'` and `when else` throwing
- **Business logic**: `calculateTotal(inputMap, options)` returns envelope
- **Type coercion**: `toDecimal()` and `toInteger()` handle Decimal, Integer, Long, String (OmniScript casts)
- **Error envelope**: Missing/invalid inputs → `success: false` with `INVALID_INPUT` error code

### 4. Test Coverage (Phase 4)

| Test | Category | Purpose |
|------|----------|---------|
| `testCalculateTotal_Success` | Positive | InputMap/options format, validates TotalAmount |
| `testCalculateTotal_FlatArgs_Success` | Positive | Flat args backward compatibility |
| `testCalculateTotal_StringInputs_Success` | Contract | String coercion from OmniScript |
| `testUnsupportedAction_Throws` | Negative | Unsupported action throws `IndustriesCallableException` |
| `testCalculateTotal_MissingPrice_ReturnsError` | Contract | Missing required key returns error envelope |
| `testCalculateTotal_NullArgs_ReturnsError` | Contract | Null args handled without NPE |

### 5. Files Produced

| File | Role |
|------|------|
| `MyCustomCallable.cls` | Converted System.Callable |
| `MyCustomCallableTest.cls` | Test class |
| `IndustriesCallableException.cls` | Custom exception (skill pattern) |
| `TRANSCRIPT.md` | This transcript |

---

## Skill References

- **Phase 2 (Design)**: Contract mapping (`methodName`→`action`, `inputMap`/`options`→`args`)
- **Phase 3 (Implementation)**: Callable skeleton (inputMap/options), implementation rules (validate early, null-safe)
- **Phase 4 (Testing)**: Positive, negative, contract tests
- **Migration**: Preserve action names, return envelope instead of mutating output
README.md
# sf-industry-commoncore-callable-apex

Generates and reviews Salesforce Industries Common Core (OmniStudio/Vlocity) Apex callable
implementations. Build secure, deterministic `System.Callable` classes with a 120-point scoring
rubric and migration guidance from legacy `VlocityOpenInterface` implementations.

## Features

- **Callable Generation**: Create `System.Callable` classes with safe action dispatch
- **Callable Review**: Analyze existing callable implementations for risks and fixes
- **120-Point Scoring**: Validation across 7 callable-specific categories
- **VlocityOpenInterface / VlocityOpenInterface2 Support**: Phase 2 contract mapping and Phase 3 implementation patterns for `invokeMethod(String methodName, Map<String, Object> inputMap, Map<String, Object> outputMap, Map<String, Object> options)`
- **Migration Guidance**: Patterns for moving from `VlocityOpenInterface`/`VlocityOpenInterface2` to `System.Callable`
- **Testing Examples**: Test class patterns for actions, errors, and bulk inputs

## Installation

```bash
# Install as part of sf-skills
npx skills add Jaganpro/sf-skills

# Or install just this skill
npx skills add Jaganpro/sf-skills --skill sf-industry-commoncore-callable-apex
```

## Quick Start

### 1. Invoke the skill

```
Skill: sf-industry-commoncore-callable-apex
Request: "Create a callable implementation for Order actions with createOrder and cancelOrder"
```

### 2. Answer requirements questions

The skill will ask about:
- Industries entry point (OmniScript, Integration Procedure, DataRaptor)
- Action names (strings passed into `call`)
- Input/output contract (required keys, response shape)
- Data access needs and security expectations

### 3. Review generated code

The skill generates:
- Callable class with explicit `switch on action`
- Consistent response envelope
- Test class examples for action coverage and error paths

## Bundled Examples

- [examples/Test_QuoteByProductCallable/](examples/Test_QuoteByProductCallable/) — read-only callable example with SOQL and test coverage
- [examples/Test_VlocityOpenInterfaceConversion/](examples/Test_VlocityOpenInterfaceConversion/) — migration pattern from legacy `VlocityOpenInterface`
- [examples/Test_VlocityOpenInterface2Conversion/](examples/Test_VlocityOpenInterface2Conversion/) — migration pattern from `VlocityOpenInterface2`

## Scoring System (120 Points)

| Category | Points | Focus |
|----------|--------|-------|
| Contract & Dispatch | 20 | Explicit actions, `switch on`, versioned strings |
| Input Validation | 20 | Required keys, type coercion, null guards |
| Security | 20 | CRUD/FLS checks, `with sharing`, stripInaccessible |
| Error Handling | 15 | Typed exceptions, consistent errors |
| Bulkification & Limits | 20 | No SOQL/DML in loops, list inputs |
| Testing | 15 | Positive/negative/contract/bulk tests |
| Documentation | 10 | ApexDoc for class and action methods |

**Thresholds**: ✅ 90+ (Ready) | ⚠️ 70-89 (Review) | ❌ <70 (Block)

## Cross-Skill Integration

| Related Skill | When to Use |
|---------------|-------------|
| sf-apex | General Apex work beyond callable implementations |
| sf-metadata | Verify object/field availability before coding |
| sf-testing | Run tests and analyze coverage |
| sf-deploy | Deploy callable classes to an org |

## Documentation

- [Skill Instructions](SKILL.md)
- [Callable Implementations (Salesforce Help)](https://help.salesforce.com/s/articleView?id=ind.v_dev_t_callable_implementations_651821.htm&type=5)

### VlocityOpenInterface / VlocityOpenInterface2

The skill includes design (Phase 2) and implementation (Phase 3) guidance for the Open Interface signature `invokeMethod(String methodName, Map<String, Object> inputMap, Map<String, Object> outputMap, Map<String, Object> options)`. Use this when extending legacy OmniStudio/Vlocity integration points or building dual Callable + Open Interface implementations.

## Requirements

- sf CLI v2
- Target Salesforce org

## License

MIT License. See LICENSE file.
Copyright (c) 2024-2025 Jag Valaiyapathy
    sf-industry-commoncore-callable-apex | Prompt Minder