From 4f734f0d1721eca7e99e4718a8ccbaca916adb66 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Wed, 25 Feb 2026 09:22:29 -0500 Subject: [PATCH] Initial commit: Salesforce Composite Envelope Builder --- .gitignore | 21 + composite-envelope-builder/.forceignore | 12 + composite-envelope-builder/.gitignore | 48 ++ composite-envelope-builder/.husky/pre-commit | 1 + composite-envelope-builder/.prettierignore | 11 + composite-envelope-builder/.prettierrc | 17 + composite-envelope-builder/QUICK_START.md | 248 +++++++ composite-envelope-builder/README.md | 105 +++ .../config/project-scratch-def.json | 13 + .../deploy-to-dev-org.sh | 104 +++ composite-envelope-builder/deploy.sh | 91 +++ .../docs/api-reference.md | 529 ++++++++++++++ .../docs/deployment-guide.md | 531 ++++++++++++++ composite-envelope-builder/docs/design.md | 647 ++++++++++++++++++ .../docs/requirements.md | 304 ++++++++ composite-envelope-builder/eslint.config.js | 55 ++ .../DocuSign_Envelope_Templates.flow-meta.xml | 345 ++++++++++ ...cusign_Envelope_Templates_V2.flow-meta.xml | 347 ++++++++++ .../default/classes/DocusignAPIService.cls | 271 ++++++++ .../classes/DocusignAPIService.cls-meta.xml | 5 + .../classes/DocusignAPIServiceTest.cls | 323 +++++++++ .../DocusignAPIServiceTest.cls-meta.xml | 5 + .../DocusignCompositeEnvelopeBuilder.cls | 327 +++++++++ ...usignCompositeEnvelopeBuilder.cls-meta.xml | 5 + .../DocusignCompositeEnvelopeBuilderTest.cls | 312 +++++++++ ...nCompositeEnvelopeBuilderTest.cls-meta.xml | 5 + .../default/classes/DocusignCredentials.cls | 187 +++++ .../classes/DocusignCredentials.cls-meta.xml | 5 + .../classes/DocusignCredentialsTest.cls | 258 +++++++ .../DocusignCredentialsTest.cls-meta.xml | 5 + .../Docusign_Configuration__c.object-meta.xml | 8 + .../fields/Account_Id__c.field-meta.xml | 12 + .../fields/Base_URL__c.field-meta.xml | 12 + composite-envelope-builder/jest.config.js | 6 + composite-envelope-builder/package.json | 44 ++ .../scripts/apex/hello.apex | 10 + .../scripts/soql/account.soql | 6 + composite-envelope-builder/sfdx-project.json | 12 + 38 files changed, 5247 insertions(+) create mode 100644 .gitignore create mode 100644 composite-envelope-builder/.forceignore create mode 100644 composite-envelope-builder/.gitignore create mode 100644 composite-envelope-builder/.husky/pre-commit create mode 100644 composite-envelope-builder/.prettierignore create mode 100644 composite-envelope-builder/.prettierrc create mode 100644 composite-envelope-builder/QUICK_START.md create mode 100644 composite-envelope-builder/README.md create mode 100644 composite-envelope-builder/config/project-scratch-def.json create mode 100755 composite-envelope-builder/deploy-to-dev-org.sh create mode 100755 composite-envelope-builder/deploy.sh create mode 100644 composite-envelope-builder/docs/api-reference.md create mode 100644 composite-envelope-builder/docs/deployment-guide.md create mode 100644 composite-envelope-builder/docs/design.md create mode 100644 composite-envelope-builder/docs/requirements.md create mode 100644 composite-envelope-builder/eslint.config.js create mode 100644 composite-envelope-builder/flows/DocuSign_Envelope_Templates.flow-meta.xml create mode 100644 composite-envelope-builder/flows/Docusign_Envelope_Templates_V2.flow-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls create mode 100644 composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/Docusign_Configuration__c.object-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Account_Id__c.field-meta.xml create mode 100644 composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Base_URL__c.field-meta.xml create mode 100644 composite-envelope-builder/jest.config.js create mode 100644 composite-envelope-builder/package.json create mode 100644 composite-envelope-builder/scripts/apex/hello.apex create mode 100644 composite-envelope-builder/scripts/soql/account.soql create mode 100644 composite-envelope-builder/sfdx-project.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d1f634 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Salesforce +.sfdx/ +.sf/ +node_modules/ +dist/ +*.zip + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local diff --git a/composite-envelope-builder/.forceignore b/composite-envelope-builder/.forceignore new file mode 100644 index 0000000..7b5b5a7 --- /dev/null +++ b/composite-envelope-builder/.forceignore @@ -0,0 +1,12 @@ +# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status +# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm +# + +package.xml + +# LWC configuration files +**/jsconfig.json +**/.eslintrc.json + +# LWC Jest +**/__tests__/** \ No newline at end of file diff --git a/composite-envelope-builder/.gitignore b/composite-envelope-builder/.gitignore new file mode 100644 index 0000000..0931e26 --- /dev/null +++ b/composite-envelope-builder/.gitignore @@ -0,0 +1,48 @@ +# This file is used for Git repositories to specify intentionally untracked files that Git should ignore. +# If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore +# For useful gitignore templates see: https://github.com/github/gitignore + +# Salesforce cache +.sf/ +.sfdx/ +.localdevserver/ +deploy-options.json + +# LWC VSCode autocomplete +**/lwc/jsconfig.json + +# LWC Jest coverage reports +coverage/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Dependency directories +node_modules/ + +# ApexPMD cache +.pmdCache + +# Eslint cache +.eslintcache + +# MacOS system files +.DS_Store + +# Windows system files +Thumbs.db +ehthumbs.db +[Dd]esktop.ini +$RECYCLE.BIN/ + +# Local environment variables +.env + +# Python Salesforce Functions +**/__pycache__/ +**/.venv/ +**/venv/ diff --git a/composite-envelope-builder/.husky/pre-commit b/composite-envelope-builder/.husky/pre-commit new file mode 100644 index 0000000..2601a3b --- /dev/null +++ b/composite-envelope-builder/.husky/pre-commit @@ -0,0 +1 @@ +npm run precommit \ No newline at end of file diff --git a/composite-envelope-builder/.prettierignore b/composite-envelope-builder/.prettierignore new file mode 100644 index 0000000..8cccc6e --- /dev/null +++ b/composite-envelope-builder/.prettierignore @@ -0,0 +1,11 @@ +# List files or directories below to ignore them when running prettier +# More information: https://prettier.io/docs/en/ignore.html +# + +**/staticresources/** +.localdevserver +.sfdx +.sf +.vscode + +coverage/ \ No newline at end of file diff --git a/composite-envelope-builder/.prettierrc b/composite-envelope-builder/.prettierrc new file mode 100644 index 0000000..18039a0 --- /dev/null +++ b/composite-envelope-builder/.prettierrc @@ -0,0 +1,17 @@ +{ + "trailingComma": "none", + "plugins": [ + "prettier-plugin-apex", + "@prettier/plugin-xml" + ], + "overrides": [ + { + "files": "**/lwc/**/*.html", + "options": { "parser": "lwc" } + }, + { + "files": "*.{cmp,page,component}", + "options": { "parser": "html" } + } + ] +} diff --git a/composite-envelope-builder/QUICK_START.md b/composite-envelope-builder/QUICK_START.md new file mode 100644 index 0000000..be56f66 --- /dev/null +++ b/composite-envelope-builder/QUICK_START.md @@ -0,0 +1,248 @@ +# Quick Start Guide + +## βœ… Phase 2 Complete: Code Development + +All Apex classes and tests have been created! Here's what you have: + +### πŸ“¦ Code Files Created (6 classes) + +**Main Classes:** +1. `DocusignCompositeEnvelopeBuilder.cls` (11.5 KB) - Invocable method for Flow integration +2. `DocusignAPIService.cls` (10.5 KB) - REST API service layer +3. `DocusignCredentials.cls` (5.9 KB) - Credential management + +**Test Classes:** +4. `DocusignCompositeEnvelopeBuilderTest.cls` (13.8 KB) - 12 test methods +5. `DocusignAPIServiceTest.cls` (11.7 KB) - 14 test methods +6. `DocusignCredentialsTest.cls` (8.0 KB) - 13 test methods + +**Total:** 39 test methods for comprehensive coverage! + +**Metadata:** +- Custom Setting: `Docusign_Configuration__c` with `Account_Id__c` and `Base_URL__c` fields +- All `.cls-meta.xml` files (API version 61.0) + +--- + +## πŸš€ Next Steps (Deployment) + +### Step 1: Open Project in VS Code + +```bash +cd ~/.openclaw/workspace/projects/salesforce-composite-envelope-builder/composite-envelope-builder +code . +``` + +### Step 2: Install Salesforce Extension Pack (if not already installed) + +In VS Code: +1. Open Extensions (Ctrl+Shift+X) +2. Search for "Salesforce Extension Pack" +3. Click Install + +### Step 3: Authorize Your Salesforce Org + +**For Sandbox:** +```bash +sf org login web --alias my-sandbox --instance-url https://test.salesforce.com +``` + +**For Production:** +```bash +sf org login web --alias my-production --instance-url https://login.salesforce.com +``` + +This will open a browser window. Log in with your Salesforce credentials. + +### Step 4: Set Default Org + +```bash +sf config set target-org my-sandbox +``` + +### Step 5: Deploy to Sandbox + +```bash +sf project deploy start --target-org my-sandbox +``` + +Expected output: +``` +Deploying v61.0 metadata to my-sandbox +Status: Succeeded + +Component Deployed: + ApexClass DocusignCompositeEnvelopeBuilder + ApexClass DocusignAPIService + ApexClass DocusignCredentials + ApexClass DocusignCompositeEnvelopeBuilderTest + ApexClass DocusignAPIServiceTest + ApexClass DocusignCredentialsTest + CustomObject Docusign_Configuration__c +``` + +### Step 6: Run Unit Tests + +```bash +sf apex run test --wait 10 --result-format human --code-coverage --target-org my-sandbox +``` + +Expected output: +``` +=== Test Summary === +Outcome: Passed +Tests Ran: 39 +Passing: 39 +Failing: 0 +Skipped: 0 +Pass Rate: 100% +Code Coverage: 85%+ +``` + +### Step 7: Configure Docusign Credentials + +1. **Create Named Credential** (see docs/deployment-guide.md section 3.1) + - Setup β†’ Named Credentials β†’ New Named Credential + - Name: `DocusignAPI` + - URL: `https://na3.docusign.net/restapi/v2.1` (or your data center) + +2. **Configure Custom Settings** + - Setup β†’ Custom Settings β†’ Docusign Configuration β†’ Manage + - Click "New" (organization-wide default) + - Account Id: `{Your Docusign Account GUID}` + - Base URL: `callout:DocusignAPI` + - Save + +3. **Add Remote Site Settings** + - Setup β†’ Remote Site Settings β†’ New Remote Site + - Name: `Docusign_API` + - URL: `https://na3.docusign.net` + - Active: βœ“ + +### Step 8: Update Screen Flow + +See `docs/deployment-guide.md` section 5 for detailed Flow configuration steps. + +**Summary:** +1. Open your existing form selection Flow +2. Add Apex Action element (after template selection) +3. Select action: `Send Composite Docusign Envelope` +4. Map inputs: + - `templateIds` β†’ `{!SelectedTemplateIds}` (from checkboxes) + - `recordId` β†’ `{!recordId}` + - `language` β†’ `{!SelectedLanguage}` + - `emailSubject` β†’ "Please review and sign these forms" +5. Store outputs: + - `envelopeId` β†’ `{!EnvelopeId}` + - `success` β†’ `{!Success}` + - `errorMessage` β†’ `{!ErrorMessage}` +6. Add Decision element for success/error handling +7. Save & Activate + +### Step 9: Test Integration + +1. Navigate to Salesforce record +2. Launch Screen Flow +3. Select language: English +4. Check 2-3 templates +5. Click Send +6. Verify: + - Success message shown + - Envelope ID displayed + - Check Docusign: envelope created with multiple documents + - Recipients receive ONE email + +--- + +## πŸ“š Documentation + +All docs are in the `docs/` folder: + +- **requirements.md** - What we're building and why +- **design.md** - Architecture, sequence diagrams, data flow +- **api-reference.md** - Docusign REST API details +- **deployment-guide.md** - Complete deployment instructions + +--- + +## πŸ§ͺ Test Coverage Details + +**DocusignCompositeEnvelopeBuilderTest** (12 methods): +- βœ… Success with 3 templates +- βœ… Single template (minimum) +- βœ… 14 templates (maximum) +- βœ… Duplicate template handling +- βœ… Custom fields support +- βœ… Validation: no templates +- βœ… Validation: too many templates (>14) +- βœ… Validation: missing record ID +- βœ… API error 400 (bad request) +- βœ… API error 401 (unauthorized) +- βœ… JSON builder test + +**DocusignAPIServiceTest** (14 methods): +- βœ… Successful envelope creation +- βœ… Parse envelope ID +- βœ… Parse envelope ID missing +- βœ… Handle errors: 400, 401, 403, 404, 429, 500 +- βœ… Retry logic (transient failures) +- βœ… Build HTTP request +- βœ… Execute with retry (non-retryable error) + +**DocusignCredentialsTest** (13 methods): +- βœ… Singleton pattern +- βœ… Load from Custom Settings +- βœ… Get access token, account ID, base URL +- βœ… Validate success +- βœ… Validate failures: missing account ID, base URL, token +- βœ… Set test credentials +- βœ… Reset instance +- βœ… Missing Custom Settings error +- βœ… Named Credential format + +**Total: 39 test methods across 6 classes** + +--- + +## 🎯 Project Status + +| Phase | Status | +|-------|--------| +| Requirements & Design | βœ… Complete | +| Apex Code Development | βœ… Complete | +| Unit Tests | βœ… Complete (39 tests) | +| Documentation | βœ… Complete (56+ KB) | +| Deployment | ⏳ Ready to deploy | +| Flow Integration | ⏳ Awaiting deployment | +| Production Testing | ⏳ Next | + +--- + +## πŸ’‘ Tips + +**Before Deploying:** +- Review `docs/deployment-guide.md` sections 2-3 for Docusign setup +- Have your Docusign Integration Key ready +- Know your Docusign Account ID + +**After Deploying:** +- Run all tests to verify 100% pass rate +- Check debug logs for any issues +- Test with 1 template first, then multiple + +**Flow Modification:** +- Keep your existing template selection UI +- Replace the loop that sends individual envelopes +- Add the Apex Action in place of the loop + +--- + +## ❓ Need Help? + +Check the troubleshooting section in `docs/deployment-guide.md` (section 10) for common issues and solutions. + +--- + +**Project Created:** February 23, 2026 +**Developer:** Paul Huliganga +**Ready to Deploy:** YES! πŸš€ diff --git a/composite-envelope-builder/README.md b/composite-envelope-builder/README.md new file mode 100644 index 0000000..3c47164 --- /dev/null +++ b/composite-envelope-builder/README.md @@ -0,0 +1,105 @@ +# Salesforce Composite Envelope Builder + +## Project Overview + +A Salesforce solution for sending multiple Docusign templates in a single envelope using composite template functionality via the Docusign REST API. + +## Problem Statement + +Current implementation requires 42+ pre-built Docusign templates for various combinations of 14 forms (English + Spanish). This approach is: +- Difficult to maintain +- Cannot handle all possible combinations +- Requires creating new templates for each scenario + +## Solution + +Build custom Apex code to dynamically combine multiple single-form templates into one envelope at runtime, allowing users to select any combination of forms. + +## Key Features + +- **Dynamic Form Selection**: Users select forms via Salesforce Screen Flow checkboxes +- **Single Template Per Form**: 28 templates total (14 forms Γ— 2 languages) +- **Composite Envelope API**: Combines templates into one envelope via Docusign REST API +- **Alphabetical Ordering**: Forms presented and combined in alphabetical order by title +- **Automatic Recipient Merge**: Recipients/roles automatically merged across templates +- **Salesforce Integration**: Documents written back to initiating Salesforce record + +## Technical Architecture + +### Components + +1. **Salesforce Screen Flow** (existing, minor modifications) + - Language selection + - Template display and selection (checkboxes) + - Calls Apex action with selected template IDs + +2. **Custom Apex Class** (new) + - `DocusignCompositeEnvelopeBuilder` - Invocable method for Flow integration + - Constructs composite envelope JSON + - Makes Docusign REST API callout + - Returns envelope ID to Flow + +3. **Unit Tests** (new) + - Test class with >75% code coverage + - Mock callouts for Docusign API + - Edge case handling + +### Technologies + +- **Salesforce Platform**: Apex, Screen Flows, Lightning Web Components (potential future UI) +- **Docusign REST API v2.1**: Composite Templates endpoint +- **VS Code + Salesforce Extensions**: Development environment + +## Project Structure + +``` +salesforce-composite-envelope-builder/ +β”œβ”€β”€ docs/ +β”‚ β”œβ”€β”€ requirements.md # Detailed requirements +β”‚ β”œβ”€β”€ design.md # Architecture and design decisions +β”‚ β”œβ”€β”€ api-reference.md # Docusign API integration details +β”‚ └── deployment-guide.md # Deployment and configuration +β”œβ”€β”€ force-app/main/default/ +β”‚ β”œβ”€β”€ classes/ +β”‚ β”‚ β”œβ”€β”€ DocusignCompositeEnvelopeBuilder.cls +β”‚ β”‚ β”œβ”€β”€ DocusignCompositeEnvelopeBuilder.cls-meta.xml +β”‚ β”‚ β”œβ”€β”€ DocusignCompositeEnvelopeBuilderTest.cls +β”‚ β”‚ β”œβ”€β”€ DocusignCompositeEnvelopeBuilderTest.cls-meta.xml +β”‚ β”‚ β”œβ”€β”€ DocusignCredentials.cls +β”‚ β”‚ └── DocusignCredentials.cls-meta.xml +β”‚ └── flows/ +β”‚ └── Form_Selection_Flow.flow-meta.xml (example/reference) +β”œβ”€β”€ scripts/ +β”‚ └── apex/ +β”‚ └── test-composite-envelope.apex # Manual testing script +└── README.md # This file +``` + +## Getting Started + +See [docs/deployment-guide.md](docs/deployment-guide.md) for setup instructions. + +## Documentation + +- [Requirements](docs/requirements.md) - Functional and technical requirements +- [Design](docs/design.md) - Architecture, sequence diagrams, data flow +- [API Reference](docs/api-reference.md) - Docusign REST API integration details +- [Deployment Guide](docs/deployment-guide.md) - Installation and configuration + +## Timeline + +- **Phase 1**: Requirements & Design Documentation βœ… +- **Phase 2**: Apex Class Development +- **Phase 3**: Unit Tests & Code Coverage +- **Phase 4**: Flow Integration +- **Phase 5**: Testing & Deployment + +## License + +Internal project for customer implementation. + +--- + +**Last Updated**: February 23, 2026 +**Project Lead**: Paul Huliganga +**Status**: In Development diff --git a/composite-envelope-builder/config/project-scratch-def.json b/composite-envelope-builder/config/project-scratch-def.json new file mode 100644 index 0000000..c37f951 --- /dev/null +++ b/composite-envelope-builder/config/project-scratch-def.json @@ -0,0 +1,13 @@ +{ + "orgName": "paulh company", + "edition": "Developer", + "features": ["EnableSetPasswordInApi"], + "settings": { + "lightningExperienceSettings": { + "enableS1DesktopEnabled": true + }, + "mobileSettings": { + "enableS1EncryptedStoragePref2": false + } + } +} diff --git a/composite-envelope-builder/deploy-to-dev-org.sh b/composite-envelope-builder/deploy-to-dev-org.sh new file mode 100755 index 0000000..1284947 --- /dev/null +++ b/composite-envelope-builder/deploy-to-dev-org.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Deploy to Salesforce Developer Edition (WSL-friendly) + +echo "πŸš€ Deploying to Developer Edition Org (WSL Mode)" +echo "================================================" +echo "" + +# Detect Windows Chrome path +CHROME_PATHS=( + "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" + "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" + "/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe" + "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" +) + +BROWSER="" +for path in "${CHROME_PATHS[@]}"; do + if [ -f "$path" ]; then + BROWSER="$path" + echo "βœ… Found browser: $BROWSER" + break + fi +done + +if [ -z "$BROWSER" ]; then + echo "⚠️ Could not auto-detect browser." + echo " Please specify Chrome or Edge path:" + echo " Example: /mnt/c/Program Files/Google/Chrome/Application/chrome.exe" + read -p "Browser path: " BROWSER + + if [ ! -f "$BROWSER" ]; then + echo "❌ Browser not found at: $BROWSER" + echo "" + echo "Alternative: Use manual authentication URL method" + echo "Run this command:" + echo " sf org login web --alias dev-org --instance-url https://login.salesforce.com --json" + echo "" + echo "Then copy the URL from the output and open it in your Windows browser." + exit 1 + fi +fi + +echo "" +echo "πŸ” Authorizing your Developer Edition org..." +echo " Opening browser: $(basename "$BROWSER")" +echo "" + +sf org login web --alias dev-org --instance-url https://login.salesforce.com --browser "$BROWSER" + +if [ $? -ne 0 ]; then + echo "" + echo "❌ Authorization failed." + echo "" + echo "πŸ“ Manual alternative:" + echo " 1. Run: sf org login web --alias dev-org --instance-url https://login.salesforce.com --json" + echo " 2. Copy the 'url' from the JSON output" + echo " 3. Paste it into your Windows browser" + echo " 4. Log in and authorize" + echo " 5. Re-run this script" + exit 1 +fi + +echo "" +echo "βœ… Authorization successful" +echo "" + +# Set as default +sf config set target-org dev-org + +# Deploy +echo "πŸ“¦ Deploying code to Developer Edition..." +sf project deploy start --target-org dev-org + +if [ $? -ne 0 ]; then + echo "❌ Deployment failed. Check errors above." + exit 1 +fi + +echo "" +echo "βœ… Deployment successful!" +echo "" + +# Run tests +echo "πŸ§ͺ Running all 39 unit tests..." +sf apex run test --wait 10 --result-format human --code-coverage --target-org dev-org + +echo "" +echo "======================================" +echo "βœ… Deployment to Developer Edition Complete!" +echo "" +echo "πŸ“ Next Steps:" +echo "1. Log into your Developer Edition org" +echo "2. Setup β†’ Custom Settings β†’ Docusign Configuration β†’ Manage β†’ New" +echo " - Account Id: {your Docusign sandbox account ID}" +echo " - Base URL: callout:DocusignAPI" +echo "" +echo "3. Setup β†’ Named Credentials β†’ New Named Credential" +echo " - Name: DocusignAPI" +echo " - URL: https://demo.docusign.net/restapi/v2.1" +echo " - Configure OAuth for Docusign Sandbox" +echo "" +echo "4. Create/update your Screen Flow to call the Apex Action" +echo "" +echo "See docs/deployment-guide.md for detailed configuration." diff --git a/composite-envelope-builder/deploy.sh b/composite-envelope-builder/deploy.sh new file mode 100755 index 0000000..896d182 --- /dev/null +++ b/composite-envelope-builder/deploy.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Deployment script for Salesforce Composite Envelope Builder + +echo "πŸš€ Salesforce Composite Envelope Builder Deployment" +echo "==================================================" +echo "" + +# Check if sf CLI is installed +if ! command -v sf &> /dev/null; then + echo "❌ Salesforce CLI not found. Please install it first:" + echo " npm install -g @salesforce/cli" + exit 1 +fi + +echo "βœ… Salesforce CLI found: $(sf --version)" +echo "" + +# Ask for org type +echo "Which org do you want to deploy to?" +echo "1) Sandbox" +echo "2) Production" +read -p "Enter choice (1 or 2): " ORG_CHOICE + +if [ "$ORG_CHOICE" == "1" ]; then + ORG_TYPE="sandbox" + INSTANCE_URL="https://test.salesforce.com" + ORG_ALIAS="composite-envelope-sandbox" +elif [ "$ORG_CHOICE" == "2" ]; then + ORG_TYPE="production" + INSTANCE_URL="https://login.salesforce.com" + ORG_ALIAS="composite-envelope-production" +else + echo "❌ Invalid choice. Exiting." + exit 1 +fi + +echo "" +echo "πŸ“ Deploying to: $ORG_TYPE" +echo "πŸ”— Instance URL: $INSTANCE_URL" +echo "🏷️ Org Alias: $ORG_ALIAS" +echo "" + +# Authorize org +echo "πŸ” Authorizing org..." +sf org login web --alias "$ORG_ALIAS" --instance-url "$INSTANCE_URL" + +if [ $? -ne 0 ]; then + echo "❌ Authorization failed. Exiting." + exit 1 +fi + +echo "βœ… Authorization successful" +echo "" + +# Set default org +echo "βš™οΈ Setting default org..." +sf config set target-org "$ORG_ALIAS" + +echo "" +echo "πŸ“¦ Deploying metadata..." +sf project deploy start --target-org "$ORG_ALIAS" + +if [ $? -ne 0 ]; then + echo "❌ Deployment failed. Check errors above." + exit 1 +fi + +echo "" +echo "βœ… Deployment successful!" +echo "" + +# Run tests +echo "πŸ§ͺ Running unit tests..." +sf apex run test --wait 10 --result-format human --code-coverage --target-org "$ORG_ALIAS" + +if [ $? -ne 0 ]; then + echo "⚠️ Some tests failed. Please review." +else + echo "βœ… All tests passed!" +fi + +echo "" +echo "==================================================" +echo "βœ… Deployment Complete!" +echo "" +echo "Next steps:" +echo "1. Configure Docusign credentials in Salesforce" +echo "2. Update your Screen Flow to call the Apex Action" +echo "3. Test with real Docusign templates" +echo "" +echo "See docs/deployment-guide.md for detailed instructions." diff --git a/composite-envelope-builder/docs/api-reference.md b/composite-envelope-builder/docs/api-reference.md new file mode 100644 index 0000000..d55830d --- /dev/null +++ b/composite-envelope-builder/docs/api-reference.md @@ -0,0 +1,529 @@ +# API Reference + +**Project**: Salesforce Composite Envelope Builder +**Version**: 1.0 +**Date**: February 23, 2026 + +--- + +## 1. Docusign REST API Integration + +### 1.1 Base Endpoint + +**Production**: +``` +https://na3.docusign.net/restapi/v2.1 +``` + +**Sandbox**: +``` +https://demo.docusign.net/restapi/v2.1 +``` + +**Note**: Replace `na3` with your account's data center (na2, na3, eu1, etc.) + +--- + +## 2. Create Composite Envelope + +### 2.1 HTTP Request + +**Method**: POST +**Endpoint**: `/accounts/{accountId}/envelopes` +**Content-Type**: `application/json` +**Authorization**: `Bearer {access_token}` + +### 2.2 Request Headers + +```http +POST /restapi/v2.1/accounts/12345678-abcd-1234-abcd-1234567890ab/envelopes HTTP/1.1 +Host: na3.docusign.net +Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjY4MTg... +Content-Type: application/json +Accept: application/json +``` + +### 2.3 Request Body (Composite Templates) + +#### Minimum Example (2 templates) + +```json +{ + "status": "sent", + "emailSubject": "Please review and sign these forms", + "compositeTemplates": [ + { + "compositeTemplateId": "1", + "serverTemplates": [ + { + "sequence": "1", + "templateId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + ] + }, + { + "compositeTemplateId": "2", + "serverTemplates": [ + { + "sequence": "2", + "templateId": "b2c3d4e5-f6g7-8901-bcde-fg2345678901" + } + ] + } + ] +} +``` + +#### Advanced Example (with merge fields) + +```json +{ + "status": "sent", + "emailSubject": "Please review and sign these forms", + "compositeTemplates": [ + { + "compositeTemplateId": "1", + "serverTemplates": [ + { + "sequence": "1", + "templateId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + ], + "inlineTemplates": [ + { + "sequence": "1", + "customFields": { + "textCustomFields": [ + { + "name": "SalesforceRecordId", + "value": "0018V00000ABC123" + }, + { + "name": "CaseNumber", + "value": "00001234" + } + ] + } + } + ] + } + ] +} +``` + +### 2.4 Response + +#### Success (201 Created) + +```json +{ + "envelopeId": "f9876543-21ab-cdef-0123-456789abcdef", + "uri": "/envelopes/f9876543-21ab-cdef-0123-456789abcdef", + "statusDateTime": "2026-02-23T12:34:56.789Z", + "status": "sent" +} +``` + +#### Error (400 Bad Request) + +```json +{ + "errorCode": "INVALID_REQUEST_PARAMETER", + "message": "The request contained at least one invalid parameter. The template with ID 'invalid-id' does not exist." +} +``` + +#### Error (401 Unauthorized) + +```json +{ + "errorCode": "USER_AUTHENTICATION_FAILED", + "message": "One or both of Username and Password are invalid." +} +``` + +--- + +## 3. Composite Template Structure + +### 3.1 Key Concepts + +**compositeTemplates** (array): +- Each element represents a server template to include in the envelope +- Order in array determines document order in envelope + +**compositeTemplateId** (string): +- Unique identifier for this composite template within the request +- Can be any string (recommend using sequence numbers: "1", "2", "3") + +**serverTemplates** (array): +- References pre-existing templates in your Docusign account +- Must include `templateId` (GUID from Docusign) + +**sequence** (string): +- Determines document order within the envelope +- "1" = first document, "2" = second, etc. +- **Important**: Use strings, not integers + +**inlineTemplates** (array, optional): +- Allows runtime override of template values +- Used for custom fields, recipient data, tabs + +### 3.2 Recipient Merging + +**Automatic Merge**: +When multiple templates have recipients with the same `roleName`, Docusign automatically merges them into a single recipient. + +**Example**: +- Template A has recipient role: "Signer" +- Template B has recipient role: "Signer" +- Result: One recipient signs all documents + +**Requirements for merge**: +- Exact match on `roleName` +- Same recipient `routingOrder` (signing order) +- Merge happens automatically, no additional configuration needed + +--- + +## 4. Authentication + +### 4.1 JWT (JSON Web Token) + +**Preferred method for server-to-server integration** + +#### Step 1: Generate JWT + +```apex +// Apex pseudocode +String jwt = generateJWT( + integrationKey, // From Docusign Apps & Keys + userId, // Docusign user GUID + rsaPrivateKey, // RSA private key (PEM format) + 'https://account-d.docusign.com/oauth/auth', // Sandbox + 3600 // Token expiry (1 hour) +); +``` + +#### Step 2: Exchange JWT for Access Token + +**Request**: +```http +POST /oauth/token HTTP/1.1 +Host: account-d.docusign.com +Content-Type: application/x-www-form-urlencoded + +grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={jwt} +``` + +**Response**: +```json +{ + "access_token": "eyJ0eXAiOiJNVCIsImFsZyI...", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +#### Step 3: Use Access Token + +```http +Authorization: Bearer eyJ0eXAiOiJNVCIsImFsZyI... +``` + +### 4.2 OAuth2 Authorization Code + +**User-based authentication** (not recommended for this use case) + +See [Docusign OAuth2 documentation](https://developers.docusign.com/platform/auth/authcode/) for details. + +--- + +## 5. Error Codes + +### 5.1 Common Error Codes + +| Error Code | HTTP | Meaning | Resolution | +|------------|------|---------|------------| +| `INVALID_REQUEST_PARAMETER` | 400 | Invalid parameter in request | Check JSON structure, template IDs | +| `USER_AUTHENTICATION_FAILED` | 401 | Invalid or expired access token | Refresh access token | +| `USER_LACKS_PERMISSIONS` | 403 | User doesn't have permission | Check Docusign user permissions | +| `RESOURCE_NOT_FOUND` | 404 | Template ID not found | Verify template exists in account | +| `DUPLICATE_RESOURCE` | 409 | Duplicate request | Check for duplicate envelope | +| `ONESIGNAL_GENERIC_ERROR` | 500 | Docusign server error | Retry after delay | + +### 5.2 Rate Limits + +**Hourly limit**: Varies by plan (1,000 - 10,000+ API calls/hour) + +**Response header when nearing limit**: +```http +X-RateLimit-Limit: 1000 +X-RateLimit-Remaining: 50 +X-RateLimit-Reset: 1614358800 +``` + +**Error when limit exceeded**: +```json +{ + "errorCode": "HOURLY_APIINVOCATION_LIMIT_EXCEEDED", + "message": "The maximum number of hourly API invocations has been exceeded." +} +``` + +**Mitigation**: +- Implement exponential backoff +- Cache access tokens (don't generate new token for each request) +- Batch operations when possible + +--- + +## 6. Custom Fields + +### 6.1 Text Custom Fields + +**Use case**: Store Salesforce record ID in envelope + +```json +{ + "compositeTemplates": [ + { + "inlineTemplates": [ + { + "customFields": { + "textCustomFields": [ + { + "name": "SalesforceRecordId", + "value": "0018V00000ABC123", + "show": "false", + "required": "false" + } + ] + } + } + ] + } + ] +} +``` + +### 6.2 List Custom Fields + +```json +{ + "customFields": { + "listCustomFields": [ + { + "name": "FormLanguage", + "value": "English", + "listItems": ["English", "Spanish"] + } + ] + } +} +``` + +--- + +## 7. Webhook Configuration + +### 7.1 Connect Webhook (for document retrieval) + +**Not required for Phase 1**, but useful for future automation. + +**Endpoint**: Configure in Docusign Admin β†’ Connect β†’ Add Configuration + +**Webhook payload** (when envelope completes): +```json +{ + "event": "envelope-completed", + "apiVersion": "v2.1", + "uri": "/restapi/v2.1/accounts/123/envelopes/abc", + "envelopeId": "f9876543-21ab-cdef-0123-456789abcdef", + "envelopeSummary": { + "status": "completed", + "emailSubject": "Please review and sign", + "completedDateTime": "2026-02-23T14:30:00Z" + } +} +``` + +--- + +## 8. Template Management APIs + +### 8.1 List Templates + +**Use case**: Retrieve template list for Flow dropdown + +**Request**: +```http +GET /restapi/v2.1/accounts/{accountId}/templates?count=100&order=name&order_by=asc +``` + +**Response**: +```json +{ + "resultSetSize": "14", + "totalSetSize": "14", + "envelopeTemplates": [ + { + "templateId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Form A - English", + "shared": "true", + "description": "English version of Form A" + }, + { + "templateId": "b2c3d4e5-f6g7-8901-bcde-fg2345678901", + "name": "Form B - English", + "shared": "true", + "description": "English version of Form B" + } + ] +} +``` + +### 8.2 Get Template Details + +**Use case**: Retrieve template metadata (recipients, tabs) + +**Request**: +```http +GET /restapi/v2.1/accounts/{accountId}/templates/{templateId} +``` + +**Response**: +```json +{ + "templateId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Form A - English", + "recipients": { + "signers": [ + { + "roleName": "Signer 1", + "recipientId": "1", + "routingOrder": "1" + }, + { + "roleName": "Signer 2", + "recipientId": "2", + "routingOrder": "2" + } + ] + }, + "documents": [ + { + "documentId": "1", + "name": "Form_A.pdf", + "pages": "2" + } + ] +} +``` + +--- + +## 9. Apex HTTP Callout Example + +### 9.1 Complete Callout + +```apex +public static String createCompositeEnvelope(String envelopeJSON, String accessToken, String accountId) { + HttpRequest req = new HttpRequest(); + req.setEndpoint('callout:DocusignAPI/accounts/' + accountId + '/envelopes'); + req.setMethod('POST'); + req.setHeader('Authorization', 'Bearer ' + accessToken); + req.setHeader('Content-Type', 'application/json'); + req.setHeader('Accept', 'application/json'); + req.setBody(envelopeJSON); + req.setTimeout(120000); // 120 seconds + + Http http = new Http(); + HttpResponse res = http.send(req); + + if (res.getStatusCode() == 201) { + Map responseMap = (Map) JSON.deserializeUntyped(res.getBody()); + return (String) responseMap.get('envelopeId'); + } else { + throw new CalloutException('Docusign API error [' + res.getStatusCode() + ']: ' + res.getBody()); + } +} +``` + +### 9.2 Named Credential Configuration + +**Setup β†’ Named Credentials β†’ New Named Credential** + +**Settings**: +- **Label**: Docusign API +- **Name**: DocusignAPI +- **URL**: `https://na3.docusign.net/restapi/v2.1` +- **Identity Type**: Named Principal +- **Authentication Protocol**: OAuth 2.0 +- **Scope**: `signature impersonation` +- **Token Endpoint**: `https://account-d.docusign.com/oauth/token` (sandbox) +- **JWT Token Exchange**: Enabled + +**Usage in Apex**: +```apex +req.setEndpoint('callout:DocusignAPI/accounts/' + accountId + '/envelopes'); +``` + +--- + +## 10. Testing with Postman + +### 10.1 Import Docusign Collection + +Docusign provides an official Postman collection: +https://github.com/docusign/postman-collections + +### 10.2 Create Composite Envelope Test + +**Request**: +``` +POST {{baseUrl}}/accounts/{{accountId}}/envelopes +Headers: + Authorization: Bearer {{accessToken}} + Content-Type: application/json +Body: + { + "status": "sent", + "emailSubject": "Test Composite Envelope", + "compositeTemplates": [ + { + "compositeTemplateId": "1", + "serverTemplates": [ + { + "sequence": "1", + "templateId": "{{templateId1}}" + } + ] + }, + { + "compositeTemplateId": "2", + "serverTemplates": [ + { + "sequence": "2", + "templateId": "{{templateId2}}" + } + ] + } + ] + } +``` + +--- + +## 11. Reference Links + +- [Docusign REST API Reference](https://developers.docusign.com/docs/esign-rest-api/reference/) +- [Composite Templates Guide](https://developers.docusign.com/docs/esign-rest-api/how-to/request-signature-composite-template/) +- [JWT Authentication](https://developers.docusign.com/platform/auth/jwt/) +- [Salesforce Named Credentials](https://help.salesforce.com/s/articleView?id=sf.named_credentials_about.htm) +- [Salesforce HTTP Callouts](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_restful_http_httprequest.htm) + +--- + +**Document Version**: 1.0 +**Last Updated**: February 23, 2026 diff --git a/composite-envelope-builder/docs/deployment-guide.md b/composite-envelope-builder/docs/deployment-guide.md new file mode 100644 index 0000000..c1d0235 --- /dev/null +++ b/composite-envelope-builder/docs/deployment-guide.md @@ -0,0 +1,531 @@ +# Deployment Guide + +**Project**: Salesforce Composite Envelope Builder +**Version**: 1.0 +**Date**: February 23, 2026 + +--- + +## 1. Prerequisites + +### 1.1 Salesforce Requirements + +- **Edition**: Enterprise Edition or higher (required for API access) +- **User Permissions**: + - API Enabled + - Modify All Data (for deployment) + - Manage Flows (for Flow updates) +- **Features Enabled**: + - Screen Flows + - Apex Classes + +### 1.2 Docusign Requirements + +- **Account**: Docusign eSignature (Production or Sandbox) +- **API Access**: Enabled +- **Templates**: 28 single-form templates created (14 English + 14 Spanish) +- **User Role**: Sender or higher + +### 1.3 Development Tools + +- **VS Code** with Salesforce Extension Pack +- **Salesforce CLI** (sf CLI version 2.0+) +- **Git** for version control + +--- + +## 2. Docusign Configuration + +### 2.1 Create Integration Key + +**Step 1: Navigate to Apps & Keys** +1. Log in to Docusign +2. Go to **Settings β†’ Apps and Keys** +3. Click **Add App and Integration Key** + +**Step 2: Configure Integration** +- **App Name**: Salesforce Composite Envelope Builder +- **Integration Type**: JWT (JSON Web Token) +- **Redirect URI**: Not required for JWT +- **Secret Key**: Save securely (will be shown once) + +**Step 3: Generate RSA Key Pair** +1. Click **Generate RSA** +2. Download private key (PEM file) +3. Copy public key (will be added to Salesforce) + +**Step 4: Grant Consent** +1. Click **Grant Consent** button +2. Authenticate as Docusign admin user +3. Accept permissions + +**Step 5: Note Important Values** +- **Integration Key** (GUID): e.g., `a1b2c3d4-...` +- **User ID** (GUID of API user): e.g., `f9876543-...` +- **Account ID** (GUID): e.g., `12345678-...` +- **Base URL**: e.g., `https://na3.docusign.net/restapi/v2.1` + +### 2.2 Create Templates + +**For each of 14 forms, create 2 templates (English + Spanish)**: + +1. Go to **Templates β†’ New β†’ Use a Template** +2. Upload PDF document +3. Add fields (signature tabs, text fields, etc.) +4. Define recipients: + - **Role 1**: Signer 1 (routing order 1) + - **Role 2**: Signer 2 (routing order 2) +5. Name template clearly: + - Format: `[Form Name] - [Language]` + - Example: `Form A - English`, `Form A - Spanish` +6. Save template +7. Note template ID (GUID) + +**Template Naming Convention** (for alphabetical sorting): +``` +Form A - Credit Application - English +Form A - Credit Application - Spanish +Form B - Income Verification - English +Form B - Income Verification - Spanish +... (continue alphabetically) +``` + +--- + +## 3. Salesforce Configuration + +### 3.1 Create Named Credential + +**Option 1: External Credential (Recommended for JWT)** + +**Step 1: Create External Credential** +1. Setup β†’ Named Credentials β†’ External Credentials β†’ New +2. **Label**: Docusign JWT Auth +3. **Name**: DocusignJWTAuth +4. **Authentication Protocol**: Custom +5. **Identity Type**: Named Principal +6. Click **Create** + +**Step 2: Add Custom Headers** +1. Open External Credential β†’ Custom Headers β†’ New +2. Add header: + - **Header Field Name**: Authorization + - **Header Field Value**: `Bearer {!$Credential.DocusignJWTAuth.AccessToken}` + +**Step 3: Create Named Credential** +1. Setup β†’ Named Credentials β†’ New Named Credential +2. **Label**: Docusign API +3. **Name**: DocusignAPI +4. **URL**: `https://na3.docusign.net/restapi/v2.1` (or your data center) +5. **External Credential**: DocusignJWTAuth +6. **Enabled for Callouts**: Checked +7. Click **Save** + +--- + +**Option 2: Legacy Named Credential (Simpler but less flexible)** + +1. Setup β†’ Named Credentials β†’ New Legacy +2. **Label**: Docusign API +3. **Name**: DocusignAPI +4. **URL**: `https://na3.docusign.net/restapi/v2.1` +5. **Identity Type**: Named Principal +6. **Authentication Protocol**: OAuth 2.0 +7. **Authentication Provider**: (Create new - see below) +8. **Scope**: `signature impersonation` +9. **Save** + +**Create Authentication Provider**: +1. Setup β†’ Auth. Providers β†’ New +2. **Provider Type**: Open ID Connect +3. **Name**: Docusign OAuth +4. **Consumer Key**: {Integration Key from Docusign} +5. **Consumer Secret**: {Secret from Docusign} +6. **Authorize Endpoint URL**: `https://account-d.docusign.com/oauth/auth` +7. **Token Endpoint URL**: `https://account-d.docusign.com/oauth/token` +8. **Default Scopes**: `signature impersonation` +9. **Save** + +### 3.2 Create Custom Settings (for Account ID) + +**Step 1: Create Custom Setting** +1. Setup β†’ Custom Settings β†’ New +2. **Label**: Docusign Configuration +3. **Object Name**: Docusign_Configuration +4. **Setting Type**: Hierarchy +5. Add custom fields: + - **Account_Id__c** (Text, 255) + - **Base_URL__c** (Text, 255) +6. **Save** + +**Step 2: Manage Custom Setting** +1. Setup β†’ Custom Settings β†’ Docusign Configuration β†’ Manage +2. Click **New** (organization-wide default) +3. **Account Id**: {Docusign Account GUID} +4. **Base URL**: `https://na3.docusign.net/restapi/v2.1` +5. **Save** + +### 3.3 Create Remote Site Settings + +1. Setup β†’ Remote Site Settings β†’ New Remote Site +2. **Remote Site Name**: Docusign_API +3. **Remote Site URL**: `https://na3.docusign.net` +4. **Active**: Checked +5. **Save** + +**Add additional sites** (if using sandbox or different data center): +- `https://demo.docusign.net` (Docusign Sandbox) +- `https://account-d.docusign.com` (OAuth endpoints) + +--- + +## 4. Code Deployment + +### 4.1 Clone Repository (if using Git) + +```bash +git clone https://github.com/your-org/salesforce-composite-envelope-builder.git +cd salesforce-composite-envelope-builder/composite-envelope-builder +``` + +### 4.2 Authorize Salesforce Org + +**Sandbox**: +```bash +sf org login web --alias my-sandbox --instance-url https://test.salesforce.com +``` + +**Production**: +```bash +sf org login web --alias my-production --instance-url https://login.salesforce.com +``` + +### 4.3 Deploy to Sandbox + +```bash +sf project deploy start --target-org my-sandbox +``` + +**Expected output**: +``` +Deploying v60.0 metadata to my-sandbox using the v60.0 SOAP API +Status: Succeeded +Component Deployed: + ApexClass DocusignCompositeEnvelopeBuilder + ApexClass DocusignAPIService + ApexClass DocusignCredentials + ApexClass DocusignCompositeEnvelopeBuilderTest + ApexClass DocusignAPIServiceTest + ApexClass DocusignCredentialsTest +``` + +### 4.4 Run Unit Tests + +```bash +sf apex run test --class-names DocusignCompositeEnvelopeBuilderTest --target-org my-sandbox --wait 5 --result-format human +``` + +**Expected output**: +``` +Test Success 1 tests ran, 1 passed, 0 failed, 0 skipped +Code Coverage 85% (target: 75%) +``` + +### 4.5 Validate Production (without deploying) + +```bash +sf project deploy validate --target-org my-production --test-level RunLocalTests +``` + +### 4.6 Deploy to Production + +```bash +sf project deploy start --target-org my-production --test-level RunLocalTests +``` + +--- + +## 5. Flow Configuration + +### 5.1 Update Existing Screen Flow + +**Step 1: Open Flow in Flow Builder** +1. Setup β†’ Flows β†’ [Your Form Selection Flow] +2. Click **Edit** + +**Step 2: Modify Template Loop Logic** + +**Old Logic** (sends one envelope per template): +``` +Loop through selected templates + └─ Create envelope for template +``` + +**New Logic** (sends one envelope with all templates): +``` +Get selected templates (checkbox collection) + └─ Apex Action: Send Composite Envelope + Input: templateIds (checkbox collection) + Output: envelopeId +``` + +**Step 3: Add Apex Action Element** +1. Drag **Action** element onto canvas (after template selection screen) +2. **Action**: `Send Composite Docusign Envelope` +3. **Label**: Send Composite Envelope +4. **API Name**: Send_Composite_Envelope +5. **Set Input Values**: + - `templateIds` = `{!SelectedTemplateIds}` (collection from checkboxes) + - `recordId` = `{!recordId}` (from flow start) + - `language` = `{!SelectedLanguage}` (from language selection) + - `emailSubject` = "Please review and sign these forms" +6. **Store Output Values**: + - `envelopeId` β†’ `{!EnvelopeId}` (text variable) + - `success` β†’ `{!Success}` (boolean variable) + - `errorMessage` β†’ `{!ErrorMessage}` (text variable) + +**Step 4: Add Decision Element (Success/Error)** +1. Drag **Decision** element after Apex Action +2. **Outcome 1: Success** + - Condition: `{!Success}` Equals `true` + - Next: Show success screen +3. **Outcome 2: Error** + - Default outcome + - Next: Show error screen (with `{!ErrorMessage}`) + +**Step 5: Remove Old Loop** +1. Delete old loop element that sent individual envelopes +2. Delete old Apex actions (if any) for single-template sending + +**Step 6: Save and Activate** +1. Click **Save As** β†’ New version +2. Click **Activate** + +--- + +## 6. Testing + +### 6.1 Unit Test Verification + +**Run all tests**: +```bash +sf apex run test --wait 10 --result-format human --code-coverage --target-org my-sandbox +``` + +**Expected code coverage**: >75% on all classes + +### 6.2 Integration Test (Manual) + +**Test Case 1: Send 3 Templates (Happy Path)** +1. Navigate to Salesforce record +2. Launch Screen Flow +3. Select language: English +4. Check 3 templates: Form A, Form B, Form C +5. Click **Send** +6. **Expected**: + - Success message shown + - Envelope ID displayed + - One envelope created in Docusign + - Three documents in envelope + - Recipients receive one email + +**Test Case 2: Send 1 Template (Minimum)** +1. Select 1 template only +2. **Expected**: Envelope created with 1 document + +**Test Case 3: Send 14 Templates (Maximum)** +1. Select all 14 templates +2. **Expected**: Envelope created with 14 documents + +**Test Case 4: Spanish Language** +1. Select language: Spanish +2. Select 2 Spanish templates +3. **Expected**: Envelope created with Spanish documents + +**Test Case 5: Error Handling (Invalid Template)** +1. Manually modify Apex to use invalid template ID +2. **Expected**: Error message displayed, no envelope created + +### 6.3 Performance Test + +**Measure Apex execution time**: +1. Enable Debug Logs (Setup β†’ Debug Logs) +2. Send envelope with 14 templates +3. Review log file +4. **Expected**: Execution time < 10 seconds + +--- + +## 7. Monitoring + +### 7.1 Debug Logs + +**Enable for API User**: +1. Setup β†’ Debug Logs β†’ New +2. **Traced Entity Type**: User +3. **Traced Entity Name**: [API User] +4. **Log Level**: FINEST (for troubleshooting) or INFO (for production) +5. **Expiration**: 1 day (or desired duration) + +**Review Logs**: +1. Setup β†’ Debug Logs +2. Click **View** on recent log +3. Search for: + - `REQUEST_BODY` (Docusign API request JSON) + - `RESPONSE_BODY` (Docusign API response) + - `CalloutException` (errors) + +### 7.2 Custom Logging (Optional) + +**Create Custom Object for API Logs**: +1. Setup β†’ Object Manager β†’ Create β†’ Custom Object +2. **Object Name**: Docusign API Log +3. **API Name**: Docusign_API_Log__c +4. Add fields: + - **Envelope_Id__c** (Text, 255) + - **Template_Count__c** (Number, 0 decimals) + - **Status__c** (Picklist: Success, Error) + - **Error_Message__c** (Long Text Area, 32,000) + - **Request_Time__c** (Date/Time) + - **Duration_Ms__c** (Number, 0 decimals) + +**Log API calls in Apex**: +```apex +Docusign_API_Log__c log = new Docusign_API_Log__c( + Envelope_Id__c = envelopeId, + Template_Count__c = templateIds.size(), + Status__c = 'Success', + Request_Time__c = System.now(), + Duration_Ms__c = durationMs +); +insert log; +``` + +--- + +## 8. Rollback Plan + +### 8.1 Quick Rollback (Deactivate Flow) + +If issues arise in production: +1. Setup β†’ Flows β†’ [Your Flow] +2. **Deactivate** current version +3. **Activate** previous version (before composite envelope changes) + +### 8.2 Full Rollback (Remove Apex) + +```bash +sf project deploy start --manifest manifest/destructiveChanges.xml --target-org production +``` + +**destructiveChanges.xml**: +```xml + + + + DocusignCompositeEnvelopeBuilder + DocusignAPIService + DocusignCredentials + DocusignCompositeEnvelopeBuilderTest + DocusignAPIServiceTest + DocusignCredentialsTest + ApexClass + + 60.0 + +``` + +--- + +## 9. Post-Deployment Checklist + +- [ ] Named Credential configured and tested +- [ ] Custom Settings populated with Account ID +- [ ] Remote Site Settings added +- [ ] Apex classes deployed (100% coverage) +- [ ] Unit tests pass +- [ ] Screen Flow updated and activated +- [ ] Integration test completed successfully +- [ ] Debug logs reviewed (no errors) +- [ ] User training completed +- [ ] Documentation updated +- [ ] Rollback plan tested + +--- + +## 10. Troubleshooting + +### 10.1 Common Issues + +**Issue**: `USER_AUTHENTICATION_FAILED` error +**Cause**: Access token expired or invalid +**Solution**: +- Verify Named Credential configuration +- Check Integration Key and User ID in Docusign +- Re-grant consent if needed + +**Issue**: `INVALID_REQUEST_PARAMETER` - Template ID not found +**Cause**: Template doesn't exist or is in different account +**Solution**: +- Verify template IDs in Docusign +- Ensure using correct Docusign account (sandbox vs. production) + +**Issue**: Governor limit exceeded (Heap size) +**Cause**: Too many templates or large JSON payload +**Solution**: +- Reduce number of templates per envelope +- Optimize JSON construction (avoid unnecessary string concatenation) + +**Issue**: Flow shows no templates +**Cause**: Flow query not finding templates +**Solution**: +- Check Flow SOQL query (if using custom object for template list) +- Verify Docusign template naming convention matches Flow filter + +--- + +## 11. Maintenance + +### 11.1 Regular Tasks + +**Weekly**: +- Review debug logs for errors +- Check Docusign API usage (approaching rate limits?) + +**Monthly**: +- Review custom object logs (if implemented) +- Update templates as needed + +**Quarterly**: +- Review access token expiry settings +- Update Apex classes if Docusign API changes + +### 11.2 Template Updates + +When adding a new form template: +1. Create template in Docusign (English + Spanish versions) +2. Update Flow template list (if hardcoded) +3. Test envelope creation with new template +4. Update user documentation + +--- + +## 12. Support + +### 12.1 Internal Support + +- **Salesforce Admin**: [Contact info] +- **Docusign Admin**: [Contact info] +- **Developer**: Paul Huliganga + +### 12.2 External Resources + +- **Salesforce Support**: https://help.salesforce.com +- **Docusign Support**: https://support.docusign.com +- **Docusign Developer Center**: https://developers.docusign.com + +--- + +**Document Version**: 1.0 +**Last Updated**: February 23, 2026 +**Next Review**: After first production deployment diff --git a/composite-envelope-builder/docs/design.md b/composite-envelope-builder/docs/design.md new file mode 100644 index 0000000..dde1a1b --- /dev/null +++ b/composite-envelope-builder/docs/design.md @@ -0,0 +1,647 @@ +# Design Document + +**Project**: Salesforce Composite Envelope Builder +**Version**: 1.0 +**Date**: February 23, 2026 +**Author**: Paul Huliganga + +--- + +## 1. Architecture Overview + +### 1.1 System Context + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Salesforce User β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Salesforce Platform β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Screen Flow │───▢│ Apex Class β”‚ β”‚ +β”‚ β”‚ (Template β”‚ β”‚ (Composite β”‚ β”‚ +β”‚ β”‚ Selection) β”‚ β”‚ Builder) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό HTTPS/REST API + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Docusign REST API β”‚ + β”‚ (Composite β”‚ + β”‚ Templates) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 1.2 Component Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Salesforce Org β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Presentation Layer (Screen Flow) β”‚ β”‚ +β”‚ β”‚ - Language selection β”‚ β”‚ +β”‚ β”‚ - Template display (checkbox collection) β”‚ β”‚ +β”‚ β”‚ - Success/error messaging β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Business Logic Layer (Apex) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ DocusignCompositeEnvelopeBuilder β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - @InvocableMethod entry point β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Input validation β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Composite JSON construction β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Envelope ID return β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ DocusignAPIService β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - API authentication β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - HTTP callout construction β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Response parsing β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Error handling β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ DocusignCredentials β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Credential retrieval β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Token management β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Data Layer β”‚ β”‚ +β”‚ β”‚ - Named Credential (Docusign API creds) β”‚ β”‚ +β”‚ β”‚ - Custom Settings (configuration) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό HTTPS REST API + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Docusign Platform β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 2. Detailed Component Design + +### 2.1 DocusignCompositeEnvelopeBuilder (Main Class) + +**Purpose**: Invocable Apex class that combines multiple Docusign templates into a single envelope + +**Responsibilities**: +- Receive template IDs from Screen Flow +- Validate inputs +- Construct composite template JSON +- Delegate API call to service class +- Return envelope ID to Flow + +**Methods**: + +```apex +// Flow-invocable entry point +@InvocableMethod( + label='Send Composite Docusign Envelope' + description='Combines multiple templates into one envelope' +) +public static List sendCompositeEnvelope(List requests) + +// Private helper methods +private static String buildCompositeEnvelopeJSON( + List templateIds, + String recordId, + String language, + Map customFields +) + +private static void validateInputs(Request req) + +private static List sortTemplatesAlphabetically(List templateIds) +``` + +**Inner Classes**: + +```apex +public class Request { + @InvocableVariable(required=true label='Template IDs') + public List templateIds; + + @InvocableVariable(required=true label='Salesforce Record ID') + public String recordId; + + @InvocableVariable(required=false label='Language') + public String language; // 'en' or 'es' + + @InvocableVariable(required=false label='Email Subject') + public String emailSubject; + + @InvocableVariable(required=false label='Custom Fields') + public Map customFields; // For merge fields +} + +public class Result { + @InvocableVariable(label='Envelope ID') + public String envelopeId; + + @InvocableVariable(label='Success') + public Boolean success; + + @InvocableVariable(label='Error Message') + public String errorMessage; +} +``` + +--- + +### 2.2 DocusignAPIService (Service Class) + +**Purpose**: Handles all Docusign REST API interactions + +**Responsibilities**: +- Construct HTTP requests +- Make callouts to Docusign API +- Parse responses +- Handle errors and retries +- Log API interactions + +**Methods**: + +```apex +public static String createCompositeEnvelope( + String envelopeJSON, + DocusignCredentials creds +) + +public static HttpResponse callDocusignAPI( + String endpoint, + String method, + String body, + Map headers +) + +public static String parseEnvelopeId(HttpResponse response) + +private static void logAPICall( + HttpRequest req, + HttpResponse res, + Long durationMs +) + +private static void handleAPIError(HttpResponse response) +``` + +--- + +### 2.3 DocusignCredentials (Credential Management) + +**Purpose**: Retrieve and manage Docusign API credentials securely + +**Responsibilities**: +- Fetch credentials from Named Credential or Custom Settings +- Handle token refresh (JWT/OAuth2) +- Provide credential object to service layer + +**Methods**: + +```apex +public static DocusignCredentials getInstance() + +public String getAccessToken() + +public String getAccountId() + +public String getBaseUrl() + +private static String refreshAccessToken() // JWT or OAuth2 refresh + +public class CredentialException extends Exception {} +``` + +**Properties**: + +```apex +public String baseUrl { get; private set; } +public String accountId { get; private set; } +public String accessToken { get; private set; } +public DateTime tokenExpiry { get; private set; } +``` + +--- + +## 3. Data Flow + +### 3.1 Sequence Diagram: Send Composite Envelope + +``` +User Flow Apex Service Docusign API + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ Select β”‚ β”‚ β”‚ β”‚ + β”‚ Templates β”‚ β”‚ β”‚ β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Invoke Apex β”‚ β”‚ β”‚ + β”‚ β”‚ Action β”‚ β”‚ β”‚ + β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ β”‚ β”‚ + β”‚ β”‚ (templateIds) β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ Validate inputs β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ Build JSON β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ Get credentials β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ │◀──────────────────── β”‚ + β”‚ β”‚ β”‚ (creds) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ Call API β”‚ β”‚ + β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ β”‚ + β”‚ β”‚ β”‚ (JSON, creds) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ POST /envelopes β”‚ + β”‚ β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ 201 Created β”‚ + β”‚ β”‚ β”‚ β”‚ {envelopeId} β”‚ + β”‚ β”‚ β”‚ │◀────────────────── + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ envelopeId β”‚ β”‚ + β”‚ β”‚ │◀──────────────────── β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Result β”‚ β”‚ β”‚ + β”‚ β”‚ (envelopeId) β”‚ β”‚ β”‚ + β”‚ │◀─────────────── β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ Success β”‚ β”‚ β”‚ β”‚ + β”‚ Message β”‚ β”‚ β”‚ β”‚ + │◀───────────── β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ +``` + +### 3.2 Error Handling Flow + +``` +Apex Service Docusign API + β”‚ β”‚ β”‚ + β”‚ Call API β”‚ β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ β”‚ + β”‚ β”‚ POST /envelopes β”‚ + β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ 400 Bad Request β”‚ + β”‚ β”‚ (error JSON) β”‚ + β”‚ │◀────────────────── + β”‚ β”‚ β”‚ + β”‚ β”‚ Parse error β”‚ + β”‚ β”œβ”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚β—€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ Throw exception β”‚ + β”‚ β”‚ β”‚ + β”‚ Catch exception β”‚ β”‚ + │◀───────────────── β”‚ + β”‚ β”‚ β”‚ + β”‚ Log error β”‚ β”‚ + β”œβ”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚β—€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β”‚ β”‚ + β”‚ Return Result β”‚ β”‚ + β”‚ (success=false, β”‚ β”‚ + β”‚ errorMessage) β”‚ β”‚ + β”‚ β”‚ β”‚ +``` + +--- + +## 4. Docusign Composite Template JSON Structure + +### 4.1 Request Format + +```json +{ + "status": "sent", + "emailSubject": "Please review and sign these forms", + "compositeTemplates": [ + { + "compositeTemplateId": "1", + "serverTemplates": [ + { + "sequence": "1", + "templateId": "TEMPLATE_ID_FORM_A" + } + ] + }, + { + "compositeTemplateId": "2", + "serverTemplates": [ + { + "sequence": "2", + "templateId": "TEMPLATE_ID_FORM_B" + } + ] + }, + { + "compositeTemplateId": "3", + "serverTemplates": [ + { + "sequence": "3", + "templateId": "TEMPLATE_ID_FORM_C" + } + ] + } + ] +} +``` + +### 4.2 Apex JSON Construction + +**Approach**: Use `Map` + `JSON.serialize()` for dynamic construction + +```apex +List compositeTemplates = new List(); + +Integer sequence = 1; +for (String templateId : sortedTemplateIds) { + Map template = new Map{ + 'compositeTemplateId' => String.valueOf(sequence), + 'serverTemplates' => new List{ + new Map{ + 'sequence' => String.valueOf(sequence), + 'templateId' => templateId + } + } + }; + compositeTemplates.add(template); + sequence++; +} + +Map envelope = new Map{ + 'status' => 'sent', + 'emailSubject' => emailSubject, + 'compositeTemplates' => compositeTemplates +}; + +String envelopeJSON = JSON.serialize(envelope); +``` + +--- + +## 5. API Integration Details + +### 5.1 Endpoint + +**POST** `https://{baseUrl}/restapi/v2.1/accounts/{accountId}/envelopes` + +Where: +- `{baseUrl}`: e.g., `na3.docusign.net` (from Named Credential) +- `{accountId}`: Docusign account GUID (from Named Credential or Custom Settings) + +### 5.2 Headers + +``` +Authorization: Bearer {access_token} +Content-Type: application/json +Accept: application/json +``` + +### 5.3 Authentication + +**JWT (JSON Web Token) - Preferred**: +- Use RSA key pair stored in Named Credential +- Generate JWT, exchange for access token +- Token valid for 1 hour, cache and refresh as needed + +**OAuth2 Authorization Code - Alternative**: +- User-based authentication +- Access token + refresh token +- Requires user interaction for initial authorization + +### 5.4 Response Codes + +| Code | Meaning | Action | +|------|---------|--------| +| 201 | Created | Success - parse envelope ID | +| 400 | Bad Request | Invalid JSON or parameters - log and return error | +| 401 | Unauthorized | Token expired - refresh and retry | +| 403 | Forbidden | Insufficient permissions - log and return error | +| 429 | Too Many Requests | Rate limit - retry after delay | +| 500 | Server Error | Docusign issue - log and return error | + +--- + +## 6. Governor Limit Considerations + +### 6.1 Heap Size +- **Limit**: 6 MB (synchronous), 12 MB (asynchronous) +- **Mitigation**: + - Serialize JSON incrementally + - Avoid loading large objects into memory + - Process template IDs in batches if needed (future enhancement) + +### 6.2 CPU Time +- **Limit**: 10,000 ms (synchronous) +- **Mitigation**: + - Minimize loops and complex logic + - Delegate heavy work to Docusign API + +### 6.3 Callouts +- **Limit**: 100 callouts per transaction, 10-second timeout per callout +- **Mitigation**: + - Single API call per envelope + - Use asynchronous callouts (@future) for bulk operations (future enhancement) + +### 6.4 SOQL Queries +- **Limit**: 100 queries per transaction +- **Mitigation**: + - Minimize queries in loop (not applicable for this design) + - Cache credentials if fetched via SOQL + +--- + +## 7. Security Design + +### 7.1 Credential Storage + +**Option 1: Named Credential (Preferred)** +- Credentials encrypted by Salesforce +- No code changes needed for credential updates +- OAuth flows managed by platform + +**Option 2: Protected Custom Settings** +- API credentials stored encrypted +- Accessible only via Apex +- Manual token refresh required + +### 7.2 Access Control + +- Apex class runs in **user context** (sharing rules enforced) +- Users must have: + - Permission to execute Flows + - Read access to Salesforce record + - Docusign send permission (managed via Docusign) + +### 7.3 Data Protection + +- No sensitive data logged in debug statements +- API credentials never exposed in logs or error messages +- HTTPS for all API communication + +--- + +## 8. Testing Strategy + +### 8.1 Unit Tests + +**Test Coverage Goal**: >75% (Salesforce requirement) + +**Test Classes**: +- `DocusignCompositeEnvelopeBuilderTest` +- `DocusignAPIServiceTest` +- `DocusignCredentialsTest` + +**Test Scenarios**: +1. **Success case**: 3 templates combined, envelope created +2. **Edge case**: 1 template (minimum) +3. **Edge case**: 14 templates (maximum) +4. **Error case**: Invalid template ID +5. **Error case**: API returns 400 +6. **Error case**: API returns 401 (token expired) +7. **Error case**: API timeout +8. **Mock callouts**: Use `HttpCalloutMock` for Docusign API + +### 8.2 Integration Tests + +**Manual Testing**: +1. Create test Docusign templates in sandbox +2. Configure Named Credential with sandbox credentials +3. Execute Screen Flow with various template combinations +4. Verify envelope creation in Docusign sandbox +5. Verify documents written back to Salesforce + +### 8.3 Performance Tests + +- Test with maximum templates (14) +- Measure Apex CPU time +- Verify heap size usage +- Test concurrent requests (multiple users) + +--- + +## 9. Deployment Architecture + +### 9.1 Deployment Components + +**Metadata to Deploy**: +1. Apex classes: + - `DocusignCompositeEnvelopeBuilder.cls` + - `DocusignAPIService.cls` + - `DocusignCredentials.cls` + - Test classes (3 files) +2. Named Credential configuration +3. Remote Site Settings (for Docusign API URL) +4. Permission Sets (optional, for access control) + +### 9.2 Deployment Steps + +1. **Sandbox deployment** (via VS Code or CLI): + ```bash + sf project deploy start --target-org sandbox + ``` + +2. **Run unit tests**: + ```bash + sf apex run test --wait 5 + ``` + +3. **Validate production** (without deployment): + ```bash + sf project deploy validate --target-org production + ``` + +4. **Deploy to production**: + ```bash + sf project deploy start --target-org production + ``` + +### 9.3 Rollback Plan + +- Version control: Git repository tracks all changes +- Destructive changes: Remove Apex classes if needed +- Flow modification: Update Flow to call old logic temporarily + +--- + +## 10. Monitoring & Maintenance + +### 10.1 Logging + +**Debug Logs**: +- Log API request/response (sanitized, no credentials) +- Log execution time +- Log errors with stack traces + +**Custom Object (Optional)**: +- Store API call history in custom object `Docusign_API_Log__c` +- Fields: Timestamp, Envelope ID, Template Count, Status, Error Message + +### 10.2 Alerts + +- Email alerts for repeated API failures +- Platform Event for real-time monitoring (future enhancement) + +### 10.3 Maintenance Tasks + +- Monitor Docusign API rate limits +- Review logs for errors +- Update templates as business needs change +- Refresh API credentials before expiry + +--- + +## 11. Future Enhancements + +### 11.1 Phase 2 Features + +1. **Dynamic recipient selection**: Override template recipients +2. **Conditional form selection**: Show/hide forms based on Salesforce data +3. **Bulk sending**: Send envelopes to multiple records +4. **Template caching**: Cache template metadata to reduce API calls + +### 11.2 Technical Improvements + +1. **Asynchronous processing**: Use `@future` or Queueable for large batches +2. **Retry logic**: Automatic retry for transient failures +3. **Rate limit handling**: Intelligent backoff and retry +4. **Analytics dashboard**: Lightning component for usage reporting + +--- + +## 12. Glossary + +| Term | Definition | +|------|------------| +| **Composite Template** | Docusign feature to combine multiple templates into one envelope | +| **Invocable Method** | Apex method callable from Screen Flows, Process Builder | +| **Named Credential** | Salesforce feature for secure external credential storage | +| **JWT** | JSON Web Token - authentication method for server-to-server integration | +| **Governor Limits** | Salesforce platform resource limits (CPU, heap, callouts) | + +--- + +**Document Status**: Draft +**Next Steps**: Review with stakeholders, begin Apex development +**Estimated Effort**: 3-5 days development + 2 days testing diff --git a/composite-envelope-builder/docs/requirements.md b/composite-envelope-builder/docs/requirements.md new file mode 100644 index 0000000..d7c0948 --- /dev/null +++ b/composite-envelope-builder/docs/requirements.md @@ -0,0 +1,304 @@ +# Requirements Document + +**Project**: Salesforce Composite Envelope Builder +**Version**: 1.0 +**Date**: February 23, 2026 +**Author**: Paul Huliganga + +--- + +## 1. Executive Summary + +### 1.1 Purpose +Enable Salesforce users to dynamically select and send multiple Docusign form templates in a single envelope, eliminating the need for pre-built combination templates. + +### 1.2 Business Problem +- Current system has **42 pre-built templates** for various combinations of 14 forms +- Maintenance burden: every new combination requires a new template +- Cannot handle all possible combinations (14 forms = 16,384 possible combinations) +- User confusion: difficult to find the right pre-built combination template + +### 1.3 Solution Overview +Replace combination templates with **28 single-form templates** (14 forms Γ— 2 languages). Build custom Apex code to dynamically combine selected templates into one envelope at runtime using Docusign's Composite Templates API. + +--- + +## 2. Business Requirements + +### 2.1 Functional Requirements + +#### FR-001: Multi-Language Support +**Priority**: High +**Description**: System must support English and Spanish forms +**Acceptance Criteria**: +- User selects language before form selection +- Only templates in selected language are displayed +- All forms in envelope are in same language + +#### FR-002: Dynamic Form Selection +**Priority**: High +**Description**: Users can select any combination of forms to include in envelope +**Acceptance Criteria**: +- Forms displayed in alphabetical order by title +- Checkbox interface for selection (existing Screen Flow UI) +- Minimum 1 form, maximum 14 forms per envelope +- User can select all, none, or any combination + +#### FR-003: Single Envelope Generation +**Priority**: High +**Description**: All selected forms must be combined into ONE envelope +**Acceptance Criteria**: +- One envelope ID returned +- One email sent to recipients +- One signing ceremony +- Documents appear in selected order + +#### FR-004: Consistent Recipients +**Priority**: High +**Description**: All forms go to the same 2 recipients for review and signature +**Acceptance Criteria**: +- Recipient 1: Review and sign +- Recipient 2: Review and sign +- Same roles across all templates +- Recipients automatically merged by Docusign + +#### FR-005: Salesforce Document Storage +**Priority**: High +**Description**: Completed envelope documents written back to initiating Salesforce record +**Acceptance Criteria**: +- Documents attached to correct record +- All forms from envelope stored +- Metadata preserved (envelope ID, completion date, signers) + +### 2.2 Non-Functional Requirements + +#### NFR-001: Performance +**Priority**: Medium +**Description**: Envelope creation must complete within acceptable timeframe +**Acceptance Criteria**: +- Apex execution time < 10 seconds +- No Salesforce governor limit violations +- REST API call timeout handled gracefully + +#### NFR-002: Maintainability +**Priority**: High +**Description**: Code must be easy to maintain and extend +**Acceptance Criteria**: +- Clear variable naming +- Inline comments for complex logic +- Modular design (separation of concerns) +- Unit tests with >75% code coverage + +#### NFR-003: Error Handling +**Priority**: High +**Description**: System must handle errors gracefully +**Acceptance Criteria**: +- API failures logged and reported to user +- Partial failures do not corrupt data +- Clear error messages for troubleshooting +- Retry logic for transient failures (optional Phase 2) + +#### NFR-004: Security +**Priority**: High +**Description**: Docusign credentials and API calls must be secure +**Acceptance Criteria**: +- Credentials stored in Named Credential or Protected Custom Settings +- No hardcoded API keys +- OAuth2 or JWT authentication +- HTTPS for all API calls + +--- + +## 3. Technical Requirements + +### 3.1 Salesforce Platform Requirements + +#### TR-001: Apex Version +- API version: 60.0 or higher +- Language: Apex + +#### TR-002: Salesforce Edition +- Enterprise Edition or higher (required for API access) +- Screen Flows enabled + +#### TR-003: Permissions +- Users must have permission to: + - Execute Flows + - Send Docusign envelopes + - Attach files to Salesforce records + +### 3.2 Docusign Requirements + +#### TR-004: Docusign Account +- Docusign eSignature account (Production or Sandbox) +- REST API enabled +- Composite Templates feature available + +#### TR-005: Templates +- 28 single-form templates created: + - 14 English templates + - 14 Spanish templates +- Each template contains: + - Document with Docusign tabs + - 2 recipient roles defined + - Merge fields for Salesforce data + +#### TR-006: API Credentials +- Integration Key (Integrator Key) +- User ID with API access +- RSA key pair (for JWT) OR OAuth2 client credentials +- Account ID (GUID) + +### 3.3 Integration Requirements + +#### TR-007: REST API Endpoint +- Endpoint: `/accounts/{accountId}/envelopes` +- Method: POST +- Request format: JSON (Composite Templates structure) + +#### TR-008: Authentication +- Preferred: JWT (JSON Web Token) +- Alternative: OAuth2 Authorization Code Grant +- Access token refresh handling + +--- + +## 4. User Stories + +### US-001: Select Forms in English +**As a** Salesforce user +**I want to** select multiple English forms to send +**So that** I can send all required documents in one envelope + +**Acceptance Criteria**: +- Given I am on the form selection screen +- When I select "English" as the language +- Then I see all 14 English form templates in alphabetical order +- And I can check any combination of forms +- And I can send them in one envelope + +### US-002: Select Forms in Spanish +**As a** Salesforce user +**I want to** select multiple Spanish forms to send +**So that** I can send all required documents in one envelope in Spanish + +**Acceptance Criteria**: +- Given I am on the form selection screen +- When I select "Spanish" as the language +- Then I see all 14 Spanish form templates in alphabetical order +- And I can check any combination of forms +- And I can send them in one envelope + +### US-003: Send Selected Forms +**As a** Salesforce user +**I want to** send my selected forms in one envelope +**So that** recipients receive all documents together + +**Acceptance Criteria**: +- Given I have selected 3 forms +- When I click "Send" +- Then one envelope is created +- And one email is sent to recipients +- And envelope ID is returned to Salesforce +- And I see a success confirmation + +### US-004: View Completed Documents +**As a** Salesforce user +**I want to** see completed documents attached to the Salesforce record +**So that** I have a record of what was signed + +**Acceptance Criteria**: +- Given an envelope was sent and completed +- When I view the Salesforce record +- Then I see all signed documents attached +- And I see envelope metadata (completion date, signers) + +--- + +## 5. Constraints + +### 5.1 Technical Constraints +- **Salesforce Governor Limits**: Must stay within Apex heap size (6 MB synchronous, 12 MB asynchronous), callout limits (100 per transaction), CPU time +- **Docusign API Rate Limits**: Respect Docusign API rate limits (varies by plan) +- **Template Limit**: Maximum 14 forms per envelope (business rule) + +### 5.2 Business Constraints +- **Single Language Per Envelope**: Cannot mix English and Spanish in one envelope +- **Same Recipients**: All forms must go to the same 2 recipients + +### 5.3 Timeline Constraints +- **Delivery**: Solution needed soon (target: 2-4 weeks) + +--- + +## 6. Assumptions + +1. Salesforce Screen Flow already exists and displays templates +2. Docusign templates are already created with correct tabs and roles +3. Docusign API credentials are available (or can be configured) +4. Existing document write-back logic can be reused +5. Users have appropriate Salesforce and Docusign permissions + +--- + +## 7. Dependencies + +### 7.1 Internal Dependencies +- Salesforce Screen Flow (existing) +- Docusign templates (must be created) +- Salesforce record structure (for document attachment) + +### 7.2 External Dependencies +- Docusign REST API availability +- Network connectivity for API callouts + +--- + +## 8. Success Criteria + +### 8.1 Functional Success +- βœ… Users can select any combination of forms +- βœ… All selected forms sent in ONE envelope +- βœ… Documents written back to Salesforce +- βœ… No pre-built combination templates needed + +### 8.2 Technical Success +- βœ… Code coverage >75% +- βœ… No Salesforce governor limit violations +- βœ… Error handling tested +- βœ… API integration validated + +### 8.3 User Success +- βœ… Easier form selection (alphabetical list vs. searching for combination) +- βœ… Faster envelope creation +- βœ… Reduced maintenance burden + +--- + +## 9. Out of Scope (Phase 1) + +The following are explicitly out of scope for the initial release: + +1. **Custom recipient selection** - recipients are fixed per template +2. **Dynamic tab positioning** - tabs defined in templates, not runtime +3. **Form preview** - no preview before sending +4. **Bulk sending** - one envelope at a time +5. **Template versioning** - template updates handled manually +6. **Analytics dashboard** - no reporting on form selection patterns + +--- + +## 10. Future Enhancements (Potential Phase 2) + +1. **Dynamic recipient selection**: Allow users to override default recipients +2. **Conditional logic**: Show/hide forms based on Salesforce data +3. **Form preview**: Preview selected forms before sending +4. **Bulk operations**: Send envelopes to multiple records +5. **Template management UI**: Admin UI for template configuration +6. **Analytics**: Track which form combinations are most common + +--- + +**Document Status**: Draft +**Next Review**: After design document completion +**Approvers**: [Customer stakeholders] diff --git a/composite-envelope-builder/eslint.config.js b/composite-envelope-builder/eslint.config.js new file mode 100644 index 0000000..a58c917 --- /dev/null +++ b/composite-envelope-builder/eslint.config.js @@ -0,0 +1,55 @@ +const { defineConfig } = require('eslint/config'); +const eslintJs = require('@eslint/js'); +const jestPlugin = require('eslint-plugin-jest'); +const auraConfig = require('@salesforce/eslint-plugin-aura'); +const lwcConfig = require('@salesforce/eslint-config-lwc/recommended'); +const globals = require('globals'); + +module.exports = defineConfig([ + // Aura configuration + { + files: ['**/aura/**/*.js'], + extends: [ + ...auraConfig.configs.recommended, + ...auraConfig.configs.locker + ] + }, + + // LWC configuration + { + files: ['**/lwc/**/*.js'], + extends: [lwcConfig] + }, + + // LWC configuration with override for LWC test files + { + files: ['**/lwc/**/*.test.js'], + extends: [lwcConfig], + rules: { + '@lwc/lwc/no-unexpected-wire-adapter-usages': 'off' + }, + languageOptions: { + globals: { + ...globals.node + } + } + }, + + // Jest mocks configuration + { + files: ['**/jest-mocks/**/*.js'], + languageOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + globals: { + ...globals.node, + ...globals.es2021, + ...jestPlugin.environments.globals.globals + } + }, + plugins: { + eslintJs + }, + extends: ['eslintJs/recommended'] + } +]); \ No newline at end of file diff --git a/composite-envelope-builder/flows/DocuSign_Envelope_Templates.flow-meta.xml b/composite-envelope-builder/flows/DocuSign_Envelope_Templates.flow-meta.xml new file mode 100644 index 0000000..43880ca --- /dev/null +++ b/composite-envelope-builder/flows/DocuSign_Envelope_Templates.flow-meta.xml @@ -0,0 +1,345 @@ + + + + Invoke_DS_Envelope + + 138 + 890 + dfsle__EnvelopeConfigurationBulkRequest + apex + + Iterating_Envelope_Templates + + Automatic + + envelopeConfigurationId + + Iterating_Envelope_Templates.Id + + + + sourceId + + recordId + + + dfsle__EnvelopeConfigurationBulkRequest + + 60.0 + false + + Check_Row_Selection + + 182 + 674 + + Row_not_selected + + Default Outcome + + Is_Row_Selected + and + + data.firstSelectedRow.Id + IsNull + + false + + + + Iterating_Envelope_Templates + + + + + + Is_Language_Selected + + 380 + 242 + + Language_Not_Added_Screen + + Default Outcome + + Language_Selected + and + + Get_Records.Docusign_Envelope_Language__c + IsNull + + false + + + + Language_Warning_Screen + + + + + Default + DocuSign Envelope Templates {!$Flow.CurrentDateTime} + true + + + Iterating_Envelope_Templates + + 50 + 782 + data.selectedRows + Asc + + Invoke_DS_Envelope + + + Success_Screen + + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + Flow + + DocuSign_Envelope_Templates + + 182 + 458 + false + + Envelope_template_records + + and + + Envelope_Template_Language__c + EqualTo + + Get_Records.Docusign_Envelope_Language__c + + + false + dfsle__EnvelopeConfiguration__c + Id + Name + true + + + Get_Records + + 380 + 134 + false + + Is_Language_Selected + + and + + Id + EqualTo + + recordId + + + true + Client_Case__c + Id + Docusign_Envelope_Language__c + true + + + Envelope_template_records + + 182 + 566 + true + true + false + Back + + Check_Row_Selection + + + data + + T + dfsle__EnvelopeConfiguration__c + + flowruntime:datatable + ComponentInstance + + label + + Data Table + + + + selectionMode + + MULTI_SELECT + + + + minRowSelection + + 0.0 + + + + tableData + + DocuSign_Envelope_Templates + + + + columns + + [{"apiName":"Name","guid":"column-6d57","editable":false,"hasCustomHeaderLabel":true,"customHeaderLabel":"Envelope Template Name","wrapText":true,"order":0,"label":"Name","type":"text"}] + + + UseStoredValues + true + true + + + top + + + 12 + + + + Send + true + true + + + Language_Not_Added_Screen + + 578 + 350 + false + true + false + + LanguageNotSelected + <p>The <strong>DocuSign Envelope Language</strong> is not populated on the record. Please add the language first and then proceed.</p> + DisplayText + + + top + + + 12 + + + + true + true + + + Language_Warning_Screen + + 182 + 350 + false + true + false + + DocuSign_Envelope_Templates + + + LangWarningText + <p>The current selected language is <strong>{!Get_Records.Docusign_Envelope_Language__c}. </strong>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 <strong>DocuSign Envelope Language</strong>.</p> + DisplayText + + + top + + + 12 + + + + Next + true + true + + + Row_not_selected + + 314 + 782 + true + true + false + Back + + ErrorMessage + <p><strong style="background-color: rgb(255, 255, 255); color: rgb(68, 68, 68);"><em>You have not selected any of the forms. Please go back and select the form first and then proceed.ο»Ώ</em></strong></p> + DisplayText + + + top + + + 12 + + + + true + false + + + Success_Screen + + 50 + 1082 + false + true + false + + SuccessMessage + <p><span style="font-size: 16px;">Envelope sent successfully.</span></p> + DisplayText + + + top + + + 12 + + + + true + false + + + 254 + 0 + + Get_Records + + + Active + + recordId + String + false + true + false + + diff --git a/composite-envelope-builder/flows/Docusign_Envelope_Templates_V2.flow-meta.xml b/composite-envelope-builder/flows/Docusign_Envelope_Templates_V2.flow-meta.xml new file mode 100644 index 0000000..7479c8a --- /dev/null +++ b/composite-envelope-builder/flows/Docusign_Envelope_Templates_V2.flow-meta.xml @@ -0,0 +1,347 @@ + + + + Invoke_DS_Envelope + + 138 + 890 + dfsle__EnvelopeConfigurationBulkRequest + apex + + Iterating_Envelope_Templates + + Automatic + + envelopeConfigurationId + + Iterating_Envelope_Templates.Id + + + + sourceId + + recordId + + + dfsle__EnvelopeConfigurationBulkRequest + 0 + + 60.0 + false + + Check_Row_Selection + + 182 + 674 + + Row_not_selected + + Default Outcome + + Is_Row_Selected + and + + data.firstSelectedRow.Id + IsNull + + false + + + + Iterating_Envelope_Templates + + + + + + Is_Language_Selected + + 380 + 242 + + Language_Not_Added_Screen + + Default Outcome + + Language_Selected + and + + Get_Records.Docusign_Envelope_Language__c + IsNull + + false + + + + Language_Warning_Screen + + + + + Default + Docusign Envelope Templates V2 {!$Flow.CurrentDateTime} + + + Iterating_Envelope_Templates + + 50 + 782 + data.selectedRows + Asc + + Invoke_DS_Envelope + + + Success_Screen + + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + Flow + + DocuSign_Envelope_Templates + + 182 + 458 + false + + Envelope_template_records + + and + + Envelope_Template_Language__c + EqualTo + + Get_Records.Docusign_Envelope_Language__c + + + false + dfsle__EnvelopeConfiguration__c + Id + Name + Name + Asc + true + + + Get_Records + + 380 + 134 + false + + Is_Language_Selected + + and + + Id + EqualTo + + recordId + + + true + Client_Case__c + Id + Docusign_Envelope_Language__c + true + + + Envelope_template_records + + 182 + 566 + true + true + false + Back + + Check_Row_Selection + + + data + + T + dfsle__EnvelopeConfiguration__c + + flowruntime:datatable + ComponentInstance + + label + + Data Table + + + + selectionMode + + MULTI_SELECT + + + + minRowSelection + + 0.0 + + + + tableData + + DocuSign_Envelope_Templates + + + + columns + + [{"apiName":"Name","guid":"column-6d57","editable":false,"hasCustomHeaderLabel":true,"customHeaderLabel":"Envelope Template Name","wrapText":true,"order":0,"label":"Name","type":"text"}] + + + UseStoredValues + true + true + + + top + + + 12 + + + + Send + true + true + + + Language_Not_Added_Screen + + 578 + 350 + false + true + false + + LanguageNotSelected + <p>The <strong>DocuSign Envelope Language</strong> is not populated on the record. Please add the language first and then proceed.</p> + DisplayText + + + top + + + 12 + + + + true + true + + + Language_Warning_Screen + + 182 + 350 + false + true + false + + DocuSign_Envelope_Templates + + + LangWarningText + <p>The current selected language is <strong>{!Get_Records.Docusign_Envelope_Language__c}. </strong>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 <strong>DocuSign Envelope Language</strong>.</p> + DisplayText + + + top + + + 12 + + + + Next + true + true + + + Row_not_selected + + 314 + 782 + true + true + false + Back + + ErrorMessage + <p><strong style="background-color: rgb(255, 255, 255); color: rgb(68, 68, 68);"><em>You have not selected any of the forms. Please go back and select the form first and then proceed.ο»Ώ</em></strong></p> + DisplayText + + + top + + + 12 + + + + true + false + + + Success_Screen + + 50 + 1082 + false + true + false + + SuccessMessage + <p><span style="font-size: 16px;">Envelope sent successfully.</span></p> + DisplayText + + + top + + + 12 + + + + true + false + + + 254 + 0 + + Get_Records + + + Active + + recordId + String + false + true + false + + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls new file mode 100644 index 0000000..72403aa --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls @@ -0,0 +1,271 @@ +/** + * @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 responseMap = (Map) 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 errorBody = (Map) 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 {} +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls new file mode 100644 index 0000000..aa884c1 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls @@ -0,0 +1,323 @@ +/** + * @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'); + } +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignAPIServiceTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls new file mode 100644 index 0000000..6e176eb --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls @@ -0,0 +1,327 @@ +/** + * @description Main invocable class for combining multiple Docusign templates into a single envelope + * @author Paul Huliganga + * @date 2026-02-23 + */ +global with sharing class DocusignCompositeEnvelopeBuilder { + + /** + * @description Invocable method called from Salesforce Screen Flow + * @param requests List of request objects containing template IDs and metadata + * @return List of result objects with envelope ID and status + */ + @InvocableMethod( + label='Send Composite Docusign Envelope' + description='Combines multiple Docusign templates into a single envelope' + category='Docusign' + ) + public static List sendCompositeEnvelope(List requests) { + List results = new List(); + + // Process first request (Flow only sends one) + if (requests == null || requests.isEmpty()) { + return buildErrorResult('No request provided'); + } + + Request req = requests[0]; + Result result = new Result(); + + try { + // Validate inputs + validateInputs(req); + + // Remove duplicates and sort alphabetically + List sortedTemplateIds = sortTemplatesAlphabetically( + new Set(req.templateIds) + ); + + // Build composite envelope JSON + String envelopeJSON = buildCompositeEnvelopeJSON( + sortedTemplateIds, + req.recordId, + req.language, + req.emailSubject, + null // customFields not supported in InvocableVariable (Phase 2 enhancement) + ); + + // Get Docusign credentials + DocusignCredentials creds = DocusignCredentials.getInstance(); + + // Call Docusign API + String envelopeId = DocusignAPIService.createCompositeEnvelope( + envelopeJSON, + creds + ); + + // Success + result.envelopeId = envelopeId; + result.success = true; + result.errorMessage = null; + + // Log success + logAPICall(req.templateIds.size(), envelopeId, 'Success', null); + + } catch (Exception e) { + // Error handling + result.success = false; + result.errorMessage = e.getMessage(); + result.envelopeId = null; + + // Log error + logAPICall( + req.templateIds != null ? req.templateIds.size() : 0, + null, + 'Error', + e.getMessage() + '\n' + e.getStackTraceString() + ); + + // Re-throw if critical (governor limits) + if (e instanceof System.LimitException) { + throw e; + } + } + + results.add(result); + return results; + } + + /** + * @description Validates input parameters + * @param req Request object to validate + * @throws IllegalArgumentException if validation fails + */ + private static void validateInputs(Request req) { + if (req.templateIds == null || req.templateIds.isEmpty()) { + throw new IllegalArgumentException('At least one template ID is required'); + } + + if (req.templateIds.size() > 14) { + throw new IllegalArgumentException('Maximum 14 templates allowed per envelope'); + } + + if (String.isBlank(req.recordId)) { + throw new IllegalArgumentException('Salesforce record ID is required'); + } + + // Check for null template IDs + for (String templateId : req.templateIds) { + if (String.isBlank(templateId)) { + throw new IllegalArgumentException('Template ID cannot be blank'); + } + } + } + + /** + * @description Removes duplicates and sorts template IDs alphabetically + * @param templateIdSet Set of template IDs + * @return Sorted list of unique template IDs + */ + private static List sortTemplatesAlphabetically(Set templateIdSet) { + List sortedList = new List(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 + */ + @TestVisible + private static String buildCompositeEnvelopeJSON( + List templateIds, + String recordId, + String language, + String emailSubject, + Map customFields + ) { + // Build composite templates array + List compositeTemplates = new List(); + + Integer sequence = 1; + for (String templateId : templateIds) { + Map compositeTemplate = new Map{ + 'compositeTemplateId' => String.valueOf(sequence), + 'serverTemplates' => new List{ + new Map{ + '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 envelope = new Map{ + '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 buildInlineTemplates( + String recordId, + String language, + Map customFields + ) { + List textCustomFields = new List(); + + // Add Salesforce record ID + if (String.isNotBlank(recordId)) { + textCustomFields.add(new Map{ + 'name' => 'SalesforceRecordId', + 'value' => recordId, + 'show' => 'false', + 'required' => 'false' + }); + } + + // Add language + if (String.isNotBlank(language)) { + textCustomFields.add(new Map{ + '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{ + 'name' => fieldName, + 'value' => customFields.get(fieldName), + 'show' => 'false', + 'required' => 'false' + }); + } + } + + return new List{ + new Map{ + 'sequence' => '1', + 'customFields' => new Map{ + 'textCustomFields' => textCustomFields + } + } + }; + } + + /** + * @description Logs API call to debug log (future: custom object) + * @param templateCount Number of templates in envelope + * @param envelopeId Docusign envelope ID + * @param status Success or Error + * @param errorMessage Error message if applicable + */ + private static void logAPICall( + Integer templateCount, + String envelopeId, + String status, + String errorMessage + ) { + System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope API Call ==='); + System.debug(LoggingLevel.INFO, 'Timestamp: ' + System.now()); + System.debug(LoggingLevel.INFO, 'Template Count: ' + templateCount); + System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId); + System.debug(LoggingLevel.INFO, 'Status: ' + status); + if (String.isNotBlank(errorMessage)) { + System.debug(LoggingLevel.ERROR, 'Error: ' + errorMessage); + } + + // Future enhancement: Insert into Docusign_API_Log__c custom object + } + + /** + * @description Helper to build error result + * @param errorMessage Error message + * @return List containing single error result + */ + private static List buildErrorResult(String errorMessage) { + Result result = new Result(); + result.success = false; + result.errorMessage = errorMessage; + result.envelopeId = null; + return new List{ 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 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; + } +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls new file mode 100644 index 0000000..2dca219 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls @@ -0,0 +1,312 @@ +/** + * @description Test class for DocusignCompositeEnvelopeBuilder + * @author Paul Huliganga + * @date 2026-02-23 + */ +@isTest +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 + static void testSuccessfulEnvelopeCreation() { + // Arrange + String envelopeId = 'envelope-12345-abcde'; + String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}'; + Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); + + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{'template-1', 'template-2', 'template-3'}; + req.recordId = '001000000ABC123'; + req.language = 'en'; + req.emailSubject = 'Test Envelope'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(1, results.size(), 'Should return 1 result'); + System.assertEquals(true, results[0].success, 'Should be successful'); + System.assertEquals(envelopeId, results[0].envelopeId, 'Should return envelope ID'); + System.assertEquals(null, results[0].errorMessage, 'Should have no error message'); + } + + /** + * @description Test with single template (minimum) + */ + @isTest + static void testSingleTemplate() { + // Arrange + String mockResponse = '{"envelopeId":"envelope-single","status":"sent"}'; + Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); + + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{'template-1'}; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should be successful with 1 template'); + } + + /** + * @description Test with 14 templates (maximum) + */ + @isTest + static void testMaximumTemplates() { + // Arrange + String mockResponse = '{"envelopeId":"envelope-max","status":"sent"}'; + Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); + + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + List templateIds = new List(); + for (Integer i = 1; i <= 14; i++) { + templateIds.add('template-' + i); + } + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = templateIds; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should be successful with 14 templates'); + } + + /** + * @description Test with duplicate template IDs (should be deduplicated) + */ + @isTest + static void testDuplicateTemplates() { + // Arrange + String mockResponse = '{"envelopeId":"envelope-dedup","status":"sent"}'; + Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse)); + + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{'template-1', 'template-2', 'template-1'}; // Duplicate + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should handle duplicates'); + } + + // Custom fields test removed - not supported in InvocableVariable (Phase 2 enhancement) + + /** + * @description Test validation failure - no template IDs + */ + @isTest + static void testValidationNoTemplates() { + // Arrange + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List(); // Empty + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Error should mention templates'); + } + + /** + * @description Test validation failure - too many templates + */ + @isTest + static void testValidationTooManyTemplates() { + // Arrange + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + List templateIds = new List(); + for (Integer i = 1; i <= 15; i++) { + templateIds.add('template-' + i); + } + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = templateIds; // 15 templates (> max of 14) + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.contains('Maximum 14'), 'Error should mention limit'); + } + + /** + * @description Test validation failure - no record ID + */ + @isTest + static void testValidationNoRecordId() { + // Arrange + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{'template-1'}; + req.recordId = ''; // Blank + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.contains('record ID'), 'Error should mention record ID'); + } + + /** + * @description Test API error - 400 Bad Request + */ + @isTest + static void testAPIError400() { + // Arrange + String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}'; + Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse)); + + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{'invalid-template'}; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail with API error'); + System.assert(results[0].errorMessage.contains('400'), 'Error should include status code'); + } + + /** + * @description Test API error - 401 Unauthorized + */ + @isTest + static void testAPIError401() { + // Arrange + String mockResponse = '{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid token"}'; + Test.setMock(HttpCalloutMock.class, new DocusignMock(401, mockResponse)); + + DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'invalid-token'); + + DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request(); + req.templateIds = new List{'template-1'}; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail with auth error'); + System.assertNotEquals(null, results[0].errorMessage, 'Should have error message'); + System.assert(String.isNotBlank(results[0].errorMessage), 'Error message should not be blank'); + } + + /** + * @description Test JSON builder with all parameters + */ + @isTest + static void testJSONBuilder() { + // Arrange + List templateIds = new List{'template-A', 'template-B'}; + String recordId = '001000000ABC123'; + String language = 'es'; + String emailSubject = 'Custom Subject'; + + // Act + Test.startTest(); + String json = DocusignCompositeEnvelopeBuilder.buildCompositeEnvelopeJSON( + templateIds, + recordId, + language, + emailSubject, + null // customFields - not supported in Phase 1 + ); + Test.stopTest(); + + // Assert + System.assertNotEquals(null, json, 'JSON should be generated'); + System.assert(json.contains('template-A'), 'Should contain first template'); + System.assert(json.contains('template-B'), 'Should contain second template'); + System.assert(json.contains(emailSubject), 'Should contain email subject'); + System.assert(json.contains('SalesforceRecordId'), 'Should contain record ID field'); + System.assert(json.contains(language), 'Should contain language'); + } +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls new file mode 100644 index 0000000..6a7015e --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls @@ -0,0 +1,187 @@ +/** + * @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 {} +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentials.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls new file mode 100644 index 0000000..03376d1 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls @@ -0,0 +1,258 @@ +/** + * @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:'); + } +} diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCredentialsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/Docusign_Configuration__c.object-meta.xml b/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/Docusign_Configuration__c.object-meta.xml new file mode 100644 index 0000000..167fe9b --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/Docusign_Configuration__c.object-meta.xml @@ -0,0 +1,8 @@ + + + Hierarchy + Configuration for Docusign API integration + false + + Public + diff --git a/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Account_Id__c.field-meta.xml b/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Account_Id__c.field-meta.xml new file mode 100644 index 0000000..b786ed3 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Account_Id__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Account_Id__c + Docusign Account ID (GUID) + false + + 255 + false + false + Text + false + diff --git a/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Base_URL__c.field-meta.xml b/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Base_URL__c.field-meta.xml new file mode 100644 index 0000000..9d2c2e8 --- /dev/null +++ b/composite-envelope-builder/force-app/main/default/objects/Docusign_Configuration__c/fields/Base_URL__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Base_URL__c + Docusign API Base URL (e.g., callout:DocusignAPI or https://na3.docusign.net/restapi/v2.1) + false + + 255 + false + false + Text + false + diff --git a/composite-envelope-builder/jest.config.js b/composite-envelope-builder/jest.config.js new file mode 100644 index 0000000..f5a9fed --- /dev/null +++ b/composite-envelope-builder/jest.config.js @@ -0,0 +1,6 @@ +const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); + +module.exports = { + ...jestConfig, + modulePathIgnorePatterns: ['/.localdevserver'] +}; diff --git a/composite-envelope-builder/package.json b/composite-envelope-builder/package.json new file mode 100644 index 0000000..0d77cdc --- /dev/null +++ b/composite-envelope-builder/package.json @@ -0,0 +1,44 @@ +{ + "name": "salesforce-app", + "private": true, + "version": "1.0.0", + "description": "Salesforce App", + "scripts": { + "lint": "eslint **/{aura,lwc}/**/*.js", + "test": "npm run test:unit", + "test:unit": "sfdx-lwc-jest", + "test:unit:watch": "sfdx-lwc-jest --watch", + "test:unit:debug": "sfdx-lwc-jest --debug", + "test:unit:coverage": "sfdx-lwc-jest --coverage", + "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", + "prettier:verify": "prettier --check \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", + "prepare": "husky || true", + "precommit": "lint-staged" + }, + "devDependencies": { + "@lwc/eslint-plugin-lwc": "^3.1.0", + "@prettier/plugin-xml": "^3.4.1", + "@salesforce/eslint-config-lwc": "^4.0.0", + "@salesforce/eslint-plugin-aura": "^3.0.0", + "@salesforce/eslint-plugin-lightning": "^2.0.0", + "@salesforce/sfdx-lwc-jest": "^7.0.2", + "eslint": "^9.29.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^28.14.0", + "husky": "^9.1.7", + "lint-staged": "^16.1.2", + "prettier": "^3.5.3", + "prettier-plugin-apex": "^2.2.6" + }, + "lint-staged": { + "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ + "prettier --write" + ], + "**/{aura,lwc}/**/*.js": [ + "eslint" + ], + "**/lwc/**": [ + "sfdx-lwc-jest -- --bail --findRelatedTests --passWithNoTests" + ] + } +} diff --git a/composite-envelope-builder/scripts/apex/hello.apex b/composite-envelope-builder/scripts/apex/hello.apex new file mode 100644 index 0000000..1fba732 --- /dev/null +++ b/composite-envelope-builder/scripts/apex/hello.apex @@ -0,0 +1,10 @@ +// Use .apex files to store anonymous Apex. +// You can execute anonymous Apex in VS Code by selecting the +// apex text and running the command: +// SFDX: Execute Anonymous Apex with Currently Selected Text +// You can also execute the entire file by running the command: +// SFDX: Execute Anonymous Apex with Editor Contents + +string tempvar = 'Enter_your_name_here'; +System.debug('Hello World!'); +System.debug('My name is ' + tempvar); \ No newline at end of file diff --git a/composite-envelope-builder/scripts/soql/account.soql b/composite-envelope-builder/scripts/soql/account.soql new file mode 100644 index 0000000..10d4b9c --- /dev/null +++ b/composite-envelope-builder/scripts/soql/account.soql @@ -0,0 +1,6 @@ +// Use .soql files to store SOQL queries. +// You can execute queries in VS Code by selecting the +// query text and running the command: +// SFDX: Execute SOQL Query with Currently Selected Text + +SELECT Id, Name FROM Account diff --git a/composite-envelope-builder/sfdx-project.json b/composite-envelope-builder/sfdx-project.json new file mode 100644 index 0000000..1577f10 --- /dev/null +++ b/composite-envelope-builder/sfdx-project.json @@ -0,0 +1,12 @@ +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + } + ], + "name": "composite-envelope-builder", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "65.0" +}