Compare commits

..

12 Commits

Author SHA1 Message Date
Paul Huliganga 03a8f48e8d feat: envelope uses combined template names for subject and concatenated dfsle_EmailMessage__c for body
- Email subject is now combined document names (Short_Name__c if available)
- Email body is concatenated dfsle_EmailMessage__c from each selected template
- No longer uses template-level email subject for envelope
- Keeps status/document name logic unchanged
2026-02-25 23:55:30 -05:00
Paul Huliganga 5ae3da3c1e feat: use Short_Name__c for Document Name when available
Priority: Short_Name__c > stripped Name > template ID
Requires adding Short_Name__c (Text 80) custom field to
dfsle__EnvelopeConfiguration__c. Falls back to language-stripped
Name if Short_Name__c is blank or not populated.
2026-02-25 17:44:15 -05:00
Paul Huliganga fed796e6cc feat: concatenate template names for Document Name, strip language suffix
- First document label is now all template names joined with ', '
- Strips ' - English', ' - Spanish', ' - French' suffixes (case-insensitive)
- Truncates to 255 chars with '...' if too long
- Docusign Status now shows e.g. 'Admin Unit Notification, Consent to Eval'
  instead of 'Template 89dd5ead'
2026-02-25 17:33:42 -05:00
Paul Huliganga 978051bf49 fix: use actual template names for Document Name in Docusign Status
Query dfsle__EnvelopeConfiguration__c to get template names and pass
them as document labels to dfsle.Document.fromTemplate(). This makes
the Docusign Status screen show meaningful names like 'Consent to Eval'
instead of 'Template 89dd5ead'.
2026-02-25 17:17:40 -05:00
Paul Huliganga 7023b2e040 docs: add template ID update instructions for sandbox refresh 2026-02-25 13:19:44 -05:00
Paul Huliganga 4211648e2a feat: add recipient resolution from Client_Case__c lookup fields
- Queries Client_Case__c for Service_Coordinator__c and Docusign_Recipient_1__c
- Resolves Contact/User name and email from lookup targets
- Maps recipients to Docusign template roles: 'Service Coordinator' and 'Docusign Recipient #1'
- Validates recipients exist and have email addresses
- Supports both Contact and User lookup types
- Role names and field names are configurable constants at top of class
- Clear error messages if recipients are missing or have no email
2026-02-25 12:28:08 -05:00
Paul Huliganga 4b1edd4d27 refactor: switch to dfsle Apex Toolkit - remove raw API code
BREAKING CHANGE: Now uses dfsle managed package for Docusign integration.
No separate credentials or Named Credentials needed.

Changes:
- Rewrote DocusignCompositeEnvelopeBuilder to use dfsle.EnvelopeService
  and dfsle.Document.fromTemplate() for composite envelopes
- Simplified DocusignEnvelopeRequestHandler to validation-only
- Updated all tests to use dfsle.TestUtils.setMock()
- 12 comprehensive test methods (success, validation, edge cases)

Deleted (no longer needed):
- DocusignAPIService.cls (raw HTTP callouts)
- DocusignAPIServiceTest.cls
- DocusignCredentials.cls (custom settings)
- DocusignCredentialsTest.cls

Benefits:
- Uses existing dfsle package authentication automatically
- No Named Credential or Custom Setting setup required
- ~95 lines of code vs ~380 lines before
- Fully supported by Docusign managed package
2026-02-25 11:29:38 -05:00
Paul Huliganga ace2518349 fix: reorder V3 Flow XML elements - group same types together
Salesforce requires elements of the same type (recordLookups, decisions,
screens, etc.) to be grouped together. Reordered all elements alphabetically
by type to comply with Flow metadata schema.
2026-02-25 11:06:40 -05:00
Paul Huliganga 19852fd4bb fix: move flow XML files to correct force-app/main/default/flows/ directory 2026-02-25 11:03:10 -05:00
Paul Huliganga f56b5374e4 feat: add V3 Flow XML for composite envelope sending
V3 replaces individual envelope sends with single composite API call:
- Added dfsle__DocuSignId__c to record lookup query
- New loop builds text collection of Docusign template IDs from selected rows
- Single Action call to DocusignCompositeEnvelopeBuilder after loop
- Passes language from Client_Case__c automatically
- New success screen shows envelope ID
- New error screen with error message display
- Result checking decision (success/failure routing)
- Flow status: Draft (safe to deploy and test)
2026-02-25 10:52:48 -05:00
Paul Huliganga 7df62e06ca refactor: extract Request and Result as standalone global classes
- Created DocusignEnvelopeRequest.cls (separate global class for input)
- Created DocusignEnvelopeResult.cls (separate global class for output)
- Updated DocusignCompositeEnvelopeBuilder to use standalone classes
- Updated DocusignEnvelopeRequestHandler to reference standalone classes
- Updated all test classes to use new class references
- Fixes Flow Builder visibility issues with nested inner classes
- Better API design with clear input/output types
- Easier to extend and reuse across other classes
2026-02-25 10:05:36 -05:00
Paul Huliganga 565b851462 docs: add section for running tests for individual classes only
- Added examples for each test class: DocusignCompositeEnvelopeBuilderTest, DocusignEnvelopeRequestHandlerTest, DocusignAPIServiceTest, DocusignCredentialsTest
- Included both Bash/WSL and PowerShell versions
- Useful for targeted testing during development
- Includes --code-coverage flag for coverage metrics
2026-02-25 09:42:39 -05:00
18 changed files with 990 additions and 1564 deletions

View File

@ -122,6 +122,40 @@ sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --method-names
--- ---
**Run tests for a specific class only:**
Bash/WSL:
```bash
# Test the main invocable class
sf apex run test --class-names DocusignCompositeEnvelopeBuilderTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the request handler
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the API service
sf apex run test --class-names DocusignAPIServiceTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the credentials helper
sf apex run test --class-names DocusignCredentialsTest --wait 10 --result-format human --code-coverage --target-org dev-org
```
PowerShell:
```powershell
# Test the main invocable class
sf apex run test --class-names DocusignCompositeEnvelopeBuilderTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the request handler
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the API service
sf apex run test --class-names DocusignAPIServiceTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the credentials helper
sf apex run test --class-names DocusignCredentialsTest --wait 10 --result-format human --code-coverage --target-org dev-org
```
---
**Run ALL tests with JSON output (for parsing):** **Run ALL tests with JSON output (for parsing):**
Bash/WSL: Bash/WSL:
@ -184,6 +218,75 @@ Apex Code Coverage: 92%
--- ---
## Updating Template IDs After Sandbox Refresh
After a sandbox refresh, Docusign template IDs may become stale and need updating with the correct demo/sandbox template IDs.
### Find Your Demo Template IDs
1. Log into your Docusign demo account: **https://demo.docusign.net**
2. Go to **Templates**
3. Click each template — the GUID is in the URL or under **Template ID**
4. Copy the template IDs you need
### Option 1: Developer Console (Quickest)
1. Open **Developer Console** (gear icon → Developer Console)
2. Click **Debug** → **Open Execute Anonymous Window**
**Step 1: View current template IDs:**
```apex
List<dfsle__EnvelopeConfiguration__c> configs = [
SELECT Id, Name, dfsle__DocuSignId__c
FROM dfsle__EnvelopeConfiguration__c
ORDER BY Name
];
for (dfsle__EnvelopeConfiguration__c c : configs) {
System.debug(c.Name + ' → ' + c.dfsle__DocuSignId__c);
}
```
Check the **Logs** tab at the bottom for the output.
**Step 2: Update with new template IDs:**
```apex
// Replace template names and IDs with YOUR values
Map<String, String> updates = new Map<String, String>{
'Template Name 1' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'Template Name 2' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};
List<dfsle__EnvelopeConfiguration__c> configs = [
SELECT Id, Name, dfsle__DocuSignId__c
FROM dfsle__EnvelopeConfiguration__c
WHERE Name IN :updates.keySet()
];
for (dfsle__EnvelopeConfiguration__c c : configs) {
c.dfsle__DocuSignId__c = updates.get(c.Name);
}
update configs;
System.debug('Updated ' + configs.size() + ' templates');
```
### Option 2: Query Editor (View Only)
In Developer Console → **Query Editor** tab:
```sql
SELECT Id, Name, dfsle__DocuSignId__c, Envelope_Template_Language__c
FROM dfsle__EnvelopeConfiguration__c
ORDER BY Name
```
### Option 3: Salesforce UI (Point and Click)
1. Use **App Launcher** → search for **Docusign Envelope Templates**
2. Open each record → **Edit**
3. Update the **DocuSign template ID** field
4. **Save**
---
## Post-Deployment Setup ## Post-Deployment Setup
After successful deployment, configure your Salesforce org: After successful deployment, configure your Salesforce org:

View File

@ -1,271 +0,0 @@
/**
* @description Service class for Docusign REST API interactions
* @author Paul Huliganga
* @date 2026-02-23
*/
public with sharing class DocusignAPIService {
private static final Integer TIMEOUT_MS = 120000; // 120 seconds
private static final Integer MAX_RETRIES = 2;
/**
* @description Creates a composite envelope in Docusign
* @param envelopeJSON JSON string containing envelope definition
* @param creds Docusign credentials object
* @return Envelope ID (GUID)
* @throws CalloutException if API call fails
*/
public static String createCompositeEnvelope(
String envelopeJSON,
DocusignCredentials creds
) {
Long startTime = System.currentTimeMillis();
// Build HTTP request
HttpRequest req = buildCreateEnvelopeRequest(envelopeJSON, creds);
// Make callout with retry logic
HttpResponse res = executeWithRetry(req, MAX_RETRIES);
Long duration = System.currentTimeMillis() - startTime;
// Log request/response (sanitized)
logAPICall(req, res, duration);
// Parse response
if (res.getStatusCode() == 201) {
return parseEnvelopeId(res);
} else {
handleAPIError(res);
return null; // Won't reach here, handleAPIError throws
}
}
/**
* @description Builds HTTP request for creating envelope
* @param envelopeJSON JSON body
* @param creds Credentials
* @return Configured HttpRequest
*/
@TestVisible
private static HttpRequest buildCreateEnvelopeRequest(
String envelopeJSON,
DocusignCredentials creds
) {
HttpRequest req = new HttpRequest();
// Use Named Credential if available, otherwise build URL manually
String endpoint = buildEnvelopeEndpoint(creds);
req.setEndpoint(endpoint);
req.setMethod('POST');
req.setHeader('Authorization', 'Bearer ' + creds.getAccessToken());
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setBody(envelopeJSON);
req.setTimeout(TIMEOUT_MS);
return req;
}
/**
* @description Builds envelope endpoint URL
* @param creds Docusign credentials
* @return Full endpoint URL
*/
private static String buildEnvelopeEndpoint(DocusignCredentials creds) {
// Check if using Named Credential (starts with 'callout:')
if (creds.getBaseUrl().startsWith('callout:')) {
return creds.getBaseUrl() + '/accounts/' + creds.getAccountId() + '/envelopes';
} else {
return creds.getBaseUrl() + '/restapi/v2.1/accounts/' + creds.getAccountId() + '/envelopes';
}
}
/**
* @description Executes HTTP request with retry logic
* @param req HTTP request
* @param maxRetries Maximum number of retry attempts
* @return HTTP response
* @throws CalloutException if all retries fail
*/
@TestVisible
private static HttpResponse executeWithRetry(HttpRequest req, Integer maxRetries) {
Http http = new Http();
HttpResponse res;
Integer attempt = 0;
while (attempt <= maxRetries) {
attempt++;
try {
res = http.send(req);
// Success or non-retryable error
if (res.getStatusCode() == 201 || !isRetryableError(res.getStatusCode())) {
return res;
}
// Rate limit - wait before retry
if (res.getStatusCode() == 429 && attempt <= maxRetries) {
Integer waitMs = calculateBackoff(attempt);
System.debug('Rate limited, waiting ' + waitMs + 'ms before retry ' + attempt);
// Note: Apex doesn't have Thread.sleep, but in production this would be handled by Platform Events
// For now, just retry immediately
}
} catch (System.CalloutException e) {
// Timeout or connection error
if (attempt > maxRetries) {
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries: ' + e.getMessage());
}
System.debug('Callout failed (attempt ' + attempt + '): ' + e.getMessage());
}
}
// All retries exhausted
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries. Last status: ' + res.getStatusCode());
}
/**
* @description Determines if an HTTP status code is retryable
* @param statusCode HTTP status code
* @return True if error is transient and should be retried
*/
private static Boolean isRetryableError(Integer statusCode) {
// 401 (token expired), 429 (rate limit), 500-599 (server errors)
return statusCode == 401 || statusCode == 429 || (statusCode >= 500 && statusCode < 600);
}
/**
* @description Calculates exponential backoff delay
* @param attempt Retry attempt number
* @return Delay in milliseconds
*/
private static Integer calculateBackoff(Integer attempt) {
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, ...)
return (Integer) Math.pow(2, attempt) * 1000;
}
/**
* @description Parses envelope ID from successful response
* @param res HTTP response
* @return Envelope ID (GUID)
*/
@TestVisible
private static String parseEnvelopeId(HttpResponse res) {
try {
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String envelopeId = (String) responseMap.get('envelopeId');
if (String.isBlank(envelopeId)) {
throw new CalloutException('Envelope ID not found in Docusign response');
}
return envelopeId;
} catch (Exception e) {
throw new CalloutException('Failed to parse Docusign response: ' + e.getMessage());
}
}
/**
* @description Handles API error responses
* @param res HTTP response with error
* @throws CalloutException with detailed error message
*/
@TestVisible
private static void handleAPIError(HttpResponse res) {
String errorMessage = 'Docusign API error [' + res.getStatusCode() + ']';
try {
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String errorCode = (String) errorBody.get('errorCode');
String message = (String) errorBody.get('message');
if (String.isNotBlank(errorCode)) {
errorMessage += ' ' + errorCode;
}
if (String.isNotBlank(message)) {
errorMessage += ': ' + message;
}
} catch (Exception e) {
// Could not parse error body, use raw response
errorMessage += ': ' + res.getBody();
}
// Map common errors to user-friendly messages
errorMessage = enhanceErrorMessage(res.getStatusCode(), errorMessage);
throw new CalloutException(errorMessage);
}
/**
* @description Enhances error messages with user-friendly guidance
* @param statusCode HTTP status code
* @param originalMessage Original error message
* @return Enhanced error message
*/
private static String enhanceErrorMessage(Integer statusCode, String originalMessage) {
switch on statusCode {
when 400 {
return originalMessage + ' - Check template IDs and request parameters.';
}
when 401 {
return originalMessage + ' - Authentication failed. Check API credentials and access token.';
}
when 403 {
return originalMessage + ' - User lacks permission to create envelopes.';
}
when 404 {
return originalMessage + ' - One or more templates not found in Docusign account.';
}
when 429 {
return originalMessage + ' - API rate limit exceeded. Please try again later.';
}
when 500, 503 {
return originalMessage + ' - Docusign server error. Please try again.';
}
when else {
return originalMessage;
}
}
}
/**
* @description Logs API call details (sanitized, no credentials)
* @param req HTTP request
* @param res HTTP response
* @param durationMs Duration in milliseconds
*/
private static void logAPICall(HttpRequest req, HttpResponse res, Long durationMs) {
System.debug(LoggingLevel.INFO, '=== Docusign API Call ===');
System.debug(LoggingLevel.INFO, 'Method: ' + req.getMethod());
System.debug(LoggingLevel.INFO, 'Endpoint: ' + sanitizeEndpoint(req.getEndpoint()));
System.debug(LoggingLevel.INFO, 'Request Body Length: ' + req.getBody().length() + ' bytes');
System.debug(LoggingLevel.INFO, 'Response Status: ' + res.getStatusCode() + ' ' + res.getStatus());
System.debug(LoggingLevel.INFO, 'Response Body Length: ' + res.getBody().length() + ' bytes');
System.debug(LoggingLevel.INFO, 'Duration: ' + durationMs + 'ms');
// Only log full bodies in DEBUG mode (not in production)
if (Test.isRunningTest()) {
System.debug(LoggingLevel.FINEST, 'Request Body: ' + req.getBody());
System.debug(LoggingLevel.FINEST, 'Response Body: ' + res.getBody());
}
}
/**
* @description Sanitizes endpoint URL for logging (removes account ID)
* @param endpoint Full endpoint URL
* @return Sanitized URL
*/
private static String sanitizeEndpoint(String endpoint) {
// Replace account ID with placeholder for security
return endpoint.replaceAll('/accounts/[a-f0-9\\-]+/', '/accounts/{accountId}/');
}
/**
* @description Custom exception for API errors
*/
public class CalloutException extends Exception {}
}

View File

@ -1,323 +0,0 @@
/**
* @description Test class for DocusignAPIService
* @author Paul Huliganga
* @date 2026-02-23
*/
@isTest
private class DocusignAPIServiceTest {
/**
* @description Mock HTTP callout
*/
private class DocusignMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
private Integer callCount = 0;
public DocusignMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HTTPResponse respond(HTTPRequest req) {
callCount++;
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.responseBody);
res.setStatus(this.statusCode == 201 ? 'Created' : 'Error');
return res;
}
}
/**
* @description Mock that fails first time, succeeds second (for retry testing)
*/
private class RetryMock implements HttpCalloutMock {
private Integer callCount = 0;
public HTTPResponse respond(HTTPRequest req) {
callCount++;
HttpResponse res = new HttpResponse();
if (callCount == 1) {
// First call fails with 500
res.setStatusCode(500);
res.setBody('{"errorCode":"INTERNAL_ERROR","message":"Server error"}');
res.setStatus('Internal Server Error');
} else {
// Second call succeeds
res.setStatusCode(201);
res.setBody('{"envelopeId":"envelope-retry-success","status":"sent"}');
res.setStatus('Created');
}
return res;
}
}
/**
* @description Test successful envelope creation
*/
@isTest
static void testSuccessfulEnvelopeCreation() {
// Arrange
String envelopeId = 'envelope-test-12345';
String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
String envelopeJSON = '{"status":"sent","compositeTemplates":[]}';
// Act
Test.startTest();
String resultEnvelopeId = DocusignAPIService.createCompositeEnvelope(envelopeJSON, creds);
Test.stopTest();
// Assert
System.assertEquals(envelopeId, resultEnvelopeId, 'Should return envelope ID');
}
/**
* @description Test parseEnvelopeId method
*/
@isTest
static void testParseEnvelopeId() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(201);
res.setBody('{"envelopeId":"parsed-envelope-123","uri":"/envelopes/parsed-envelope-123"}');
// Act
Test.startTest();
String envelopeId = DocusignAPIService.parseEnvelopeId(res);
Test.stopTest();
// Assert
System.assertEquals('parsed-envelope-123', envelopeId, 'Should parse envelope ID correctly');
}
/**
* @description Test parseEnvelopeId with missing ID
*/
@isTest
static void testParseEnvelopeIdMissing() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(201);
res.setBody('{"status":"sent"}'); // No envelopeId field
// Act & Assert
Test.startTest();
try {
DocusignAPIService.parseEnvelopeId(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('Envelope ID not found'), 'Should mention missing ID');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 400 Bad Request
*/
@isTest
static void testHandleAPIError400() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(400);
res.setBody('{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('400'), 'Should include status code');
System.assert(e.getMessage().contains('Invalid template'), 'Should include error message');
System.assert(e.getMessage().contains('template IDs'), 'Should include guidance');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 401 Unauthorized
*/
@isTest
static void testHandleAPIError401() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(401);
res.setBody('{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid access token"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('401'), 'Should include status code');
System.assert(e.getMessage().contains('Authentication'), 'Should mention authentication');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 403 Forbidden
*/
@isTest
static void testHandleAPIError403() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(403);
res.setBody('{"errorCode":"USER_LACKS_PERMISSIONS","message":"User cannot send envelopes"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('403'), 'Should include status code');
System.assert(e.getMessage().contains('permission'), 'Should mention permission');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 404 Not Found
*/
@isTest
static void testHandleAPIError404() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(404);
res.setBody('{"errorCode":"RESOURCE_NOT_FOUND","message":"Template not found"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('404'), 'Should include status code');
System.assert(e.getMessage().contains('not found'), 'Should mention template not found');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 429 Rate Limit
*/
@isTest
static void testHandleAPIError429() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(429);
res.setBody('{"errorCode":"HOURLY_APIINVOCATION_LIMIT_EXCEEDED","message":"Rate limit exceeded"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('429'), 'Should include status code');
System.assert(e.getMessage().contains('rate limit'), 'Should mention rate limit');
}
Test.stopTest();
}
/**
* @description Test handleAPIError for 500 Server Error
*/
@isTest
static void testHandleAPIError500() {
// Arrange
HttpResponse res = new HttpResponse();
res.setStatusCode(500);
res.setBody('{"errorCode":"INTERNAL_ERROR","message":"Server error"}');
// Act & Assert
Test.startTest();
try {
DocusignAPIService.handleAPIError(res);
System.assert(false, 'Should have thrown exception');
} catch (DocusignAPIService.CalloutException e) {
System.assert(e.getMessage().contains('500'), 'Should include status code');
System.assert(e.getMessage().contains('server error'), 'Should mention server error');
}
Test.stopTest();
}
/**
* @description Test retry logic with transient failure
*/
@isTest
static void testRetryLogic() {
// Arrange
Test.setMock(HttpCalloutMock.class, new RetryMock());
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
String envelopeJSON = '{"status":"sent","compositeTemplates":[]}';
// Act
Test.startTest();
String envelopeId = DocusignAPIService.createCompositeEnvelope(envelopeJSON, creds);
Test.stopTest();
// Assert
System.assertEquals('envelope-retry-success', envelopeId, 'Should succeed after retry');
}
/**
* @description Test buildCreateEnvelopeRequest
*/
@isTest
static void testBuildCreateEnvelopeRequest() {
// Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
String envelopeJSON = '{"status":"sent"}';
// Act
Test.startTest();
HttpRequest req = DocusignAPIService.buildCreateEnvelopeRequest(envelopeJSON, creds);
Test.stopTest();
// Assert
System.assertEquals('POST', req.getMethod(), 'Should use POST method');
System.assert(req.getEndpoint().contains('/envelopes'), 'Should have envelopes endpoint');
System.assert(req.getHeader('Authorization').contains('Bearer'), 'Should have Bearer token');
System.assertEquals('application/json', req.getHeader('Content-Type'), 'Should set JSON content type');
System.assertEquals(envelopeJSON, req.getBody(), 'Should set body');
}
/**
* @description Test executeWithRetry with non-retryable error
*/
@isTest
static void testExecuteWithRetryNonRetryable() {
// Arrange
String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Bad request"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCredentials creds = DocusignCredentials.getInstance();
HttpRequest req = DocusignAPIService.buildCreateEnvelopeRequest('{}', creds);
// Act
Test.startTest();
HttpResponse res = DocusignAPIService.executeWithRetry(req, 2);
Test.stopTest();
// Assert
System.assertEquals(400, res.getStatusCode(), 'Should return 400 without retry');
}
}

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -1,70 +1,151 @@
/** /**
* @description Main invocable class for combining multiple Docusign templates into a single envelope * @description Combines multiple Docusign templates into a single composite envelope
* using the dfsle Apex Toolkit (Docusign for Salesforce managed package).
* Recipients are resolved from Client_Case__c lookup fields.
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-23 * @date 2026-02-25
*/ */
global with sharing class DocusignCompositeEnvelopeBuilder { global with sharing class DocusignCompositeEnvelopeBuilder {
/** // ============================================================
* @description Invocable method called from Salesforce Screen Flow // CONFIGURATION: Update these constants if field/role names change
* @param requests List of request objects containing template IDs and metadata // ============================================================
* @return List of result objects with envelope ID and status
*/ // API names of the lookup fields on Client_Case__c that point to recipient records
// These are the "Select Lookup Field" values from the Docusign template recipient config
private static final String FIELD_SERVICE_COORDINATOR = 'Service_Coordinator__c';
private static final String FIELD_DOCUSIGN_RECIPIENT = 'Docusign_Recipient_1__c';
// Role names must match EXACTLY what's configured in the Docusign templates
private static final String ROLE_SERVICE_COORDINATOR = 'Service Coordinator';
private static final String ROLE_DOCUSIGN_RECIPIENT = 'Docusign Recipient #1';
@InvocableMethod( @InvocableMethod(
label='Send Composite Docusign Envelope' label='Send Composite Docusign Envelope'
description='Combines multiple Docusign templates into a single envelope' description='Combines multiple Docusign templates into a single envelope using dfsle Apex Toolkit'
category='Docusign' category='Docusign'
) )
public static List<Result> sendCompositeEnvelope(List<Request> requests) { public static List<DocusignEnvelopeResult> sendCompositeEnvelope(List<DocusignEnvelopeRequest> requests) {
List<Result> results = new List<Result>(); List<DocusignEnvelopeResult> results = new List<DocusignEnvelopeResult>();
// Process first request (Flow only sends one)
if (requests == null || requests.isEmpty()) { if (requests == null || requests.isEmpty()) {
return buildErrorResult('No request provided'); return buildErrorResult('No request provided');
} }
Request req = requests[0]; DocusignEnvelopeRequest req = requests[0];
Result result = new Result(); DocusignEnvelopeResult result = new DocusignEnvelopeResult();
try { try {
// Validate request using handler // Validate request
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
// Build envelope JSON using handler // Create empty envelope linked to the source record
String envelopeJSON = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req); dfsle.Envelope myEnvelope = dfsle.EnvelopeService.getEmptyEnvelope(
new dfsle.Entity(req.recordId)
// Get Docusign credentials
DocusignCredentials creds = DocusignCredentials.getInstance();
// Call Docusign API
String envelopeId = DocusignAPIService.createCompositeEnvelope(
envelopeJSON,
creds
); );
// Build document list from templates (deduplicated and sorted)
List<String> sortedTemplateIds = new List<String>(new Set<String>(req.templateIds));
sortedTemplateIds.sort();
// Query template names for document labels (shows in Docusign Status)
// Uses Short_Name__c if populated, otherwise falls back to Name (with language suffix stripped)
Map<String, String> templateNames = new Map<String, String>();
Map<String, String> templateShortNames = new Map<String, String>();
for (dfsle__EnvelopeConfiguration__c config : [
SELECT dfsle__DocuSignId__c, Name, Short_Name__c
FROM dfsle__EnvelopeConfiguration__c
WHERE dfsle__DocuSignId__c IN :sortedTemplateIds
]) {
templateNames.put(config.dfsle__DocuSignId__c, config.Name);
if (String.isNotBlank(config.Short_Name__c)) {
templateShortNames.put(config.dfsle__DocuSignId__c, config.Short_Name__c);
}
}
List<dfsle.Document> documents = new List<dfsle.Document>();
List<String> docNames = new List<String>();
for (String templateId : sortedTemplateIds) {
String label;
if (templateShortNames.containsKey(templateId)) {
label = templateShortNames.get(templateId);
} else if (templateNames.containsKey(templateId)) {
label = stripLanguageSuffix(templateNames.get(templateId));
} else {
label = templateId;
}
documents.add(
dfsle.Document.fromTemplate(
dfsle.UUID.parse(templateId),
label
)
);
docNames.add(label);
}
myEnvelope = myEnvelope.withDocuments(documents);
// Set combined template names as the envelope document name
// (shows in Docusign Status "Document Name" column)
String combinedName = String.join(docNames, ', ');
if (combinedName.length() > 255) {
combinedName = combinedName.left(252) + '...';
}
// Use combined name as the first document label so it appears in Status
if (!documents.isEmpty()) {
documents[0] = dfsle.Document.fromTemplate(
dfsle.UUID.parse(sortedTemplateIds[0]),
combinedName
);
myEnvelope = myEnvelope.withDocuments(documents);
}
// Resolve recipients from Client_Case__c lookup fields
List<dfsle.Recipient> recipients = resolveRecipients(req.recordId);
myEnvelope = myEnvelope.withRecipients(recipients);
// Set envelope subject to combined template names and body to concatenated template email messages
// Query for EmailMessage__c
Map<String, String> templateBodies = new Map<String, String>();
for (dfsle__EnvelopeConfiguration__c config : [
SELECT dfsle__DocuSignId__c, dfsle__EmailMessage__c
FROM dfsle__EnvelopeConfiguration__c
WHERE dfsle__DocuSignId__c IN :sortedTemplateIds
]) {
if (String.isNotBlank(config.dfsle__EmailMessage__c)) {
templateBodies.put(config.dfsle__DocuSignId__c, config.dfsle__EmailMessage__c);
}
}
List<String> bodyParts = new List<String>();
for (String templateId : sortedTemplateIds) {
if (templateBodies.containsKey(templateId)) {
bodyParts.add(templateBodies.get(templateId));
}
}
String envelopeSubject = combinedName;
String envelopeBody = bodyParts.isEmpty() ? '' : String.join(bodyParts, '\n\n');
myEnvelope = myEnvelope.withEmail(envelopeSubject, envelopeBody);
// Send the envelope
myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, true);
// Success // Success
result.envelopeId = envelopeId; result.envelopeId = String.valueOf(myEnvelope.docuSignId);
result.success = true; result.success = true;
result.errorMessage = null; result.errorMessage = null;
// Log success logResult(sortedTemplateIds.size(), result.envelopeId, 'Success (' + String.join(docNames, ', ') + ')', null);
logAPICall(req.templateIds.size(), envelopeId, 'Success', null);
} catch (Exception e) { } catch (Exception e) {
// Error handling
result.success = false; result.success = false;
result.errorMessage = e.getMessage(); result.errorMessage = e.getMessage();
result.envelopeId = null; result.envelopeId = null;
// Log error logResult(
logAPICall(
req.templateIds != null ? req.templateIds.size() : 0, req.templateIds != null ? req.templateIds.size() : 0,
null, null, 'Error',
'Error',
e.getMessage() + '\n' + e.getStackTraceString() e.getMessage() + '\n' + e.getStackTraceString()
); );
// Re-throw if critical (governor limits)
if (e instanceof System.LimitException) { if (e instanceof System.LimitException) {
throw e; throw e;
} }
@ -75,96 +156,123 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
} }
/** /**
* @description Logs API call to debug log (future: custom object) * @description Resolves recipients from Client_Case__c lookup fields.
* @param templateCount Number of templates in envelope * Queries the case record and related contacts to get name/email.
* @param envelopeId Docusign envelope ID * @param recordId The Client_Case__c record ID
* @param status Success or Error * @return List of dfsle.Recipient objects with role mappings
* @param errorMessage Error message if applicable
*/ */
private static void logAPICall( private static List<dfsle.Recipient> resolveRecipients(String recordId) {
Integer templateCount, // Query the Client_Case__c record with recipient lookup fields
String envelopeId, // NOTE: Adjust field API names if they differ in your org
String status, String query = 'SELECT Id, '
String errorMessage + FIELD_SERVICE_COORDINATOR + ', '
) { + FIELD_DOCUSIGN_RECIPIENT
System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope API Call ==='); + ' FROM Client_Case__c WHERE Id = :recordId LIMIT 1';
System.debug(LoggingLevel.INFO, 'Timestamp: ' + System.now());
System.debug(LoggingLevel.INFO, 'Template Count: ' + templateCount); Client_Case__c caseRecord = Database.query(query);
List<dfsle.Recipient> recipients = new List<dfsle.Recipient>();
Integer routingOrder = 1;
// Recipient 1: Service Coordinator
Id serviceCoordinatorId = (Id) caseRecord.get(FIELD_SERVICE_COORDINATOR);
if (serviceCoordinatorId != null) {
recipients.add(buildRecipient(serviceCoordinatorId, ROLE_SERVICE_COORDINATOR, routingOrder++, recordId));
}
// Recipient 2: Docusign Recipient #1
Id docusignRecipientId = (Id) caseRecord.get(FIELD_DOCUSIGN_RECIPIENT);
if (docusignRecipientId != null) {
recipients.add(buildRecipient(docusignRecipientId, ROLE_DOCUSIGN_RECIPIENT, routingOrder++, recordId));
}
if (recipients.isEmpty()) {
throw new IllegalArgumentException('No recipients found on the Client Case record. '
+ 'Please ensure Service Coordinator and Docusign Recipient #1 are populated.');
}
return recipients;
}
/**
* @description Builds a dfsle.Recipient from a Contact/User lookup ID
* @param recipientId The Contact or User record ID
* @param roleName The Docusign template role name
* @param routingOrder Signing order
* @param sourceRecordId The source Client_Case__c record ID
* @return dfsle.Recipient configured for the role
*/
private static dfsle.Recipient buildRecipient(Id recipientId, String roleName, Integer routingOrder, String sourceRecordId) {
// Determine if this is a Contact or User
String objectType = recipientId.getSObjectType().getDescribe().getName();
String recipientName;
String recipientEmail;
if (objectType == 'Contact') {
Contact c = [SELECT Id, Name, Email FROM Contact WHERE Id = :recipientId LIMIT 1];
recipientName = c.Name;
recipientEmail = c.Email;
} else if (objectType == 'User') {
User u = [SELECT Id, Name, Email FROM User WHERE Id = :recipientId LIMIT 1];
recipientName = u.Name;
recipientEmail = u.Email;
} else {
throw new IllegalArgumentException('Unsupported recipient type: ' + objectType
+ '. Expected Contact or User.');
}
if (String.isBlank(recipientEmail)) {
throw new IllegalArgumentException('No email found for ' + roleName + ' (' + recipientName + '). '
+ 'Please ensure the recipient has a valid email address.');
}
return dfsle.Recipient.fromSource(
recipientName,
recipientEmail,
null, // phone (optional)
roleName, // must match template role exactly
new dfsle.Entity(sourceRecordId) // source record for merge fields
);
}
private static void logResult(Integer templateCount, String envelopeId, String status, String errorMessage) {
System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope ===');
System.debug(LoggingLevel.INFO, 'Templates: ' + templateCount);
System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId); System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId);
System.debug(LoggingLevel.INFO, 'Status: ' + status); System.debug(LoggingLevel.INFO, 'Status: ' + status);
if (String.isNotBlank(errorMessage)) { if (String.isNotBlank(errorMessage)) {
System.debug(LoggingLevel.ERROR, 'Error: ' + errorMessage); System.debug(LoggingLevel.ERROR, 'Error: ' + errorMessage);
} }
// Future enhancement: Insert into Docusign_API_Log__c custom object
} }
/** /**
* @description Helper to build error result * @description Strips language suffixes like " - English" or " - Spanish" from template names
* @param errorMessage Error message * @param name Template name
* @return List containing single error result * @return Cleaned template name
*/ */
private static List<Result> buildErrorResult(String errorMessage) { @TestVisible
Result result = new Result(); private static String stripLanguageSuffix(String name) {
if (String.isBlank(name)) return name;
// Remove common language suffixes (case-insensitive)
String cleaned = name;
for (String suffix : new List<String>{
' - English', ' - Spanish', ' - French',
' - Anglais', ' - Espagnol', ' - Français'
}) {
if (cleaned.endsWithIgnoreCase(suffix)) {
cleaned = cleaned.left(cleaned.length() - suffix.length());
break;
}
}
return cleaned.trim();
}
private static List<DocusignEnvelopeResult> buildErrorResult(String errorMessage) {
DocusignEnvelopeResult result = new DocusignEnvelopeResult();
result.success = false; result.success = false;
result.errorMessage = errorMessage; result.errorMessage = errorMessage;
result.envelopeId = null; result.envelopeId = null;
return new List<Result>{ result }; return new List<DocusignEnvelopeResult>{ result };
}
/**
* @description Input parameters for invocable method (from Screen Flow)
*/
global class Request {
@InvocableVariable(
label='Template IDs'
description='List of Docusign template IDs to combine'
required=true
)
public List<String> templateIds;
@InvocableVariable(
label='Salesforce Record ID'
description='ID of the Salesforce record to attach documents to'
required=true
)
public String recordId;
@InvocableVariable(
label='Language'
description='Language code (en or es)'
required=false
)
public String language;
@InvocableVariable(
label='Email Subject'
description='Subject line for envelope email'
required=false
)
public String emailSubject;
}
/**
* @description Output parameters for invocable method (to Screen Flow)
*/
global class Result {
@InvocableVariable(
label='Envelope ID'
description='Docusign envelope ID'
)
public String envelopeId;
@InvocableVariable(
label='Success'
description='True if envelope was created successfully'
)
public Boolean success;
@InvocableVariable(
label='Error Message'
description='Error message if envelope creation failed'
)
public String errorMessage;
} }
} }

View File

@ -1,281 +1,266 @@
/** /**
* @description Test class for DocusignCompositeEnvelopeBuilder * @description Test class for DocusignCompositeEnvelopeBuilder (dfsle Apex Toolkit)
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-23 * @date 2026-02-25
*/ */
@isTest @isTest
private class DocusignCompositeEnvelopeBuilderTest { private class DocusignCompositeEnvelopeBuilderTest {
/**
* @description Mock HTTP callout for Docusign API
*/
private class DocusignMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
public DocusignMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.responseBody);
res.setStatus(this.statusCode == 201 ? 'Created' : 'Error');
return res;
}
}
/**
* @description Setup test data
*/
@testSetup
static void setup() {
// Create Custom Setting for credentials
Docusign_Configuration__c config = new Docusign_Configuration__c();
config.Account_Id__c = 'test-account-id-12345';
config.Base_URL__c = 'callout:DocusignAPI';
insert config;
}
/**
* @description Test successful envelope creation with 3 templates
*/
@isTest @isTest
static void testSuccessfulEnvelopeCreation() { static void testSuccessfulCompositeEnvelope() {
// Arrange // Arrange
String envelopeId = 'envelope-12345-abcde'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}';
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); '01234567-abcd-ef01-2345-6789abcdef01',
req.templateIds = new List<String>{'template-1', 'template-2', 'template-3'}; '01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef03'
};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
req.language = 'en'; req.language = 'en';
req.emailSubject = 'Test Envelope'; req.emailSubject = 'Please sign these forms';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(1, results.size(), 'Should return 1 result'); System.assertEquals(1, results.size(), 'Should return 1 result');
System.assertEquals(true, results[0].success, 'Should be successful'); System.assertEquals(true, results[0].success, 'Should be successful');
System.assertEquals(envelopeId, results[0].envelopeId, 'Should return envelope ID'); System.assertNotEquals(null, results[0].envelopeId, 'Should have envelope ID');
System.assertEquals(null, results[0].errorMessage, 'Should have no error message'); System.assertEquals(null, results[0].errorMessage, 'Should have no error');
} }
/**
* @description Test with single template (minimum)
*/
@isTest @isTest
static void testSingleTemplate() { static void testSingleTemplate() {
// Arrange // Arrange
String mockResponse = '{"envelopeId":"envelope-single","status":"sent"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{'template-1'};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should be successful with 1 template'); System.assertEquals(true, results[0].success, 'Should succeed with 1 template');
} }
/**
* @description Test with 14 templates (maximum)
*/
@isTest @isTest
static void testMaximumTemplates() { static void testMaximumTemplates() {
// Arrange // Arrange
String mockResponse = '{"envelopeId":"envelope-max","status":"sent"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
List<String> templateIds = new List<String>(); List<String> templateIds = new List<String>();
for (Integer i = 1; i <= 14; i++) { for (Integer i = 1; i <= 14; i++) {
templateIds.add('template-' + i); templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0'));
} }
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = templateIds; req.templateIds = templateIds;
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should be successful with 14 templates'); System.assertEquals(true, results[0].success, 'Should succeed with 14 templates');
} }
/**
* @description Test with duplicate template IDs (should be deduplicated)
*/
@isTest @isTest
static void testDuplicateTemplates() { static void testDuplicateTemplatesDeduped() {
// Arrange // Arrange
String mockResponse = '{"envelopeId":"envelope-dedup","status":"sent"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); '01234567-abcd-ef01-2345-6789abcdef01',
req.templateIds = new List<String>{'template-1', 'template-2', 'template-1'}; // Duplicate '01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef01' // duplicate
};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should handle duplicates'); System.assertEquals(true, results[0].success, 'Should handle duplicates');
} }
// Custom fields test removed - not supported in InvocableVariable (Phase 2 enhancement) @isTest
static void testNullRequest() {
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(null);
Test.stopTest();
// Assert
System.assertEquals(false, results[0].success, 'Should fail with null request');
System.assertEquals('No request provided', results[0].errorMessage, 'Should have error message');
}
@isTest
static void testEmptyRequest() {
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>()
);
Test.stopTest();
// Assert
System.assertEquals(false, results[0].success, 'Should fail with empty request');
}
/**
* @description Test validation failure - no template IDs
*/
@isTest @isTest
static void testValidationNoTemplates() { static void testValidationNoTemplates() {
// Arrange // Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>();
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>(); // Empty
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail validation'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Error should mention templates'); System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Should mention templates');
} }
/**
* @description Test validation failure - too many templates
*/
@isTest @isTest
static void testValidationTooManyTemplates() { static void testValidationTooManyTemplates() {
// Arrange // Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
List<String> templateIds = new List<String>(); List<String> templateIds = new List<String>();
for (Integer i = 1; i <= 15; i++) { for (Integer i = 1; i <= 15; i++) {
templateIds.add('template-' + i); templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0'));
} }
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = templateIds; // 15 templates (> max of 14) req.templateIds = templateIds;
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail validation'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.contains('Maximum 14'), 'Error should mention limit'); System.assert(results[0].errorMessage.contains('Maximum 14'), 'Should mention limit');
} }
/**
* @description Test validation failure - no record ID
*/
@isTest @isTest
static void testValidationNoRecordId() { static void testValidationNoRecordId() {
// Arrange // Arrange
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); req.recordId = '';
req.templateIds = new List<String>{'template-1'};
req.recordId = ''; // Blank
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail validation'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.contains('record ID'), 'Error should mention record ID'); System.assert(results[0].errorMessage.contains('record ID'), 'Should mention record ID');
} }
/**
* @description Test API error - 400 Bad Request
*/
@isTest @isTest
static void testAPIError400() { static void testValidationBlankTemplateId() {
// Arrange // Arrange
String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}'; DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse)); req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01', ''};
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{'invalid-template'};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail with API error'); System.assertEquals(false, results[0].success, 'Should fail validation');
System.assert(results[0].errorMessage.contains('400'), 'Error should include status code'); System.assert(results[0].errorMessage.contains('blank'), 'Should mention blank');
} }
/**
* @description Test API error - 401 Unauthorized
*/
@isTest @isTest
static void testAPIError401() { static void testWithEmailSubject() {
// Arrange // Arrange
String mockResponse = '{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid token"}'; dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
Test.setMock(HttpCalloutMock.class, new DocusignMock(401, mockResponse));
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'invalid-token'); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{'template-1'};
req.recordId = '001000000ABC123'; req.recordId = '001000000ABC123';
req.emailSubject = 'Custom: Please review and sign';
// Act // Act
Test.startTest(); Test.startTest();
List<DocusignCompositeEnvelopeBuilder.Result> results = List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req}); DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest(); Test.stopTest();
// Assert // Assert
System.assertEquals(false, results[0].success, 'Should fail with auth error'); System.assertEquals(true, results[0].success, 'Should succeed with custom subject');
System.assertNotEquals(null, results[0].errorMessage, 'Should have error message'); }
System.assert(String.isNotBlank(results[0].errorMessage), 'Error message should not be blank');
@isTest
static void testWithoutEmailSubject() {
// Arrange
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123';
req.emailSubject = null;
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest();
// Assert
System.assertEquals(true, results[0].success, 'Should succeed without subject');
} }
} }

View File

@ -1,187 +0,0 @@
/**
* @description Manages Docusign API credentials and access tokens
* @author Paul Huliganga
* @date 2026-02-23
*/
public with sharing class DocusignCredentials {
private String baseUrl;
private String accountId;
private String accessToken;
private DateTime tokenExpiry;
// Singleton instance
private static DocusignCredentials instance;
// Flag to skip loadCredentials for setTestCredentials
private static Boolean skipLoadForNextInstance = false;
/**
* @description Private constructor (singleton pattern)
*/
private DocusignCredentials() {
// Only load credentials if not explicitly skipped (for setTestCredentials)
if (!skipLoadForNextInstance) {
loadCredentials();
}
skipLoadForNextInstance = false; // Reset flag
}
/**
* @description Gets singleton instance of credentials
* @return DocusignCredentials instance
*/
public static DocusignCredentials getInstance() {
if (instance == null) {
instance = new DocusignCredentials();
}
// Refresh token if expired
if (instance.isTokenExpired()) {
instance.refreshAccessToken();
}
return instance;
}
/**
* @description Loads credentials from Named Credential or Custom Settings
*/
private void loadCredentials() {
// Option 1: Using Named Credential (preferred)
// When using Named Credential, the platform handles authentication automatically
// We just need the account ID and base URL
try {
// Try to load from Custom Settings first
Docusign_Configuration__c config = Docusign_Configuration__c.getOrgDefaults();
if (config != null && String.isNotBlank(config.Account_Id__c)) {
this.accountId = config.Account_Id__c;
this.baseUrl = String.isNotBlank(config.Base_URL__c)
? config.Base_URL__c
: 'callout:DocusignAPI'; // Default to Named Credential
// If using Named Credential, token is managed by platform
if (this.baseUrl.startsWith('callout:')) {
this.accessToken = 'MANAGED_BY_NAMED_CREDENTIAL';
this.tokenExpiry = DateTime.now().addHours(1);
} else {
// Manual token management would go here
// For now, throw exception - must configure Named Credential
throw new CredentialException('Manual token management not implemented. Please use Named Credential.');
}
} else {
throw new CredentialException('Docusign credentials not configured. Please set up Custom Settings: Docusign_Configuration__c');
}
} catch (Exception e) {
throw new CredentialException('Failed to load Docusign credentials: ' + e.getMessage());
}
}
/**
* @description Checks if access token is expired
* @return True if token is expired or about to expire (within 5 minutes)
*/
private Boolean isTokenExpired() {
if (this.tokenExpiry == null) {
return true;
}
// Refresh 5 minutes before expiry to avoid edge cases
DateTime threshold = DateTime.now().addMinutes(5);
return this.tokenExpiry < threshold;
}
/**
* @description Refreshes access token (JWT or OAuth2)
* Note: In production with Named Credential, this is handled automatically by Salesforce
*/
private void refreshAccessToken() {
// If using Named Credential, no manual refresh needed
if (this.baseUrl.startsWith('callout:')) {
this.tokenExpiry = DateTime.now().addHours(1);
return;
}
// Manual JWT token refresh would go here
// This is a placeholder for future enhancement
throw new CredentialException('Manual token refresh not implemented. Use Named Credential for automatic token management.');
}
/**
* @description Gets access token
* @return Access token string
*/
public String getAccessToken() {
if (isTokenExpired()) {
refreshAccessToken();
}
return this.accessToken;
}
/**
* @description Gets Docusign account ID
* @return Account ID (GUID)
*/
public String getAccountId() {
return this.accountId;
}
/**
* @description Gets base URL for Docusign API
* @return Base URL (either Named Credential callout or full URL)
*/
public String getBaseUrl() {
return this.baseUrl;
}
/**
* @description Validates that credentials are properly configured
* @return True if valid
* @throws CredentialException if invalid
*/
public Boolean validate() {
if (String.isBlank(this.accountId)) {
throw new CredentialException('Docusign Account ID is not configured');
}
if (String.isBlank(this.baseUrl)) {
throw new CredentialException('Docusign Base URL is not configured');
}
if (String.isBlank(this.accessToken)) {
throw new CredentialException('Docusign Access Token is not available');
}
return true;
}
/**
* @description Resets singleton instance (for testing)
*/
@TestVisible
private static void resetInstance() {
instance = null;
}
/**
* @description Sets credentials manually (for testing)
*/
@TestVisible
private static void setTestCredentials(String testAccountId, String testBaseUrl, String testAccessToken) {
// Set flag to skip loadCredentials for this instance
skipLoadForNextInstance = true;
instance = new DocusignCredentials();
// Override with specific test values
instance.accountId = testAccountId;
instance.baseUrl = testBaseUrl;
instance.accessToken = testAccessToken;
instance.tokenExpiry = DateTime.now().addHours(1);
}
/**
* @description Custom exception for credential errors
*/
public class CredentialException extends Exception {}
}

View File

@ -1,258 +0,0 @@
/**
* @description Test class for DocusignCredentials
* @author Paul Huliganga
* @date 2026-02-23
*/
@isTest
private class DocusignCredentialsTest {
/**
* @description Setup test data
*/
@testSetup
static void setup() {
// Create Custom Setting for credentials
Docusign_Configuration__c config = new Docusign_Configuration__c();
config.Account_Id__c = 'test-account-id-12345';
config.Base_URL__c = 'callout:DocusignAPI';
insert config;
}
/**
* @description Test singleton pattern
*/
@isTest
static void testGetInstance() {
// Act
Test.startTest();
DocusignCredentials creds1 = DocusignCredentials.getInstance();
DocusignCredentials creds2 = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertEquals(creds1, creds2, 'Should return same instance (singleton)');
}
/**
* @description Test loading credentials from Custom Settings
*/
@isTest
static void testLoadCredentialsFromCustomSettings() {
// Reset instance to force fresh load from Custom Settings
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertEquals('test-account-id-12345', creds.getAccountId(), 'Should load account ID');
System.assertEquals('callout:DocusignAPI', creds.getBaseUrl(), 'Should load base URL');
System.assertNotEquals(null, creds.getAccessToken(), 'Should have access token');
}
/**
* @description Test getAccessToken method
*/
@isTest
static void testGetAccessToken() {
// Arrange
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String token = creds.getAccessToken();
Test.stopTest();
// Assert
System.assertNotEquals(null, token, 'Should return access token');
}
/**
* @description Test getAccountId method
*/
@isTest
static void testGetAccountId() {
// Reset instance to force fresh load from Custom Settings
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String accountId = creds.getAccountId();
Test.stopTest();
// Assert
System.assertEquals('test-account-id-12345', accountId, 'Should return account ID');
}
/**
* @description Test getBaseUrl method
*/
@isTest
static void testGetBaseUrl() {
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String baseUrl = creds.getBaseUrl();
Test.stopTest();
// Assert
System.assertEquals('callout:DocusignAPI', baseUrl, 'Should return base URL');
}
/**
* @description Test validate method with valid credentials
*/
@isTest
static void testValidateSuccess() {
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
Boolean isValid = creds.validate();
Test.stopTest();
// Assert
System.assertEquals(true, isValid, 'Should validate successfully');
}
/**
* @description Test validate method with missing account ID
*/
@isTest
static void testValidateMissingAccountId() {
// Arrange
DocusignCredentials.resetInstance();
DocusignCredentials.setTestCredentials('', 'callout:DocusignAPI', 'test-token');
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
creds.validate();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().contains('Account ID'), 'Should mention Account ID');
}
Test.stopTest();
}
/**
* @description Test validate method with missing base URL
*/
@isTest
static void testValidateMissingBaseUrl() {
// Arrange
DocusignCredentials.resetInstance();
DocusignCredentials.setTestCredentials('test-account-id', '', 'test-token');
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
creds.validate();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().contains('Base URL'), 'Should mention Base URL');
}
Test.stopTest();
}
/**
* @description Test validate method with missing access token
*/
@isTest
static void testValidateMissingAccessToken() {
// Arrange
DocusignCredentials.resetInstance();
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', '');
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
creds.validate();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().contains('Access Token'), 'Should mention Access Token');
}
Test.stopTest();
}
/**
* @description Test setTestCredentials method
*/
@isTest
static void testSetTestCredentials() {
// Arrange
DocusignCredentials.resetInstance();
// Act
Test.startTest();
DocusignCredentials.setTestCredentials('test-acc-123', 'https://test.docusign.net', 'test-token-xyz');
DocusignCredentials creds = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertEquals('test-acc-123', creds.getAccountId(), 'Should set test account ID');
System.assertEquals('https://test.docusign.net', creds.getBaseUrl(), 'Should set test base URL');
System.assertEquals('test-token-xyz', creds.getAccessToken(), 'Should set test access token');
}
/**
* @description Test resetInstance method
*/
@isTest
static void testResetInstance() {
// Arrange
DocusignCredentials creds1 = DocusignCredentials.getInstance();
// Act
Test.startTest();
DocusignCredentials.resetInstance();
DocusignCredentials creds2 = DocusignCredentials.getInstance();
Test.stopTest();
// Assert
System.assertNotEquals(creds1, creds2, 'Should create new instance after reset');
}
/**
* @description Test error when Custom Settings not configured
*/
@isTest
static void testMissingCustomSettings() {
// Arrange
// Reset instance first
DocusignCredentials.resetInstance();
// Delete the test setup data
delete [SELECT Id FROM Docusign_Configuration__c];
// Act & Assert
Test.startTest();
try {
DocusignCredentials creds = DocusignCredentials.getInstance();
System.assert(false, 'Should have thrown exception');
} catch (DocusignCredentials.CredentialException e) {
System.assert(e.getMessage().containsIgnoreCase('not configured'), 'Should mention not configured');
}
Test.stopTest();
}
/**
* @description Test Named Credential URL format
*/
@isTest
static void testNamedCredentialFormat() {
// Act
Test.startTest();
DocusignCredentials creds = DocusignCredentials.getInstance();
String baseUrl = creds.getBaseUrl();
Test.stopTest();
// Assert
System.assert(baseUrl.startsWith('callout:'), 'Named Credential URL should start with callout:');
}
}

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -0,0 +1,35 @@
/**
* @description Input parameters for DocusignCompositeEnvelopeBuilder invocable method
* @author Paul Huliganga
* @date 2026-02-25
*/
global class DocusignEnvelopeRequest {
@InvocableVariable(
label='Template IDs'
description='List of Docusign template IDs to combine'
required=true
)
global List<String> templateIds;
@InvocableVariable(
label='Salesforce Record ID'
description='ID of the Salesforce record to attach documents to'
required=true
)
global String recordId;
@InvocableVariable(
label='Language'
description='Language code (en or es)'
required=false
)
global String language;
@InvocableVariable(
label='Email Subject'
description='Subject line for envelope email'
required=false
)
global String emailSubject;
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata"> <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion> <apiVersion>60.0</apiVersion>
<status>Active</status> <status>Active</status>
</ApexClass> </ApexClass>

View File

@ -1,5 +1,5 @@
/** /**
* @description Handles request validation and envelope JSON building for Docusign composite envelopes * @description Validates request parameters for Docusign composite envelopes
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-25 * @date 2026-02-25
*/ */
@ -10,7 +10,7 @@ global with sharing class DocusignEnvelopeRequestHandler {
* @param req Request object to validate * @param req Request object to validate
* @throws IllegalArgumentException if validation fails * @throws IllegalArgumentException if validation fails
*/ */
public static void validateRequest(DocusignCompositeEnvelopeBuilder.Request req) { public static void validateRequest(DocusignEnvelopeRequest req) {
if (req.templateIds == null || req.templateIds.isEmpty()) { if (req.templateIds == null || req.templateIds.isEmpty()) {
throw new IllegalArgumentException('At least one template ID is required'); throw new IllegalArgumentException('At least one template ID is required');
} }
@ -23,151 +23,10 @@ global with sharing class DocusignEnvelopeRequestHandler {
throw new IllegalArgumentException('Salesforce record ID is required'); throw new IllegalArgumentException('Salesforce record ID is required');
} }
// Check for null template IDs
for (String templateId : req.templateIds) { for (String templateId : req.templateIds) {
if (String.isBlank(templateId)) { if (String.isBlank(templateId)) {
throw new IllegalArgumentException('Template ID cannot be blank'); throw new IllegalArgumentException('Template ID cannot be blank');
} }
} }
} }
/**
* @description Builds composite envelope JSON for Docusign API from request
* @param req Request object containing parameters
* @return JSON string for Docusign API request
*/
public static String buildEnvelopeJSON(DocusignCompositeEnvelopeBuilder.Request req) {
// Remove duplicates and sort alphabetically
List<String> sortedTemplateIds = sortTemplatesAlphabetically(
new Set<String>(req.templateIds)
);
// Build composite envelope JSON
return buildCompositeEnvelopeJSON(
sortedTemplateIds,
req.recordId,
req.language,
req.emailSubject,
null // customFields not supported in InvocableVariable (Phase 2 enhancement)
);
}
/**
* @description Removes duplicates and sorts template IDs alphabetically
* @param templateIdSet Set of template IDs
* @return Sorted list of unique template IDs
*/
private static List<String> sortTemplatesAlphabetically(Set<String> templateIdSet) {
List<String> sortedList = new List<String>(templateIdSet);
sortedList.sort();
return sortedList;
}
/**
* @description Builds composite envelope JSON for Docusign API
* @param templateIds List of template IDs to combine
* @param recordId Salesforce record ID for custom fields
* @param language Language code (en/es)
* @param emailSubject Email subject line
* @param customFields Map of custom field name/value pairs
* @return JSON string for Docusign API request
*/
private static String buildCompositeEnvelopeJSON(
List<String> templateIds,
String recordId,
String language,
String emailSubject,
Map<String, String> customFields
) {
// Build composite templates array
List<Object> compositeTemplates = new List<Object>();
Integer sequence = 1;
for (String templateId : templateIds) {
Map<String, Object> compositeTemplate = new Map<String, Object>{
'compositeTemplateId' => String.valueOf(sequence),
'serverTemplates' => new List<Object>{
new Map<String, Object>{
'sequence' => String.valueOf(sequence),
'templateId' => templateId
}
}
};
// Add custom fields if this is the first template
if (sequence == 1 && (String.isNotBlank(recordId) || customFields != null)) {
compositeTemplate.put('inlineTemplates', buildInlineTemplates(recordId, language, customFields));
}
compositeTemplates.add(compositeTemplate);
sequence++;
}
// Build envelope object
Map<String, Object> envelope = new Map<String, Object>{
'status' => 'sent',
'emailSubject' => String.isNotBlank(emailSubject)
? emailSubject
: 'Please review and sign these forms',
'compositeTemplates' => compositeTemplates
};
return JSON.serialize(envelope);
}
/**
* @description Builds inline templates for custom fields
* @param recordId Salesforce record ID
* @param language Language code
* @param customFields Additional custom fields
* @return List of inline template objects
*/
private static List<Object> buildInlineTemplates(
String recordId,
String language,
Map<String, String> customFields
) {
List<Object> textCustomFields = new List<Object>();
// Add Salesforce record ID
if (String.isNotBlank(recordId)) {
textCustomFields.add(new Map<String, Object>{
'name' => 'SalesforceRecordId',
'value' => recordId,
'show' => 'false',
'required' => 'false'
});
}
// Add language
if (String.isNotBlank(language)) {
textCustomFields.add(new Map<String, Object>{
'name' => 'Language',
'value' => language,
'show' => 'false',
'required' => 'false'
});
}
// Add additional custom fields
if (customFields != null && !customFields.isEmpty()) {
for (String fieldName : customFields.keySet()) {
textCustomFields.add(new Map<String, Object>{
'name' => fieldName,
'value' => customFields.get(fieldName),
'show' => 'false',
'required' => 'false'
});
}
}
return new List<Object>{
new Map<String, Object>{
'sequence' => '1',
'customFields' => new Map<String, Object>{
'textCustomFields' => textCustomFields
}
}
};
}
} }

View File

@ -8,173 +8,73 @@ public class DocusignEnvelopeRequestHandlerTest {
@isTest @isTest
static void testValidateRequest_Success() { static void testValidateRequest_Success() {
// Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1', 'template2' }; req.templateIds = new List<String>{ 'template1', 'template2' };
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
req.language = 'en';
req.emailSubject = 'Test Subject';
// Test
Test.startTest(); Test.startTest();
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Test.stopTest(); Test.stopTest();
// Verify - no exception thrown
Assert.isTrue(true, 'Validation should pass'); Assert.isTrue(true, 'Validation should pass');
} }
@isTest @isTest
static void testValidateRequest_NoTemplateIds() { static void testValidateRequest_NoTemplateIds() {
// Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>(); req.templateIds = new List<String>();
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error');
} }
Test.stopTest();
} }
@isTest @isTest
static void testValidateRequest_TooManyTemplates() { static void testValidateRequest_TooManyTemplates() {
// Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>(); req.templateIds = new List<String>();
for (Integer i = 0; i < 15; i++) { for (Integer i = 0; i < 15; i++) {
req.templateIds.add('template' + i); req.templateIds.add('template' + i);
} }
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error');
} }
Test.stopTest();
} }
@isTest @isTest
static void testValidateRequest_NoRecordId() { static void testValidateRequest_NoRecordId() {
// Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1' }; req.templateIds = new List<String>{ 'template1' };
req.recordId = ''; req.recordId = '';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error');
} }
Test.stopTest();
} }
@isTest @isTest
static void testValidateRequest_BlankTemplateId() { static void testValidateRequest_BlankTemplateId() {
// Setup DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1', '', 'template3' }; req.templateIds = new List<String>{ 'template1', '', 'template3' };
req.recordId = '001xx000003DHf'; req.recordId = '001xx000003DHf';
// Test & Verify
Test.startTest();
try { try {
DocusignEnvelopeRequestHandler.validateRequest(req); DocusignEnvelopeRequestHandler.validateRequest(req);
Assert.fail('Should throw IllegalArgumentException'); Assert.fail('Should throw IllegalArgumentException');
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error message'); Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error');
} }
Test.stopTest();
}
@isTest
static void testBuildEnvelopeJSON_Success() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template2', 'template1', 'template2' }; // duplicates and unsorted
req.recordId = '001xx000003DHf';
req.language = 'es';
req.emailSubject = 'Custom Subject';
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify
Assert.isNotNull(json, 'JSON should be generated');
Assert.isTrue(json.contains('sent'), 'Should contain sent status');
Assert.isTrue(json.contains('Custom Subject'), 'Should contain custom subject');
Assert.isTrue(json.contains('compositeTemplates'), 'Should contain compositeTemplates');
}
@isTest
static void testBuildEnvelopeJSON_DefaultSubject() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1' };
req.recordId = '001xx000003DHf';
req.emailSubject = null;
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify
Assert.isTrue(json.contains('Please review and sign these forms'), 'Should use default subject');
}
@isTest
static void testBuildEnvelopeJSON_TemplatesAreSorted() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template3', 'template1', 'template2' };
req.recordId = '001xx000003DHf';
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify - templates should be in sorted order (template1, template2, template3)
Integer idx1 = json.indexOf('template1');
Integer idx2 = json.indexOf('template2');
Integer idx3 = json.indexOf('template3');
Assert.isTrue(idx1 < idx2 && idx2 < idx3, 'Templates should be sorted alphabetically');
}
@isTest
static void testBuildEnvelopeJSON_DuplicatesRemoved() {
// Setup
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
req.templateIds = new List<String>{ 'template1', 'template1', 'template2' };
req.recordId = '001xx000003DHf';
// Test
Test.startTest();
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
Test.stopTest();
// Verify - should only have 2 compositeTemplate entries
Integer count = 0;
Integer pos = 0;
while ((pos = json.indexOf('compositeTemplateId', pos)) != -1) {
count++;
pos++;
}
Assert.areEqual(2, count, 'Should have 2 unique templates after deduplication');
} }
} }

View File

@ -0,0 +1,25 @@
/**
* @description Output parameters for DocusignCompositeEnvelopeBuilder invocable method
* @author Paul Huliganga
* @date 2026-02-25
*/
global class DocusignEnvelopeResult {
@InvocableVariable(
label='Envelope ID'
description='Docusign envelope ID'
)
global String envelopeId;
@InvocableVariable(
label='Success'
description='True if envelope was created successfully'
)
global Boolean success;
@InvocableVariable(
label='Error Message'
description='Error message if envelope creation failed'
)
global String errorMessage;
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata"> <ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion> <apiVersion>60.0</apiVersion>
<status>Active</status> <status>Active</status>
</ApexClass> </ApexClass>

View File

@ -0,0 +1,460 @@
<?xml version="1.0" encoding="UTF-8"?>
<Flow xmlns="http://soap.sforce.com/2006/04/metadata">
<actionCalls>
<name>Send_Composite_Envelope</name>
<label>Send Composite Envelope</label>
<locationX>50</locationX>
<locationY>1000</locationY>
<actionName>DocusignCompositeEnvelopeBuilder</actionName>
<actionType>apex</actionType>
<connector>
<targetReference>Check_Envelope_Result</targetReference>
</connector>
<flowTransactionModel>Automatic</flowTransactionModel>
<inputParameters>
<name>templateIds</name>
<value>
<elementReference>compositeTemplateIds</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>recordId</name>
<value>
<elementReference>recordId</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>language</name>
<value>
<elementReference>Get_Records.Docusign_Envelope_Language__c</elementReference>
</value>
</inputParameters>
<nameSegment>DocusignCompositeEnvelopeBuilder</nameSegment>
<offset>0</offset>
<outputParameters>
<assignToReference>envelopeId</assignToReference>
<name>envelopeId</name>
</outputParameters>
<outputParameters>
<assignToReference>envelopeSuccess</assignToReference>
<name>success</name>
</outputParameters>
<outputParameters>
<assignToReference>envelopeErrorMessage</assignToReference>
<name>errorMessage</name>
</outputParameters>
</actionCalls>
<apiVersion>60.0</apiVersion>
<areMetricsLoggedToDataCloud>false</areMetricsLoggedToDataCloud>
<assignments>
<name>Add_Template_ID</name>
<label>Add Template ID</label>
<locationX>50</locationX>
<locationY>890</locationY>
<assignmentItems>
<assignToReference>compositeTemplateIds</assignToReference>
<operator>Add</operator>
<value>
<elementReference>Build_Template_ID_Collection.dfsle__DocuSignId__c</elementReference>
</value>
</assignmentItems>
<connector>
<targetReference>Build_Template_ID_Collection</targetReference>
</connector>
</assignments>
<decisions>
<name>Check_Envelope_Result</name>
<label>Check Envelope Result</label>
<locationX>50</locationX>
<locationY>1108</locationY>
<defaultConnector>
<targetReference>Error_Screen</targetReference>
</defaultConnector>
<defaultConnectorLabel>Default Outcome</defaultConnectorLabel>
<rules>
<name>Envelope_Sent_Successfully</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>envelopeSuccess</leftValueReference>
<operator>EqualTo</operator>
<rightValue>
<booleanValue>true</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Success_Screen</targetReference>
</connector>
<label>Envelope Sent Successfully</label>
</rules>
</decisions>
<decisions>
<name>Check_Row_Selection</name>
<label>Check Row Selection</label>
<locationX>182</locationX>
<locationY>674</locationY>
<defaultConnector>
<targetReference>Row_not_selected</targetReference>
</defaultConnector>
<defaultConnectorLabel>Default Outcome</defaultConnectorLabel>
<rules>
<name>Is_Row_Selected</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>data.firstSelectedRow.Id</leftValueReference>
<operator>IsNull</operator>
<rightValue>
<booleanValue>false</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Build_Template_ID_Collection</targetReference>
</connector>
<label>Is Row Selected?</label>
</rules>
</decisions>
<decisions>
<name>Is_Language_Selected</name>
<label>Is Language Selected?</label>
<locationX>380</locationX>
<locationY>242</locationY>
<defaultConnector>
<targetReference>Language_Not_Added_Screen</targetReference>
</defaultConnector>
<defaultConnectorLabel>Default Outcome</defaultConnectorLabel>
<rules>
<name>Language_Selected</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>Get_Records.Docusign_Envelope_Language__c</leftValueReference>
<operator>IsNull</operator>
<rightValue>
<booleanValue>false</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Language_Warning_Screen</targetReference>
</connector>
<label>Language Selected?</label>
</rules>
</decisions>
<environments>Default</environments>
<interviewLabel>Docusign Envelope Templates V3 {!$Flow.CurrentDateTime}</interviewLabel>
<label>Docusign Envelope Templates V3</label>
<loops>
<name>Build_Template_ID_Collection</name>
<label>Build Template ID Collection</label>
<locationX>50</locationX>
<locationY>782</locationY>
<collectionReference>data.selectedRows</collectionReference>
<iterationOrder>Asc</iterationOrder>
<nextValueConnector>
<targetReference>Add_Template_ID</targetReference>
</nextValueConnector>
<noMoreValuesConnector>
<targetReference>Send_Composite_Envelope</targetReference>
</noMoreValuesConnector>
</loops>
<processMetadataValues>
<name>BuilderType</name>
<value>
<stringValue>LightningFlowBuilder</stringValue>
</value>
</processMetadataValues>
<processMetadataValues>
<name>CanvasMode</name>
<value>
<stringValue>AUTO_LAYOUT_CANVAS</stringValue>
</value>
</processMetadataValues>
<processMetadataValues>
<name>OriginBuilderType</name>
<value>
<stringValue>LightningFlowBuilder</stringValue>
</value>
</processMetadataValues>
<processType>Flow</processType>
<recordLookups>
<name>DocuSign_Envelope_Templates</name>
<label>DocuSign Envelope Templates</label>
<locationX>182</locationX>
<locationY>458</locationY>
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
<connector>
<targetReference>Envelope_template_records</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Envelope_Template_Language__c</field>
<operator>EqualTo</operator>
<value>
<elementReference>Get_Records.Docusign_Envelope_Language__c</elementReference>
</value>
</filters>
<getFirstRecordOnly>false</getFirstRecordOnly>
<object>dfsle__EnvelopeConfiguration__c</object>
<queriedFields>Id</queriedFields>
<queriedFields>Name</queriedFields>
<queriedFields>dfsle__DocuSignId__c</queriedFields>
<sortField>Name</sortField>
<sortOrder>Asc</sortOrder>
<storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups>
<recordLookups>
<name>Get_Records</name>
<label>Get Records</label>
<locationX>380</locationX>
<locationY>134</locationY>
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
<connector>
<targetReference>Is_Language_Selected</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Id</field>
<operator>EqualTo</operator>
<value>
<elementReference>recordId</elementReference>
</value>
</filters>
<getFirstRecordOnly>true</getFirstRecordOnly>
<object>Client_Case__c</object>
<queriedFields>Id</queriedFields>
<queriedFields>Docusign_Envelope_Language__c</queriedFields>
<storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups>
<screens>
<name>Envelope_template_records</name>
<label>Envelope template records</label>
<locationX>182</locationX>
<locationY>566</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<connector>
<targetReference>Check_Row_Selection</targetReference>
</connector>
<fields>
<name>data</name>
<dataTypeMappings>
<typeName>T</typeName>
<typeValue>dfsle__EnvelopeConfiguration__c</typeValue>
</dataTypeMappings>
<extensionName>flowruntime:datatable</extensionName>
<fieldType>ComponentInstance</fieldType>
<inputParameters>
<name>label</name>
<value>
<stringValue>Select Templates for Composite Envelope</stringValue>
</value>
</inputParameters>
<inputParameters>
<name>selectionMode</name>
<value>
<stringValue>MULTI_SELECT</stringValue>
</value>
</inputParameters>
<inputParameters>
<name>minRowSelection</name>
<value>
<numberValue>0.0</numberValue>
</value>
</inputParameters>
<inputParameters>
<name>tableData</name>
<value>
<elementReference>DocuSign_Envelope_Templates</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>columns</name>
<value>
<stringValue>[{&quot;apiName&quot;:&quot;Name&quot;,&quot;guid&quot;:&quot;column-6d57&quot;,&quot;editable&quot;:false,&quot;hasCustomHeaderLabel&quot;:true,&quot;customHeaderLabel&quot;:&quot;Envelope Template Name&quot;,&quot;wrapText&quot;:true,&quot;order&quot;:0,&quot;label&quot;:&quot;Name&quot;,&quot;type&quot;:&quot;text&quot;}]</stringValue>
</value>
</inputParameters>
<inputsOnNextNavToAssocScrn>UseStoredValues</inputsOnNextNavToAssocScrn>
<isRequired>true</isRequired>
<storeOutputAutomatically>true</storeOutputAutomatically>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Send</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Error_Screen</name>
<label>Error Screen</label>
<locationX>314</locationX>
<locationY>1216</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<fields>
<name>ErrorDisplayMessage</name>
<fieldText>&lt;p&gt;&lt;span style=&quot;font-size: 16px; color: rgb(255, 0, 0);&quot;&gt;❌ Failed to send composite envelope.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Error:&lt;/strong&gt; {!envelopeErrorMessage}&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Please try again or contact your administrator.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>false</showHeader>
</screens>
<screens>
<name>Language_Not_Added_Screen</name>
<label>Language Not Added Screen</label>
<locationX>578</locationX>
<locationY>350</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<fields>
<name>LanguageNotSelected</name>
<fieldText>&lt;p&gt;The &lt;strong&gt;DocuSign Envelope Language&lt;/strong&gt; is not populated on the record. Please add the language first and then proceed.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Language_Warning_Screen</name>
<label>Language Warning Screen</label>
<locationX>182</locationX>
<locationY>350</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<connector>
<targetReference>DocuSign_Envelope_Templates</targetReference>
</connector>
<fields>
<name>LangWarningText</name>
<fieldText>&lt;p&gt;The current selected language is &lt;strong&gt;{!Get_Records.Docusign_Envelope_Language__c}. &lt;/strong&gt;On the next screen you will be able to see form names of {!Get_Records.Docusign_Envelope_Language__c} language only. If you want to switch the language, please go back to record and select another language form &lt;strong&gt;DocuSign Envelope Language&lt;/strong&gt;.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Next</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Row_not_selected</name>
<label>Row not selected</label>
<locationX>314</locationX>
<locationY>782</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<fields>
<name>ErrorMessage</name>
<fieldText>&lt;p&gt;&lt;strong style=&quot;background-color: rgb(255, 255, 255); color: rgb(68, 68, 68);&quot;&gt;&lt;em&gt;You have not selected any of the forms. Please go back and select the form first and then proceed.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>false</showHeader>
</screens>
<screens>
<name>Success_Screen</name>
<label>Success Screen</label>
<locationX>50</locationX>
<locationY>1216</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<fields>
<name>SuccessMessage</name>
<fieldText>&lt;p&gt;&lt;span style=&quot;font-size: 16px; color: rgb(0, 128, 0);&quot;&gt;✅ Composite envelope sent successfully!&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Envelope ID:&lt;/strong&gt; {!envelopeId}&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Templates combined:&lt;/strong&gt; All selected templates were merged into a single envelope.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>false</showHeader>
</screens>
<start>
<locationX>254</locationX>
<locationY>0</locationY>
<connector>
<targetReference>Get_Records</targetReference>
</connector>
</start>
<status>Draft</status>
<variables>
<name>compositeTemplateIds</name>
<dataType>String</dataType>
<isCollection>true</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>envelopeErrorMessage</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>envelopeId</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>envelopeSuccess</name>
<dataType>Boolean</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>recordId</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>true</isInput>
<isOutput>false</isOutput>
</variables>
</Flow>