SuiteScript Overview
Understand NetSuite's JavaScript-based scripting platform, API versions, script types, governance model, and when to use code vs. configuration for business requirements.
What is SuiteScript?
SuiteScript is NetSuite's JavaScript-based API that enables developers to extend and customize the platform beyond its native capabilities. It provides programmatic access to NetSuite data, business logic, and user interface components.
- SuiteScript 1.0: Legacy API, still supported but no longer enhanced
- SuiteScript 2.0: Modern module-based API (recommended for all new development)
- SuiteScript 2.1: ES2019+ JavaScript features (async/await, arrow functions)
All new scripts should use SuiteScript 2.1 unless specific compatibility requirements exist.
When to Use SuiteScript
Before writing code, evaluate whether your requirement can be met through configuration. SuiteScript should be used when native features are insufficient.
| Requirement | Configuration Option | When SuiteScript Needed |
|---|---|---|
| Field validation | Mandatory fields, validation rules | Complex cross-field logic, external validation |
| Auto-populate fields | Sourcing, default values, formulas | Complex calculations, external lookups |
| Approval workflows | SuiteFlow workflows | Complex routing, external system integration |
| Scheduled tasks | Saved search alerts, reminders | Data processing, integration syncs |
| Custom UI | Custom forms, custom records | Custom pages, portlets, dynamic interfaces |
| Integrations | Native connectors (if available) | Custom APIs, bidirectional sync |
Always try configuration before coding. Native features are:
- Maintained by NetSuite (automatic upgrades)
- Better documented and supported
- No governance consumption
- Easier for administrators to maintain
Script Types Overview
| Script Type | Execution Context | Primary Use Cases |
|---|---|---|
| Client Script | Browser (user's machine) | Field validation, UI behavior, real-time calculations |
| User Event Script | Server (on record save/load) | Data validation, auto-population, transformations |
| Scheduled Script | Server (time-based) | Batch processing, data cleanup, integrations |
| Map/Reduce Script | Server (parallel processing) | Large data sets, complex transformations |
| Suitelet | Server (on-demand page) | Custom UI pages, wizards, internal tools |
| Restlet | Server (HTTP endpoint) | REST APIs, external integrations |
Governance Model
NetSuite uses a governance system to ensure fair resource allocation. Each script execution is allocated governance units, and API calls consume these units.
| Script Type | Governance Units |
|---|---|
| Client Script | 1,000 units |
| User Event Script | 1,000 units |
| Suitelet | 1,000 units |
| Restlet | 5,000 units |
| Scheduled Script | 10,000 units |
| Map/Reduce Script | 10,000 units per phase |
Common API Governance Costs
Operation Units
--------------------------------------------------
record.load() 10
record.save() 20
record.delete() 20
search.create().run() 10
search.lookupFields() 1
email.send() 20
http.request() 10
file.load() 10 Script Deployment
Every script requires two components: the Script record and the Script Deployment record.
Upload Script File
Upload .js file to File Cabinet (SuiteScripts folder recommended)
Create Script Record
Select script file, configure script parameters if needed
Create Deployment
Link script to record types/contexts, set execution roles
Set Status to Released
Change deployment status from Testing to Released for production
SuiteScript 2.x Module System
SuiteScript 2.x uses AMD (Asynchronous Module Definition) pattern for organizing code:
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @NModuleScope SameAccount
*/
define(['N/record', 'N/search', 'N/log'], (record, search, log) => {
const beforeLoad = (context) => {
log.debug('Before Load', `Record ID: ${context.newRecord.id}`);
};
const afterSubmit = (context) => {
// Runs after record is saved to database
};
return { beforeLoad, afterSubmit };
}); Key Modules
| Module | Purpose | Common Methods |
|---|---|---|
N/record | CRUD operations on records | load, create, copy, transform, delete |
N/search | Saved searches and lookups | create, load, lookupFields |
N/query | SuiteQL queries | runSuiteQL, runSuiteQLPaged |
N/log | Script logging | debug, audit, error, emergency |
N/email | Send emails | send, sendBulk |
N/file | File Cabinet operations | load, create, delete |
N/http / N/https | HTTP/HTTPS requests | get, post, put, delete, request |
N/runtime | Runtime information | getCurrentUser, getCurrentScript |
N/ui/serverWidget | UI components (Suitelets) | createForm, createList |
N/task | Trigger scheduled scripts | create (MapReduceTask, ScheduledTask) |
Development Best Practices
- Use SuiteCloud IDE: Eclipse-based or VS Code extension for syntax checking, upload, debugging
- Test in sandbox: Never develop directly in production
- Log strategically: Use log.debug() during development, minimize in production
- Handle errors: Always wrap code in try/catch blocks
- Monitor governance: Check remaining units for long-running scripts
- Use script parameters: Externalize configuration for flexibility
- Document: JSDoc comments, README files, version history
const afterSubmit = (context) => {
try {
const customerId = context.newRecord.getValue('entity');
if (!customerId) {
log.error('Validation Error', 'Customer is required');
return;
}
// Process logic...
log.audit('Success', `Processed customer ${customerId}`);
} catch (error) {
log.error({
title: 'Script Error',
details: `Error: ${error.message}\nStack: ${error.stack}`
});
}
}; SuiteCloud Development Framework (SDF)
SDF enables source control, automated deployment, and team collaboration for NetSuite customizations:
- Version control: Track all customizations in Git
- Automated deployment: CLI commands for sandbox/production releases
- Team collaboration: Multiple developers working on same project
- Object dependencies: Automatic ordering of deployment objects
- Account customization projects (ACP): Deploy scripts, records, forms together
SuiteScript Readiness Checklist
User Event Scripts
Master server-side scripts that execute on record load, before save, and after save—the workhorses of NetSuite automation.
What Are User Event Scripts?
User Event scripts run on the server whenever a record is loaded, created, edited, or deleted. They execute automatically with record operations—no user action required.
- beforeLoad: Runs before record is displayed (view/edit modes)
- beforeSubmit: Runs before record is saved to database
- afterSubmit: Runs after record is saved to database
Entry Point Details
beforeLoad
Executes before the record form is displayed. Use for UI modifications.
| Use Case | Example |
|---|---|
| Add custom buttons | Add "Generate PDF" button to sales order |
| Hide/show fields | Hide discount field for non-managers |
| Set field defaults | Default ship date to tomorrow |
| Add sublist columns | Add calculated column to item sublist |
beforeSubmit
Executes before the record is written to the database. Perfect for validation and data transformation.
| Use Case | Example |
|---|---|
| Data validation | Ensure margin meets minimum threshold |
| Data transformation | Uppercase customer name |
| Auto-populate fields | Set approval status based on amount |
| Block saves | Prevent duplicate PO numbers |
afterSubmit
Executes after the record is saved. Use for operations that require the saved record ID.
| Use Case | Example |
|---|---|
| Create related records | Create task when opportunity closes |
| Send notifications | Email customer when order ships |
| Update other records | Update customer status based on orders |
| External integrations | Push data to external CRM |
Context Object
The context parameter provides essential information about the execution:
| Property | Description | Available In |
|---|---|---|
context.newRecord | Current record being processed | All entry points |
context.oldRecord | Record before changes (edit/delete only) | beforeSubmit, afterSubmit |
context.type | Operation type (CREATE, EDIT, DELETE, etc.) | All entry points |
context.form | Form object for UI modifications | beforeLoad only |
Comparing Old and New Values
In beforeSubmit and afterSubmit, compare old and new record values to detect changes:
const beforeSubmit = (context) => {
if (context.type !== context.UserEventType.EDIT) return;
const oldRec = context.oldRecord;
const newRec = context.newRecord;
const oldStatus = oldRec.getValue('status');
const newStatus = newRec.getValue('status');
if (oldStatus !== newStatus) {
log.audit('Status Changed', `From ${oldStatus} to ${newStatus}`);
if (newStatus === 'approved' && oldStatus !== 'approved') {
// Trigger approval notification
}
}
}; Execution Context
User Event scripts execute in different contexts. Use runtime module to detect source:
define(['N/runtime', 'N/log'], (runtime, log) => {
const beforeSubmit = (context) => {
const execContext = runtime.executionContext;
// Log the execution context
log.debug('Execution Context', execContext);
// Skip if running from CSV import
if (execContext === runtime.ContextType.CSV_IMPORT) {
log.audit('Skipped', 'Running from CSV import');
return;
}
// Skip if running from another script
if (execContext === runtime.ContextType.SCHEDULED ||
execContext === runtime.ContextType.MAP_REDUCE) {
log.audit('Skipped', 'Running from scheduled processing');
return;
}
// Continue with validation for UI and web service contexts
// ...
};
return { beforeSubmit };
}); Common Execution Contexts
| Context | Description |
|---|---|
USER_INTERFACE | Record edited through NetSuite UI |
CSV_IMPORT | CSV Import operation |
WEBSERVICES | Web Services (SOAP) |
RESTLET | RESTlet API call |
SCHEDULED | Scheduled script |
MAP_REDUCE | Map/Reduce script |
SUITELET | Suitelet execution |
WORKFLOW | Workflow action |
Best Practices
- Always check context.type: Don't run validation on DELETE or logic on VIEW
- Handle errors gracefully: Use try/catch and log errors for debugging
- Minimize governance: Avoid record.load() when context.newRecord suffices
- Consider all entry points: Remember scripts run on CSV import, web services, etc.
- Use afterSubmit for external calls: Don't block saves with slow API calls
- Validate in beforeSubmit: Throw errors to prevent invalid data
- Infinite loops: afterSubmit that edits the same record triggers another afterSubmit
- Blocking on external APIs: Slow beforeSubmit makes saves timeout
- Not handling null: Fields may be empty; always check before operations
- Ignoring oldRecord: Always compare to detect actual changes vs. re-saves
- Over-logging: Excessive log.debug() in production wastes resources
Preventing Infinite Loops
define(['N/record', 'N/runtime', 'N/log'], (record, runtime, log) => {
const afterSubmit = (context) => {
// Method 1: Check execution context
if (runtime.executionContext === runtime.ContextType.USER_EVENT) {
log.debug('Skipped', 'Avoiding recursive call');
return;
}
// Method 2: Use a flag field
const rec = context.newRecord;
if (rec.getValue('custbody_processed_flag')) {
return; // Already processed
}
// Method 3: Track in script parameter or global
const scriptObj = runtime.getCurrentScript();
const processedIds = scriptObj.getParameter('custscript_processed') || '';
if (processedIds.includes(rec.id)) {
return;
}
// Safe to update
record.submitFields({
type: rec.type,
id: rec.id,
values: {
'custbody_processed_flag': true,
'custbody_processed_date': new Date()
}
});
};
return { afterSubmit };
}); Deployment Configuration
| Setting | Recommendation |
|---|---|
| Applies To | Select specific record types; avoid "All Records" |
| Execute As Role | Use dedicated role with minimum required permissions |
| Log Level | DEBUG during testing, ERROR in production |
| Status | Testing until fully validated, then Released |
| All Employees | Usually yes; restrict only if role-specific logic needed |
User Event Script Checklist
Client Scripts
Browser-side scripts that enhance user experience with real-time validation, field manipulation, and dynamic form behavior.
Client Script Overview
Client Scripts execute in the user's browser when interacting with NetSuite forms. They provide immediate feedback, enforce business rules before submission, and create dynamic user experiences.
Client Script Entry Points
| Entry Point | Trigger | Common Uses |
|---|---|---|
pageInit | Form loads in browser | Initialize field values, hide/show fields |
fieldChanged | User changes field value | Dynamic sourcing, conditional logic |
postSourcing | After sourcing completes | Override sourced values |
sublistChanged | Line added/removed/changed | Sublist totals, line validation |
validateField | Before field value commits | Field-level validation |
saveRecord | Before form submits | Final validation, confirmation dialogs |
Client Script vs. User Event
Client Script Template
A comprehensive Client Script structure with all entry points:
/**
* @NApiVersion 2.1
* @NScriptType ClientScript
* @NModuleScope SameAccount
*/
define(['N/currentRecord', 'N/dialog', 'N/runtime', 'N/search'],
(currentRecord, dialog, runtime, search) => {
/**
* Page initialization - runs when form loads
* @param {Object} context
* @param {Record} context.currentRecord - Current form record
* @param {string} context.mode - create, edit, copy, or view
*/
const pageInit = (context) => {
const rec = context.currentRecord;
const mode = context.mode;
console.log(`Page initialized in ${mode} mode`);
// Set default values on create
if (mode === 'create') {
rec.setValue({
fieldId: 'custbody_created_by',
value: runtime.getCurrentUser().id
});
}
// Disable fields based on status
if (mode === 'edit') {
const status = rec.getValue('status');
if (status === 'approved') {
// Use jQuery to disable field (standard approach)
jQuery('#custbody_amount').attr('disabled', true);
}
}
};
/**
* Field change handler
* @param {Object} context
* @param {Record} context.currentRecord
* @param {string} context.sublistId - Sublist internal ID (if applicable)
* @param {string} context.fieldId - Changed field's internal ID
* @param {number} context.line - Line index (if sublist)
*/
const fieldChanged = (context) => {
const rec = context.currentRecord;
const fieldId = context.fieldId;
const sublistId = context.sublistId;
// Body field change
if (!sublistId && fieldId === 'entity') {
const customerId = rec.getValue('entity');
if (customerId) {
lookupCustomerDefaults(rec, customerId);
}
}
// Sublist field change
if (sublistId === 'item' && fieldId === 'quantity') {
calculateLineAmount(rec, context.line);
}
};
/**
* Post-sourcing handler - after NetSuite finishes sourcing
* @param {Object} context
*/
const postSourcing = (context) => {
const rec = context.currentRecord;
const fieldId = context.fieldId;
const sublistId = context.sublistId;
// Override sourced price for specific customer
if (sublistId === 'item' && fieldId === 'item') {
const customerId = rec.getValue('entity');
const itemId = rec.getCurrentSublistValue({
sublistId: 'item',
fieldId: 'item'
});
// Look up special pricing
const specialPrice = getSpecialPrice(customerId, itemId);
if (specialPrice) {
rec.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'rate',
value: specialPrice
});
}
}
};
/**
* Line initialization
* @param {Object} context
*/
const lineInit = (context) => {
const rec = context.currentRecord;
const sublistId = context.sublistId;
if (sublistId === 'item') {
// Default quantity to 1 for new lines
rec.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'quantity',
value: 1
});
}
};
/**
* Field validation - return false to reject change
* @param {Object} context
* @returns {boolean}
*/
const validateField = (context) => {
const rec = context.currentRecord;
const fieldId = context.fieldId;
const sublistId = context.sublistId;
// Validate discount percentage
if (sublistId === 'item' && fieldId === 'custcol_discount_pct') {
const discount = rec.getCurrentSublistValue({
sublistId: 'item',
fieldId: 'custcol_discount_pct'
});
if (discount > 25) {
dialog.alert({
title: 'Discount Limit',
message: 'Discount cannot exceed 25%. Contact management for approval.'
});
return false; // Reject the change
}
}
return true; // Accept the change
};
/**
* Line validation - return false to reject line commit
* @param {Object} context
* @returns {boolean}
*/
const validateLine = (context) => {
const rec = context.currentRecord;
const sublistId = context.sublistId;
if (sublistId === 'item') {
const quantity = rec.getCurrentSublistValue({
sublistId: 'item',
fieldId: 'quantity'
});
const rate = rec.getCurrentSublistValue({
sublistId: 'item',
fieldId: 'rate'
});
if (!quantity || quantity <= 0) {
dialog.alert({
title: 'Invalid Quantity',
message: 'Quantity must be greater than zero.'
});
return false;
}
if (!rate || rate < 0) {
dialog.alert({
title: 'Invalid Rate',
message: 'Rate cannot be negative.'
});
return false;
}
}
return true;
};
/**
* Delete validation
* @param {Object} context
* @returns {boolean}
*/
const validateDelete = (context) => {
const rec = context.currentRecord;
const sublistId = context.sublistId;
if (sublistId === 'item') {
const lineCount = rec.getLineCount({ sublistId: 'item' });
if (lineCount <= 1) {
dialog.alert({
title: 'Cannot Delete',
message: 'Transaction must have at least one line item.'
});
return false;
}
}
return true;
};
/**
* Sublist changed - after line commit or delete
* @param {Object} context
*/
const sublistChanged = (context) => {
const rec = context.currentRecord;
const sublistId = context.sublistId;
if (sublistId === 'item') {
updateHeaderTotals(rec);
}
};
/**
* Save validation - return false to prevent save
* @param {Object} context
* @returns {boolean}
*/
const saveRecord = (context) => {
const rec = context.currentRecord;
// Validate required custom fields
const projectCode = rec.getValue('custbody_project_code');
if (!projectCode) {
dialog.alert({
title: 'Missing Required Field',
message: 'Please enter a Project Code before saving.'
});
return false;
}
// Confirm large orders
const total = rec.getValue('total');
if (total > 100000) {
// Note: dialog.confirm returns a Promise in 2.1
// For synchronous behavior, use window.confirm
if (!window.confirm('Order total exceeds $100,000. Continue?')) {
return false;
}
}
return true;
};
// Helper functions
const lookupCustomerDefaults = (rec, customerId) => {
const customerLookup = search.lookupFields({
type: search.Type.CUSTOMER,
id: customerId,
columns: ['custentity_default_terms', 'custentity_sales_rep']
});
// Apply defaults...
};
const calculateLineAmount = (rec, line) => {
// Calculate logic...
};
const getSpecialPrice = (customerId, itemId) => {
// Price lookup logic...
return null;
};
const updateHeaderTotals = (rec) => {
// Recalculate header...
};
return {
pageInit,
fieldChanged,
postSourcing,
lineInit,
validateField,
validateLine,
validateDelete,
sublistChanged,
saveRecord
};
}); Entry Point Execution Order
Understanding the sequence helps debug and design scripts:
- pageInit — Form loads
- fieldChanged — User edits field
- postSourcing — Sourcing completes
- validateField — Before value is set
- lineInit — Select new sublist line
- validateLine — Commit line
- sublistChanged — Line committed
- saveRecord — Save clicked
Client-Side vs Server-Side Validation
| Aspect | Client Script | User Event Script |
|---|---|---|
| Execution | Browser (JavaScript) | NetSuite server |
| Triggered by | UI interaction only | UI, CSV imports, web services, scripts |
| Response time | Immediate (no server round-trip) | After form submission |
| Bypassable? | Yes (browser tools, API calls) | No (runs on server) |
| Best for | UX improvement, non-critical validation | Critical business rules, data integrity |
Common Client Script Patterns
Dynamic Field Show/Hide
const fieldChanged = (context) => {
if (context.fieldId === 'custbody_payment_type') {
const rec = context.currentRecord;
const paymentType = rec.getValue('custbody_payment_type');
const ccField = rec.getField({ fieldId: 'custbody_cc_number' });
const checkField = rec.getField({ fieldId: 'custbody_check_number' });
ccField.isDisplay = (paymentType === 'credit_card');
checkField.isDisplay = (paymentType === 'check');
}
}; Calculated Fields
const fieldChanged = (context) => {
if (context.fieldId === 'custbody_unit_cost' || context.fieldId === 'custbody_sale_price') {
const rec = context.currentRecord;
const cost = rec.getValue('custbody_unit_cost') || 0;
const price = rec.getValue('custbody_sale_price') || 0;
const margin = price > 0 ? ((price - cost) / price * 100).toFixed(2) : 0;
rec.setValue({ fieldId: 'custbody_margin_pct', value: margin, ignoreFieldChange: true });
}
}; Cascading Dropdowns
const fieldChanged = (context) => {
const rec = context.currentRecord;
if (context.fieldId === 'custbody_country') {
const countryId = rec.getValue('custbody_country');
// Clear dependent field
rec.setValue({
fieldId: 'custbody_state',
value: ''
});
// Rebuild state dropdown based on country
const stateField = rec.getField({ fieldId: 'custbody_state' });
// Remove existing options (except blank)
stateField.removeSelectOption({ value: null }); // Clear all
stateField.insertSelectOption({ value: '', text: '-- Select --' });
// Lookup states for selected country
const states = lookupStates(countryId);
states.forEach(state => {
stateField.insertSelectOption({
value: state.id,
text: state.name
});
});
}
}; Sublist Totals in Real-Time
const sublistChanged = (context) => {
if (context.sublistId === 'item') {
calculateTotals(context.currentRecord);
}
};
const calculateTotals = (rec) => {
const lineCount = rec.getLineCount({ sublistId: 'item' });
let totalQty = 0;
let totalWeight = 0;
for (let i = 0; i < lineCount; i++) {
totalQty += rec.getSublistValue({
sublistId: 'item',
fieldId: 'quantity',
line: i
}) || 0;
totalWeight += rec.getSublistValue({
sublistId: 'item',
fieldId: 'custcol_weight',
line: i
}) || 0;
}
// Update header fields
rec.setValue({ fieldId: 'custbody_total_qty', value: totalQty });
rec.setValue({ fieldId: 'custbody_total_weight', value: totalWeight });
// Update shipping estimate based on weight
const shippingRate = getShippingRate(totalWeight);
rec.setValue({ fieldId: 'custbody_est_shipping', value: shippingRate });
}; Debugging Client Scripts
Client Scripts run in the browser--use developer tools (F12) for debugging:
- Console:
console.log()statements appear here - Sources: Set breakpoints, step through code
- Network: Monitor AJAX calls from N/https or N/search
// Debugging techniques
const fieldChanged = (context) => {
// Log context for debugging
console.log('fieldChanged triggered');
console.log('Field ID:', context.fieldId);
console.log('Sublist ID:', context.sublistId);
console.log('Line:', context.line);
// Log current values
const rec = context.currentRecord;
console.log('Customer:', rec.getValue('entity'));
console.log('Total:', rec.getValue('total'));
// Inspect record structure
console.log('Record object:', rec);
}; Client Script Deployment
| Setting | Description | Best Practice |
|---|---|---|
| Applies To | Record types the script monitors | Limit to specific records needed |
| Form | Specific form or All | Target specific forms when possible |
| Status | Released, Testing, Not Scheduled | Use Testing in Sandbox |
| Log Level | Debug, Audit, Error | Debug in Sandbox, Audit in Production |
Performance Considerations
ignoreFieldChange to prevent recursive fieldChanged calls when setting values programmatically.
Industry Patterns
Client Script Checklist
Scheduled Scripts
Time-based server scripts for batch processing, data maintenance, and scheduled integrations.
Scheduled Script Overview
Scheduled Scripts run on a defined schedule (daily, hourly, or specific times) to perform background processing without user interaction. They're ideal for batch operations, data cleanup, and scheduled integrations.
Common Use Cases
| Use Case | Schedule | Description |
|---|---|---|
| Invoice Generation | Daily, early morning | Create invoices from fulfilled orders |
| Data Cleanup | Weekly, off-hours | Archive old records, clean temp data |
| Integration Sync | Every 15-30 minutes | Pull/push data from external systems |
| Report Generation | Daily/Weekly | Generate and email reports |
| Reminder Emails | Daily | Send overdue invoice reminders |
Scheduled Script Entry Point
Scheduled Scripts have a single entry point that receives execution context:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/search', 'N/record', 'N/runtime', 'N/log'], (search, record, runtime, log) => {
const execute = (context) => {
log.audit('Script Start', `Type: ${context.type}`);
try {
const script = runtime.getCurrentScript();
mySearch.run().each((result) => {
if (script.getRemainingUsage() < 100) return false;
processRecord(result);
return true;
});
} catch (e) {
log.error('Script Error', e.message);
}
};
return { execute };
}); Execution Types
| Type | Description | Use Case |
|---|---|---|
SCHEDULED | Triggered by schedule | Regular automated processing |
ON_DEMAND | Manually via "Save and Execute" | Testing, one-time runs |
USER_INTERFACE | Triggered from another script | Queued from User Event or Suitelet |
ABORTED | Previous execution was aborted | Recovery logic, cleanup |
SKIPPED | Previous execution was skipped | Catch-up processing |
Scheduling Options
| Schedule Type | Options | Example Use Case |
|---|---|---|
| Single Execution | Specific date and time | One-time data cleanup |
| Daily | Every day at specified time | Nightly report generation |
| Weekly | Specific days of the week | Monday morning KPI emails |
| Monthly | Day of month or first/last weekday | Month-end close tasks |
| Repeat | Every N minutes (15, 30, 60) | Integration polling |
Governance Management
Scheduled Scripts must manage their 10,000-unit budget carefully:
const processRecords = () => {
const script = runtime.getCurrentScript();
const RESERVE_UNITS = 500;
mySearch.run().each((result) => {
if (script.getRemainingUsage() < RESERVE_UNITS) {
log.audit('Governance Limit', 'Stopping - reschedule needed');
rescheduleScript();
return false;
}
processRecord(result);
return true;
});
}; Script Parameters
Use script parameters to pass configuration and maintain state across executions:
const script = runtime.getCurrentScript();
const batchSize = script.getParameter({ name: 'custscript_batch_size' }) || 1000;
const emailRecipient = script.getParameter({ name: 'custscript_notify_email' });
const lastProcessedId = script.getParameter({ name: 'custscript_last_id' }) || 0; Triggering Scheduled Scripts Programmatically
Queue Scheduled Scripts from other scripts using the N/task module:
const queueProcessingScript = (recordId) => {
try {
const scheduledTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: 'customscript_process_order',
deploymentId: 'customdeploy_process_order_queue',
params: { 'custscript_order_id': recordId }
});
scheduledTask.submit();
} catch (e) {
if (e.name === 'FAILED_TO_SUBMIT_JOB_REQUEST_1') {
log.debug('Already Queued', 'Script already pending');
} else { throw e; }
}
}; Checking Script Status
const taskStatus = task.checkStatus({ taskId: taskId });
// Possible statuses: PENDING, PROCESSING, COMPLETE, FAILED
log.debug('Status', taskStatus.status); Common Patterns
Batch Email Sender
Process email queue records in batches with governance checks and error handling per record.
Data Synchronization
Get records modified since last sync, push to external system, save checkpoint for next run.
Report Generation
Generate report data, create CSV/PDF file in File Cabinet, email to recipients.
Deployment Best Practices
| Setting | Recommendation | Rationale |
|---|---|---|
| Status | Testing then Released | Test thoroughly before releasing |
| Log Level | Audit (Production) | Capture key events without noise |
| Execute As Role | Dedicated script role | Controlled permissions |
| Queue Limit | Set based on processing time | Prevent queue overflow |
| Yield Handling | Enable for long processes | Allow recovery from interruption |
- Scheduled Script: Under 10,000 records, simple processing, time-sensitive execution
- Map/Reduce Script: Large data volumes, parallel processing beneficial, can tolerate longer queue times
Industry Patterns
Scheduled Script Checklist
Map/Reduce Scripts
Parallel processing scripts for high-volume data operations with automatic governance management.
Map/Reduce Overview
Map/Reduce scripts process large data sets by breaking work into parallel stages. They automatically manage governance, handle failures, and scale across NetSuite's infrastructure.
The Four Stages
| Stage | Purpose | Concurrency |
|---|---|---|
| getInputData | Define data to process (search, query, array) | Single execution |
| map | Transform each input into key-value pairs | Parallel (up to 50) |
| reduce | Process grouped values by key | Parallel (up to 50) |
| summarize | Handle results, errors, generate summary | Single execution |
Scheduled vs. Map/Reduce
- Automatic parallelization: NetSuite manages concurrent execution
- Governance reset: Each map/reduce invocation gets fresh governance
- Error isolation: One failed record doesn't stop the entire job
- Built-in reporting: Summary stage provides execution statistics
Basic Map/Reduce Template
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @NModuleScope SameAccount
*/
define(['N/search', 'N/record', 'N/email', 'N/runtime', 'N/log'],
(search, record, email, runtime, log) => {
const getInputData = () => {
log.audit('Stage', 'getInputData started');
// Return a saved search - NetSuite streams results
return search.create({
type: search.Type.SALES_ORDER,
filters: [
['status', 'anyof', 'SalesOrd:B'],
['mainline', 'is', 'T']
],
columns: ['entity', 'tranid', 'total', 'trandate']
});
};
const map = (context) => {
const searchResult = JSON.parse(context.value);
const orderId = searchResult.id;
const customerId = searchResult.values.entity.value;
try {
const orderData = processOrder(orderId);
context.write({
key: customerId,
value: { orderId, total: orderData.total, status: orderData.status }
});
} catch (e) {
log.error('Map Error', `Order ${orderId}: ${e.message}`);
}
};
const reduce = (context) => {
const customerId = context.key;
let totalAmount = 0;
let orderCount = 0;
context.values.forEach((valueJson) => {
const value = JSON.parse(valueJson);
totalAmount += value.total;
orderCount++;
});
try {
record.submitFields({
type: record.Type.CUSTOMER,
id: customerId,
values: {
'custentity_pending_order_total': totalAmount,
'custentity_pending_order_count': orderCount
}
});
context.write({ key: 'success', value: customerId });
} catch (e) {
context.write({ key: 'error', value: { customerId, error: e.message } });
}
};
const summarize = (summary) => {
log.audit('Usage', JSON.stringify({
mapErrors: summary.mapSummary.errors.length,
reduceErrors: summary.reduceSummary.errors.length,
inputStageTime: summary.inputSummary.seconds,
mapStageTime: summary.mapSummary.seconds,
reduceStageTime: summary.reduceSummary.seconds
}));
let successCount = 0;
let errorList = [];
summary.output.iterator().each((key, value) => {
if (key === 'success') successCount++;
else if (key === 'error') errorList.push(JSON.parse(value));
return true;
});
summary.mapSummary.errors.iterator().each((key, error) => {
log.error('Map Stage Error', `Key: ${key}, Error: ${error}`);
return true;
});
sendSummaryEmail(successCount, errorList);
};
return { getInputData, map, reduce, summarize };
}); Parallel Execution
Map and Reduce stages run in parallel across multiple processors:
const summarize = (summary) => {
log.audit('Concurrency Used', {
mapConcurrency: summary.mapSummary.concurrency,
reduceConcurrency: summary.reduceSummary.concurrency
});
}; Common Patterns
Pattern 1: Map Only (No Reduce)
When you don't need aggregation, skip the reduce stage:
const map = (context) => {
const data = JSON.parse(context.value);
record.submitFields({
type: record.Type.CUSTOMER,
id: data.id,
values: { 'custentity_processed': true }
});
// Don't call context.write() - skip reduce stage
};
const reduce = null; // Or simply don't define it Pattern 2: Using Script Parameters
const getInputData = () => {
const script = runtime.getCurrentScript();
const subsidiaryId = script.getParameter({ name: 'custscript_subsidiary' });
const startDate = script.getParameter({ name: 'custscript_start_date' });
return search.create({
type: search.Type.INVOICE,
filters: [
['subsidiary', 'anyof', subsidiaryId],
['trandate', 'onorafter', startDate],
['mainline', 'is', 'T']
],
columns: ['entity', 'total']
});
}; Pattern 3: Processing Large Files
const getInputData = () => {
const script = runtime.getCurrentScript();
const fileId = script.getParameter({ name: 'custscript_import_file' });
const csvFile = file.load({ id: fileId });
const contents = csvFile.getContents();
const lines = contents.split('\n');
return lines.slice(1).map((line, index) => ({
lineNumber: index + 2,
data: line
}));
}; Pattern 4: Yield Point Recovery
const getInputData = () => {
const script = runtime.getCurrentScript();
if (script.getParameter({ name: 'custscript_resume_id' })) {
return search.create({
type: search.Type.SALES_ORDER,
filters: [
['internalid', 'greaterthan', script.getParameter({ name: 'custscript_resume_id' })],
['mainline', 'is', 'T']
]
});
}
return search.create({
type: search.Type.SALES_ORDER,
filters: [['mainline', 'is', 'T']]
});
}; Error Handling
const map = (context) => {
try {
const data = JSON.parse(context.value);
processRecord(data);
context.write({ key: 'success', value: data.id });
} catch (e) {
log.error('Map Error', {
key: context.key,
error: e.message,
stack: e.stack
});
context.write({
key: 'error',
value: JSON.stringify({ key: context.key, error: e.message })
});
}
};
const summarize = (summary) => {
if (summary.inputSummary.error) {
log.error('Input Error', summary.inputSummary.error);
sendAlertEmail('getInputData failed', summary.inputSummary.error);
return;
}
let mapErrorCount = 0;
summary.mapSummary.errors.iterator().each((key, error) => {
mapErrorCount++;
log.error('Map Error', `Key ${key}: ${error}`);
return true;
});
let reduceErrorCount = 0;
summary.reduceSummary.errors.iterator().each((key, error) => {
reduceErrorCount++;
log.error('Reduce Error', `Key ${key}: ${error}`);
return true;
});
log.audit('Error Summary', { mapErrors: mapErrorCount, reduceErrors: reduceErrorCount });
}; Performance Optimization
- Minimize getInputData Work: Return a search object rather than running it and returning results. Let NetSuite stream the data.
- Keep Map Functions Light: Each map invocation has only 1,000 units. Move heavy processing to reduce (5,000 units).
- Choose Keys Wisely: Keys determine grouping in reduce. Too few unique keys = underutilized parallelism. Too many = no aggregation benefit.
- Use submitFields Over record.save():
record.submitFields()uses 2 units vs 20+ for full record save.
Monitoring Progress
const checkMapReduceStatus = (taskId) => {
const status = task.checkStatus({ taskId });
log.debug('M/R Status', {
status: status.status, // PENDING, PROCESSING, COMPLETE, FAILED
stage: status.stage, // GET_INPUT, MAP, REDUCE, SUMMARIZE
percentComplete: status.getPercentageCompleted()
});
return status;
}; Industry Applications
Map/Reduce Script Checklist
Suitelets & Restlets
Build custom UI pages with Suitelets and RESTful APIs with Restlets for internal tools and external integrations.
Suitelets vs Restlets
Both script types create custom endpoints in NetSuite, but serve different purposes:
| Aspect | Suitelet | Restlet |
|---|---|---|
| Primary Use | Custom UI pages | RESTful API endpoints |
| Access | Browser URL, iframes, links | HTTP clients, integrations |
| Authentication | NetSuite login session | OAuth, NLAuth, Token-Based |
| Response Format | HTML pages, files, JSON | JSON, XML, plain text |
| Governance | 10,000 units | 5,000 units |
| HTTP Methods | GET, POST | GET, POST, PUT, DELETE |
Suitelet Basics
Suitelets create custom pages within NetSuite, accessible via URL or as embedded content.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
*/
define(['N/ui/serverWidget', 'N/search', 'N/record', 'N/redirect'],
(serverWidget, search, record, redirect) => {
const onRequest = (context) => {
if (context.request.method === 'GET') {
showForm(context);
} else {
processSubmission(context);
}
};
const showForm = (context) => {
const form = serverWidget.createForm({ title: 'Custom Data Entry Form' });
form.addField({
id: 'custpage_customer',
type: serverWidget.FieldType.SELECT,
label: 'Customer',
source: 'customer'
});
form.addField({
id: 'custpage_memo',
type: serverWidget.FieldType.TEXTAREA,
label: 'Notes'
});
form.addField({
id: 'custpage_amount',
type: serverWidget.FieldType.CURRENCY,
label: 'Amount'
});
form.addSubmitButton({ label: 'Save Record' });
context.response.writePage(form);
};
const processSubmission = (context) => {
const customerId = context.request.parameters.custpage_customer;
const memo = context.request.parameters.custpage_memo;
const amount = context.request.parameters.custpage_amount;
const newRecord = record.create({ type: 'customrecord_my_record' });
newRecord.setValue('custrecord_customer', customerId);
newRecord.setValue('custrecord_memo', memo);
newRecord.setValue('custrecord_amount', amount);
const recordId = newRecord.save();
redirect.toRecord({ type: 'customrecord_my_record', id: recordId });
};
return { onRequest };
}); Suitelet UI Components
The N/ui/serverWidget module provides rich UI building blocks:
Field Types
// Text fields
form.addField({ id: 'name', type: serverWidget.FieldType.TEXT, label: 'Name' });
form.addField({ id: 'email', type: serverWidget.FieldType.EMAIL, label: 'Email' });
form.addField({ id: 'notes', type: serverWidget.FieldType.TEXTAREA, label: 'Notes' });
form.addField({ id: 'richtext', type: serverWidget.FieldType.RICHTEXT, label: 'Description' });
// Numeric fields
form.addField({ id: 'qty', type: serverWidget.FieldType.INTEGER, label: 'Quantity' });
form.addField({ id: 'rate', type: serverWidget.FieldType.FLOAT, label: 'Rate' });
form.addField({ id: 'amount', type: serverWidget.FieldType.CURRENCY, label: 'Amount' });
form.addField({ id: 'pct', type: serverWidget.FieldType.PERCENT, label: 'Discount %' });
// Date/Time fields
form.addField({ id: 'date', type: serverWidget.FieldType.DATE, label: 'Date' });
form.addField({ id: 'datetime', type: serverWidget.FieldType.DATETIMETZ, label: 'Date/Time' });
// Selection fields
form.addField({ id: 'customer', type: serverWidget.FieldType.SELECT, label: 'Customer', source: 'customer' });
// Other fields
form.addField({ id: 'check', type: serverWidget.FieldType.CHECKBOX, label: 'Active' });
form.addField({ id: 'file', type: serverWidget.FieldType.FILE, label: 'Upload File' }); Sublists
const sublist = form.addSublist({
id: 'custpage_items',
type: serverWidget.SublistType.INLINEEDITOR,
label: 'Line Items'
});
sublist.addField({ id: 'item', type: serverWidget.FieldType.SELECT, label: 'Item', source: 'item' });
sublist.addField({ id: 'qty', type: serverWidget.FieldType.INTEGER, label: 'Quantity' });
sublist.addField({ id: 'rate', type: serverWidget.FieldType.CURRENCY, label: 'Rate' });
// Pre-populate sublist lines
sublist.setSublistValue({ id: 'item', line: 0, value: '123' });
sublist.setSublistValue({ id: 'qty', line: 0, value: '10' }); Tabs and Field Groups
form.addTab({ id: 'maintab', label: 'Main Info' });
form.addTab({ id: 'detailstab', label: 'Details' });
form.addFieldGroup({ id: 'addressgroup', label: 'Address', tab: 'maintab' });
const cityField = form.addField({
id: 'city',
type: serverWidget.FieldType.TEXT,
label: 'City',
container: 'addressgroup'
}); Suitelet Access & Deployment
Generate Suitelet URLs programmatically:
const url = require('N/url');
// Internal URL (for logged-in users)
const internalUrl = url.resolveScript({
scriptId: 'customscript_my_suitelet',
deploymentId: 'customdeploy_my_suitelet',
params: { customer: '123' }
});
// External URL (for external access)
const externalUrl = url.resolveScript({
scriptId: 'customscript_my_suitelet',
deploymentId: 'customdeploy_my_suitelet',
params: { customer: '123' },
returnExternalUrl: true
}); Restlet Basics
Restlets provide RESTful API endpoints for external integrations:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record', 'N/search', 'N/log'],
(record, search, log) => {
const get = (requestParams) => {
const customerId = requestParams.id;
if (!customerId) return { error: 'Customer ID required' };
try {
const customer = record.load({ type: record.Type.CUSTOMER, id: customerId });
return {
success: true,
data: {
id: customer.id,
name: customer.getValue('companyname'),
email: customer.getValue('email'),
balance: customer.getValue('balance')
}
};
} catch (e) {
return { success: false, error: e.message };
}
};
const post = (requestBody) => {
try {
const newCustomer = record.create({ type: record.Type.CUSTOMER });
newCustomer.setValue('companyname', requestBody.name);
newCustomer.setValue('email', requestBody.email);
newCustomer.setValue('subsidiary', requestBody.subsidiary || 1);
const customerId = newCustomer.save();
return { success: true, id: customerId, message: 'Customer created' };
} catch (e) {
return { success: false, error: e.message };
}
};
const put = (requestBody) => {
if (!requestBody.id) return { error: 'Customer ID required for update' };
try {
const values = {};
if (requestBody.name) values['companyname'] = requestBody.name;
if (requestBody.email) values['email'] = requestBody.email;
record.submitFields({ type: record.Type.CUSTOMER, id: requestBody.id, values });
return { success: true, message: 'Customer updated' };
} catch (e) {
return { success: false, error: e.message };
}
};
const doDelete = (requestParams) => {
if (!requestParams.id) return { error: 'Record ID required' };
try {
record.delete({ type: 'customrecord_my_record', id: requestParams.id });
return { success: true, message: 'Record deleted' };
} catch (e) {
return { success: false, error: e.message };
}
};
return { get, post, put, delete: doDelete };
}); Restlet Authentication
Restlets require authentication. Common methods:
| Method | Use Case | Header Format |
|---|---|---|
| Token-Based Auth (TBA) | Recommended for production | OAuth 1.0 signature |
| OAuth 2.0 | Modern integrations, machine-to-machine | Bearer token |
| NLAuth | Legacy/testing (not recommended) | Account, email, password, role |
Token-Based Authentication Setup
// Integration record settings:
// - Enable Token-Based Authentication
// - Create Access Token for user/role
// HTTP request headers for TBA:
// Authorization: OAuth realm="ACCOUNT_ID",
// oauth_consumer_key="CONSUMER_KEY",
// oauth_token="TOKEN_ID",
// oauth_signature_method="HMAC-SHA256",
// oauth_timestamp="TIMESTAMP",
// oauth_nonce="NONCE",
// oauth_version="1.0",
// oauth_signature="SIGNATURE" Calling Restlets
// External: Calling from JavaScript
const restletUrl = 'https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl';
const params = `?script=123&deploy=1&id=456`;
fetch(restletUrl + params, {
method: 'GET',
headers: {
'Authorization': 'OAuth ...',
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => console.log(data));
// Internal: From SuiteScript
const https = require('N/https');
const url = require('N/url');
const restletUrl = url.resolveScript({
scriptId: 'customscript_my_restlet',
deploymentId: 'customdeploy_my_restlet'
});
const response = https.post({
url: restletUrl,
body: JSON.stringify({ name: 'Test' }),
headers: { 'Content-Type': 'application/json' }
}); Common Patterns
Pattern: Wizard-Style Suitelet
const onRequest = (context) => {
const step = context.request.parameters.step || '1';
switch (step) {
case '1': renderStep1(context); break;
case '2':
if (context.request.method === 'POST') saveStep1Data(context);
renderStep2(context);
break;
case '3':
if (context.request.method === 'POST') {
saveStep2Data(context);
completeWizard(context);
}
break;
}
}; Pattern: Bulk Action Restlet
const post = (requestBody) => {
const recordIds = requestBody.ids;
const action = requestBody.action;
const results = { success: [], failed: [] };
recordIds.forEach(id => {
try {
if (action === 'approve') {
record.submitFields({
type: 'salesorder', id: id,
values: { 'orderstatus': 'B' }
});
}
results.success.push(id);
} catch (e) {
results.failed.push({ id, error: e.message });
}
});
return results;
}; Industry Applications
Suitelet/Restlet Checklist
Workflow Automation
Design and implement business process automation using SuiteFlow workflows without coding.
Workflow Fundamentals
SuiteFlow is NetSuite's visual workflow builder that automates business processes without coding. Workflows consist of states, transitions, and actions that execute based on triggers and conditions.
Workflows vs SuiteScript
| Criteria | Workflows (SuiteFlow) | SuiteScript |
|---|---|---|
| Complexity | Simple to moderate logic | Complex, conditional logic |
| Maintenance | Business users can modify | Developers required |
| Debugging | Workflow history visible | Script logs and debugger |
| Performance | Lightweight, efficient | Full API access, more overhead |
| External Calls | Limited (Send Email) | Full HTTP/REST capabilities |
| Best For | Approvals, notifications, field updates | Integrations, complex calculations |
Workflow Components
| Component | Description | Example |
|---|---|---|
| State | A step in the workflow process | Pending Approval, Manager Review, Approved |
| Transition | Movement from one state to another | Pending → Manager Review (on submit) |
| Action | Work performed during a state | Send email, set field, create record |
| Condition | Logic that controls execution | Amount > 1000 AND Department = Sales |
Trigger Types
| Trigger On | When It Fires | Common Use |
|---|---|---|
| Before Record Load | Record opens in UI | Set defaults, show/hide fields |
| Before Record Submit | Before record saves | Validation, calculated fields |
| After Record Submit | After record saves | Send emails, create related records |
| Scheduled | On defined schedule | Periodic updates, reminders |
| Entry | When entering a state | State-specific actions |
| Exit | When leaving a state | Cleanup, logging |
Creating a Workflow
Workflow Settings
| Field | Description | Recommendation |
|---|---|---|
| Name | Workflow identifier | Use format: [Record] - [Process] (e.g., "SO - Approval") |
| Record Type | Record this workflow applies to | Cannot be changed after creation |
| Sub Type | Transaction subtype filter | Leave blank for all subtypes |
| Release Status | Not Initiating, Testing, Released | Use Testing during development |
| Initiation | Event, Scheduled, or Both | Event is most common |
| Execute as Admin | Run with full permissions | Enable for cross-department workflows |
| Keep Instance on Record Update | Continue workflow on edits | Enable for approval workflows |
Workflow Actions
Field Actions
| Action | Purpose | Trigger Options |
|---|---|---|
| Set Field Value | Update any field on the record | Entry, Exit, Before/After Submit |
| Set Field Display Type | Make field hidden, disabled, or normal | Before Load, Entry |
| Set Field Display Label | Change field label dynamically | Before Load, Entry |
| Set Field Mandatory | Make field required or optional | Before Load, Entry |
Communication Actions
| Action | Purpose | Key Settings |
|---|---|---|
| Send Email | Send email notifications | Recipient, template, attach record |
| Send Campaign Email | Use marketing template | Campaign, email template |
| Add Note | Create note on record | Title, note content |
Record Actions
| Action | Purpose | Notes |
|---|---|---|
| Create Record | Create new related record | Task, phone call, event, any custom record |
| Transform Record | Convert to another record type | Quote → Sales Order, SO → Invoice |
| Remove Button | Hide UI buttons | Before Load only |
| Lock Record | Prevent editing | Useful for approved records |
Advanced Actions
| Action | Purpose | Use Case |
|---|---|---|
| Custom Action | Execute SuiteScript | Complex logic, external calls |
| Initiate Workflow | Trigger another workflow | Chained processes |
| Go to Page | Redirect user to URL | Confirmation pages |
| Return User Error | Show error message | Validation failures |
Approval Workflow Pattern
Approvals are the most common workflow use case. Here is the standard pattern:
States
A typical approval workflow moves through these states: Draft → Pending Approval → Manager Review (for higher amounts) → Approved, with a Rejected path available at each approval level.
State Configuration
| State | Entry Actions | Exit Actions |
|---|---|---|
| Pending Approval | Email approver, set approval status field | None |
| Manager Review | Email manager, create task for follow-up | None |
| Approved | Set status, email submitter, unlock record | None |
| Rejected | Set status, email submitter with reason | None |
Transition Conditions
| Transition | Trigger | Condition |
|---|---|---|
| Draft → Pending Approval | After Record Submit | Status = Pending Approval |
| Pending → Manager Review | After Record Submit | Amount > $10,000 |
| Pending → Approved | After Record Submit | Approval Status = Approved AND Amount ≤ $10,000 |
| Manager Review → Approved | After Record Submit | Manager Approval = Approved |
| Any → Rejected | After Record Submit | Approval Status = Rejected |
- Enable "Keep Instance on Record Update" so the workflow continues when records are edited during the approval process
- Lock fields in approval states using Set Field Display Type actions to prevent changes during review
- Include rejection reason fields so approvers can explain why they rejected
- Add approval buttons using workflow button transitions for a clean user experience
Building Conditions
Condition Syntax
| Operator | Description | Example |
|---|---|---|
| = | Equals | {status} = "Pending Approval" |
| != | Not equals | {department} != "Executive" |
| > < >= <= | Comparison | {amount} > 1000 |
| contains | Text contains | {memo} contains "RUSH" |
| startswith | Text starts with | {tranid} startswith "SO" |
| isempty | Field has no value | {custbody_approver} isempty |
| isnotempty | Field has value | {custbody_reason} isnotempty |
| AND / OR | Combine conditions | {amount} > 1000 AND {type} = "Invoice" |
Common Condition Patterns
// Approval threshold by department
({department} = "Engineering" AND {amount} > 5000) OR
({department} = "Marketing" AND {amount} > 2500) OR
({department} = "Sales" AND {amount} > 10000)
// Multi-level approval
{amount} > 10000 AND {custbody_mgr_approved} = T AND {custbody_dir_approved} != T
// Record change detection (use with Before Submit)
{amount} != {previousvalue.amount}
// Day of week (for scheduled workflows)
{today} = "Monday" OR {today} = "Wednesday" Workflow Variables
Variables store values during workflow execution for use across states and actions.
Variable Types
| Type | Use Case | Example |
|---|---|---|
| Text | Store strings | Rejection reason, notes |
| Number | Store numeric values | Original amount, line count |
| Date | Store dates | Approval date, due date |
| Record Reference | Store related record ID | Approver employee record |
| Boolean | True/False flags | Has been escalated |
Setting Variables
Use "Set Field Value" action targeting workflow variables:
// In Set Field Value action
Field: [Workflow Variable] custworkflow_original_amount
Value: {total}
Value Type: Field
// Formula-based variable
Field: [Workflow Variable] custworkflow_days_pending
Value: ROUND(({today} - {datecreated}), 0)
Value Type: Formula Email Notifications
Dynamic Email Content
Use merge fields in email templates:
Subject: ${record.type} ${record.tranid} Requires Your Approval
Body:
Dear ${record.salesrep.entityid},
A ${record.type} has been submitted for your approval:
Transaction: ${record.tranid}
Customer: ${record.entity}
Amount: ${record.total}
Submitted By: ${currentuser.firstname} ${currentuser.lastname}
Please review and approve: ${record.recordurl} Recipient Options
| Option | Description | Use Case |
|---|---|---|
| Specific Recipient | Fixed employee or email | Always notify same person |
| Field on Record | Employee field value | Sales rep, project manager |
| Role | All users with role | All Administrators |
| Group | Distribution group | Approval committee |
| Current User's Supervisor | Manager hierarchy | Manager approvals |
Industry Workflow Patterns
Manufacturing
- Work Order Release: Engineering approval before production release
- BOM Changes: Multi-level approval for bill of material modifications
- Quality Hold: Automatic hold when QC failure recorded
Wholesale/Distribution
- Credit Hold Release: Approval workflow when customer exceeds credit limit
- Special Pricing: Margin approval for below-threshold pricing
- Large Order Review: Manager notification for orders exceeding threshold
Professional Services
- Time Entry Approval: Manager approval of weekly timesheets
- Expense Report: Multi-tier approval based on amount
- Project Budget Change: PM and sponsor approval required
Retail
- Returns Authorization: Approval for returns over threshold
- Price Override: Manager approval for discounts
- Gift Card Refund: Finance approval required
Testing & Troubleshooting
Release Status Strategy
| Status | Behavior | When to Use |
|---|---|---|
| Not Initiating | Workflow never starts | Initial development |
| Testing | Only initiates for Owner role | QA testing phase |
| Released | Active for all users | Production use |
Viewing Workflow History
- Shows all states the record has passed through
- Displays actions executed at each state
- Includes timestamps and triggering users
- Shows condition evaluation results
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Workflow not starting | Wrong initiation condition | Check base record filter and initiation settings |
| Transition not firing | Condition not met | Review condition logic, check field values |
| Email not sending | Empty recipient | Ensure field-based recipient has value |
| Multiple instances | Re-initiation allowed | Adjust "Allow Re-initiate" setting |
| Workflow canceled on edit | "Keep Instance" not enabled | Enable "Keep Instance on Record Update" |
Workflow Automation Checklist
Workflow Configuration Checklist
SuiteFlow Advanced
Master advanced workflow patterns including multi-level approvals, conditional routing, and workflow actions.
Custom Workflow Actions
Custom actions extend workflows by executing SuiteScript code when standard actions aren't sufficient.
When to Use Custom Actions
- Complex calculations requiring multiple field values
- External API calls (web services, third-party integrations)
- Advanced record creation with sublist lines
- Cross-record lookups and updates
- Custom validation logic
Custom Action Script Template
/**
* @NApiVersion 2.1
* @NScriptType WorkflowActionScript
*/
define(['N/record', 'N/search', 'N/runtime', 'N/log'],
(record, search, runtime, log) => {
const onAction = (context) => {
try {
const rec = context.newRecord;
const customerId = rec.getValue('entity');
const creditLimit = lookupCreditLimit(customerId);
const orderTotal = rec.getValue('total');
if (orderTotal > creditLimit) {
return 'OVER_LIMIT';
}
return 'APPROVED';
} catch (e) {
log.error('Workflow Action Error', e.message);
return 'ERROR';
}
};
const lookupCreditLimit = (customerId) => {
const customerFields = search.lookupFields({
type: search.Type.CUSTOMER,
id: customerId,
columns: ['creditlimit']
});
return parseFloat(customerFields.creditlimit) || 0;
};
return { onAction };
}); Deploying Custom Actions
| Setting | Value | Notes |
|---|---|---|
| Script Type | Workflow Action | Appears in workflow action dropdown |
| Deployment Status | Released | Must be released to use in workflows |
| Execute As Role | Administrator | Use elevated role for cross-record access |
| Log Level | Debug | Set to Error in production |
Sublist Processing in Workflows
Workflows can process line items through scheduled triggers and custom actions.
Sublist Iteration Pattern
const onAction = (context) => {
const rec = context.newRecord;
const lineCount = rec.getLineCount({ sublistId: 'item' });
let hasBackorder = false;
let totalWeight = 0;
for (let i = 0; i < lineCount; i++) {
const qty = rec.getSublistValue({
sublistId: 'item', fieldId: 'quantity', line: i
});
const qtyAvailable = rec.getSublistValue({
sublistId: 'item', fieldId: 'quantityavailable', line: i
});
const weight = rec.getSublistValue({
sublistId: 'item', fieldId: 'weight', line: i
}) || 0;
if (qty > qtyAvailable) hasBackorder = true;
totalWeight += (weight * qty);
}
return hasBackorder ? 'BACKORDER' : 'AVAILABLE';
}; Sublist Modification in Custom Actions
// NOTE: Must load record in dynamic mode
const onAction = (context) => {
const recId = context.newRecord.id;
const recType = context.newRecord.type;
const rec = record.load({ type: recType, id: recId, isDynamic: true });
rec.selectNewLine({ sublistId: 'item' });
rec.setCurrentSublistValue({
sublistId: 'item', fieldId: 'item', value: 123
});
rec.setCurrentSublistValue({
sublistId: 'item', fieldId: 'quantity', value: 1
});
rec.commitLine({ sublistId: 'item' });
rec.save();
return 'LINE_ADDED';
}; Scheduled Workflows
Scheduled workflows execute on a defined schedule for records matching specified criteria.
Scheduled Workflow Settings
| Setting | Options | Use Case |
|---|---|---|
| Initiation Type | Scheduled (or Both) | Enable scheduled execution |
| Repeat Type | Daily, Weekly, Monthly, Yearly | Frequency of execution |
| Time of Day | Specific time | When to run daily |
| Day of Week | Sun-Sat selections | Which days to run weekly |
| Day of Month | 1-31 or Last | Which day to run monthly |
Common Scheduled Workflow Patterns
// 1. Overdue Invoice Reminders
// Schedule: Daily at 8:00 AM
// Record: Invoice
// Condition: {duedate} < {today} AND {status} != "Paid In Full"
// Action: Send Email to customer contact
// 2. Stale Quote Follow-up
// Schedule: Weekly on Monday
// Record: Estimate
// Condition: {trandate} < ({today} - 14) AND {status} = "Open"
// Action: Create Task for sales rep
// 3. Month-end Accrual
// Schedule: Monthly on Last Day
// Record: Journal Entry
// Condition: {custbody_accrual_type} = "Auto"
// Action: Custom action to create reversal
// 4. Anniversary Notifications
// Schedule: Daily
// Record: Customer
// Condition: MONTH({custentity_anniversary}) = MONTH({today})
// Action: Send Campaign Email Workflow Buttons
Add custom buttons to records that trigger workflow transitions.
Creating Workflow Buttons
- Create a state with desired button action
- Add transition from that state
- Set transition trigger to "Button"
- Configure button label and conditions
Button Configuration
| Property | Description | Example |
|---|---|---|
| Button Label | Text displayed on button | "Approve", "Submit for Review" |
| Save Record First | Save before transition | Enable for data capture |
| Button Condition | When to show button | User role, field values |
Button Visibility by Role
// Show "Approve" button only to managers
Condition: {role} = "Manager" OR {role} = "Administrator"
// Show "Escalate" button when pending > 3 days
Condition: ({today} - {custbody_submit_date}) > 3
// Show "Override" button for specific users
Condition: {currentuser.id} = 123 OR {currentuser.id} = 456 Workflow-Script Integration
Workflows and scripts can work together for comprehensive automation.
Initiating Workflows from SuiteScript
define(['N/workflow'],
(workflow) => {
const afterSubmit = (context) => {
if (context.type === context.UserEventType.CREATE) {
workflow.initiate({
recordType: context.newRecord.type,
recordId: context.newRecord.id,
workflowId: 'customworkflow_approval'
});
}
};
return { afterSubmit };
}); Triggering Workflow Transitions
define(['N/workflow'],
(workflow) => {
const triggerApproval = (recordType, recordId) => {
workflow.trigger({
recordType: recordType,
recordId: recordId,
workflowId: 'customworkflow_approval',
actionId: 'customworkflowaction_approve'
});
};
return { triggerApproval };
}); Reading Workflow State in Scripts
define(['N/search'],
(search) => {
const getWorkflowState = (recordType, recordId, workflowId) => {
const results = search.create({
type: 'workflowinstance',
filters: [
['record', 'is', recordId],
'AND',
['recordtype', 'is', recordType],
'AND',
['workflow', 'is', workflowId]
],
columns: ['state', 'datecreated']
}).run().getRange({ start: 0, end: 1 });
if (results.length > 0) {
return results[0].getValue('state');
}
return null;
};
return { getWorkflowState };
}); Parallel & Sequential Approvals
Sequential Approval Pattern
Submit → Manager Approval → Director Approval → VP Approval → Approved (with rejection paths at each level).
Parallel Approval Pattern
Submit → Finance Approval + Legal Approval + Ops Approval → All Approved (when all approvals complete).
// Implementation using checkbox fields
Transition to "All Approved" when:
{custbody_finance_approved} = T AND
{custbody_legal_approved} = T AND
{custbody_ops_approved} = T Escalation & Delegation
Time-Based Escalation
// Scheduled workflow for escalation
// Runs daily at 9:00 AM
// Record: Sales Order
// Condition:
// {custbody_approval_status} = "Pending" AND
// ({today} - {custbody_submit_date}) > 3 AND
// {custbody_escalated} != T
// Actions:
// 1. Set Field: custbody_escalated = T
// 2. Set Field: custbody_approver = {custbody_original_approver.supervisor}
// 3. Send Email: To new approver about escalation
// 4. Add Note: "Approval escalated due to 3-day SLA breach" Delegation Configuration
| Delegation Type | Implementation | Use Case |
|---|---|---|
| Permanent Delegate | Custom field on employee | Executive assistants |
| Temporary Delegate | Custom record with date range | Vacation coverage |
| Category Delegate | Multiple delegate fields by type | Different delegates for PO vs SO |
Delegation Lookup Script
const onAction = (context) => {
const rec = context.newRecord;
const originalApprover = rec.getValue('custbody_approver');
const delegateSearch = search.create({
type: 'customrecord_delegation',
filters: [
['custrecord_delegate_from', 'is', originalApprover],
'AND',
['custrecord_delegate_start', 'onorbefore', 'today'],
'AND',
['custrecord_delegate_end', 'onorafter', 'today'],
'AND',
['isinactive', 'is', false]
],
columns: ['custrecord_delegate_to']
}).run().getRange({ start: 0, end: 1 });
if (delegateSearch.length > 0) {
return delegateSearch[0].getValue('custrecord_delegate_to');
}
return originalApprover;
}; Advanced Best Practices
Performance Optimization
| Practice | Reason |
|---|---|
| Use specific initiation conditions | Prevents unnecessary workflow instances |
| Minimize states with multiple actions | Reduces processing time per transition |
| Avoid lookups in conditions | Use stored field values instead |
| Use After Submit for emails | Doesn't block user save operation |
| Limit custom action complexity | Keep under 1000 governance units |
Governance in Custom Actions
| Action Trigger | Governance Units | Notes |
|---|---|---|
| Before Record Load | 1,000 | UI display context |
| Before Record Submit | 1,000 | Validation context |
| After Record Submit | 1,000 | Post-save processing |
| Scheduled | 1,000 | Per record processed |
Use the Workflow History tab on records to see workflow execution details. Navigate to: Record > Workflow > View History. This shows states entered, actions executed, and any errors.
SuiteFlow Advanced Checklist
Advanced Workflow Checklist
Formula Fields & Expressions
Leverage formulas in saved searches, workflows, and custom fields for calculations without SuiteScript.
Formula Fundamentals
NetSuite formulas use SQL-like syntax for calculations in custom fields, saved searches, and workflows. Understanding formula context is critical for correct results.
Formula Contexts
| Context | Field Reference | Example |
|---|---|---|
| Custom Field (Default) | {fieldid} | {total} * 0.1 |
| Saved Search | {fieldid} or joined {table.fieldid} | {customer.creditlimit} |
| Workflow Condition | {fieldid} | {amount} > 1000 |
| Workflow Set Field | {fieldid} | {total} - {amountpaid} |
| Advanced PDF/HTML | ${fieldid} | ${record.total} |
Formula Return Types
| Type | Use For | Example |
|---|---|---|
| Formula (Text) | String results, concatenation | {firstname} || ' ' || {lastname} |
| Formula (Numeric) | Calculations, counts | {quantity} * {rate} |
| Formula (Date) | Date calculations | {trandate} + 30 |
| Formula (Currency) | Money values with currency format | {amount} * 1.1 |
| Formula (Percent) | Percentage values | {amountpaid} / {total} |
Text Functions
String Manipulation
| Function | Description | Example |
|---|---|---|
| || (Concatenate) | Combine strings | {firstname} || ' ' || {lastname} |
| UPPER() | Convert to uppercase | UPPER({companyname}) |
| LOWER() | Convert to lowercase | LOWER({email}) |
| INITCAP() | Title case | INITCAP({city}) |
| SUBSTR() | Extract portion of string | SUBSTR({phone}, 1, 3) |
| LENGTH() | String length | LENGTH({memo}) |
| TRIM() | Remove leading/trailing spaces | TRIM({address}) |
| REPLACE() | Replace substring | REPLACE({phone}, '-', '') |
| INSTR() | Find position of substring | INSTR({email}, '@') |
| LPAD() / RPAD() | Pad string to length | LPAD({tranid}, 10, '0') |
Common Text Patterns
// Extract domain from email
SUBSTR({email}, INSTR({email}, '@') + 1)
// Format phone as (XXX) XXX-XXXX
'(' || SUBSTR({phone}, 1, 3) || ') ' ||
SUBSTR({phone}, 4, 3) || '-' || SUBSTR({phone}, 7, 4)
// First initial + last name
SUBSTR({firstname}, 1, 1) || '. ' || {lastname}
// Handle null values
NVL({altname}, {entityid}) Numeric Functions
Math Functions
| Function | Description | Example |
|---|---|---|
| ROUND() | Round to decimal places | ROUND({amount}, 2) |
| TRUNC() | Truncate decimal places | TRUNC({rate}, 0) |
| CEIL() | Round up to integer | CEIL({quantity}) |
| FLOOR() | Round down to integer | FLOOR({hours}) |
| ABS() | Absolute value | ABS({variance}) |
| MOD() | Modulo (remainder) | MOD({quantity}, 12) |
| POWER() | Raise to power | POWER(1 + {rate}, {periods}) |
| GREATEST() | Maximum of values | GREATEST({qty1}, {qty2}, 0) |
| LEAST() | Minimum of values | LEAST({available}, {ordered}) |
Common Numeric Patterns
// Margin calculation
ROUND(({amount} - {costestimate}) / NULLIF({amount}, 0) * 100, 2)
// Prevent division by zero
{numerator} / NULLIF({denominator}, 0)
// Quantity in cases (12 per case)
FLOOR({quantity} / 12) || ' cases, ' || MOD({quantity}, 12) || ' units'
// Weighted average
SUM({quantity} * {rate}) / NULLIF(SUM({quantity}), 0)
// Round to nearest 5
ROUND({amount} / 5) * 5 Date Functions
Date Manipulation
| Function | Description | Example |
|---|---|---|
| SYSDATE | Current date/time | SYSDATE |
| TRUNC(date) | Remove time portion | TRUNC(SYSDATE) |
| ADD_MONTHS() | Add/subtract months | ADD_MONTHS({startdate}, 3) |
| MONTHS_BETWEEN() | Months between dates | MONTHS_BETWEEN(SYSDATE, {startdate}) |
| LAST_DAY() | Last day of month | LAST_DAY({trandate}) |
| NEXT_DAY() | Next occurrence of day | NEXT_DAY({trandate}, 'MONDAY') |
| EXTRACT() | Extract date component | EXTRACT(YEAR FROM {trandate}) |
| TO_DATE() | Convert string to date | TO_DATE('2024-01-01', 'YYYY-MM-DD') |
| TO_CHAR() | Format date as string | TO_CHAR({trandate}, 'Mon DD, YYYY') |
Date Format Codes
| Code | Description | Example Output |
|---|---|---|
| YYYY | 4-digit year | 2024 |
| MM | Month (01-12) | 03 |
| Mon | Month abbreviation | Mar |
| DD | Day of month | 15 |
| DY | Day abbreviation | Fri |
| HH24 | Hour (00-23) | 14 |
| MI | Minutes | 30 |
| Q | Quarter | 1 |
| WW | Week of year | 11 |
Common Date Patterns
// Days until due
{duedate} - TRUNC(SYSDATE)
// Days overdue (positive if past due)
TRUNC(SYSDATE) - {duedate}
// Age in years
FLOOR(MONTHS_BETWEEN(SYSDATE, {birthdate}) / 12)
// First day of current month
TRUNC(SYSDATE, 'MM')
// Last day of previous month
TRUNC(SYSDATE, 'MM') - 1
// Quarter name
'Q' || TO_CHAR({trandate}, 'Q') || ' ' || TO_CHAR({trandate}, 'YYYY') Conditional Logic
CASE Expressions
// Simple CASE (compare to specific values)
CASE {status}
WHEN 'Pending Fulfillment' THEN 'In Progress'
WHEN 'Fulfilled' THEN 'Complete'
WHEN 'Cancelled' THEN 'Void'
ELSE 'Unknown'
END
// Searched CASE (evaluate conditions)
CASE
WHEN {amount} >= 10000 THEN 'Large'
WHEN {amount} >= 1000 THEN 'Medium'
WHEN {amount} > 0 THEN 'Small'
ELSE 'Zero'
END
// Nested CASE for complex logic
CASE
WHEN {type} = 'Customer' THEN
CASE
WHEN {balance} > {creditlimit} THEN 'Over Credit'
WHEN {balance} > {creditlimit} * 0.8 THEN 'Near Limit'
ELSE 'Good Standing'
END
ELSE 'N/A'
END NULL Handling Functions
| Function | Description | Example |
|---|---|---|
| NVL() | Replace null with value | NVL({memo}, 'No memo') |
| NVL2() | Different values for null/not null | NVL2({email}, 'Has Email', 'No Email') |
| NULLIF() | Return null if values equal | NULLIF({quantity}, 0) |
| COALESCE() | First non-null value | COALESCE({phone}, {altphone}, 'N/A') |
| DECODE() | IF-THEN-ELSE shorthand | DECODE({type}, 'A', 1, 'B', 2, 0) |
DECODE Function
// DECODE syntax: DECODE(expression, search1, result1, search2, result2, ..., default)
// Map status codes to labels
DECODE({custbody_status},
1, 'New',
2, 'In Progress',
3, 'Completed',
4, 'On Hold',
'Unknown') Aggregate Functions
Used in saved searches with Summary type columns.
Standard Aggregates
| Function | Description | Example |
|---|---|---|
| SUM() | Total of values | SUM({amount}) |
| COUNT() | Count of records | COUNT({internalid}) |
| COUNT(DISTINCT) | Count of unique values | COUNT(DISTINCT {customer}) |
| AVG() | Average value | AVG({rate}) |
| MIN() | Minimum value | MIN({trandate}) |
| MAX() | Maximum value | MAX({amount}) |
Aggregate Formula Patterns
// Weighted average rate
SUM({quantity} * {rate}) / NULLIF(SUM({quantity}), 0)
// Count with condition
SUM(CASE WHEN {status} = 'Overdue' THEN 1 ELSE 0 END)
// Percentage of total
{amount} / SUM({amount}) * 100
// Conditional sum
SUM(CASE WHEN {type} = 'Invoice' THEN {amount} ELSE 0 END) Saved Search Formula Patterns
Join Field Access
// Access parent record fields
{customer.companyname}
{customer.salesrep}
{item.displayname}
// Access custom fields on joined records
{customer.custentity_region}
{item.custitem_category}
// Multiple levels of joins
{customer.salesrep.email} Common Search Formulas
// Days since last activity
TRUNC(SYSDATE) - {lastmodifieddate}
// Full address in one column
{shipaddress1} ||
NVL2({shipaddress2}, CHR(10) || {shipaddress2}, '') ||
CHR(10) || {shipcity} || ', ' || {shipstate} || ' ' || {shipzip}
// Age bucket
CASE
WHEN TRUNC(SYSDATE) - {duedate} <= 0 THEN 'Current'
WHEN TRUNC(SYSDATE) - {duedate} <= 30 THEN '1-30 Days'
WHEN TRUNC(SYSDATE) - {duedate} <= 60 THEN '31-60 Days'
WHEN TRUNC(SYSDATE) - {duedate} <= 90 THEN '61-90 Days'
ELSE 'Over 90 Days'
END Industry Formula Applications
Manufacturing
// Scrap percentage
ROUND(({quantityscrapped} / NULLIF({quantity}, 0)) * 100, 2) || '%'
// Production efficiency
ROUND(({quantitycompleted} / NULLIF({quantityplanned}, 0)) * 100, 1)
// Yield rate
ROUND(({quantitycompleted} / NULLIF({quantitystarted}, 0)) * 100, 2) Wholesale/Distribution
// Days of supply
{quantityonhand} / NULLIF({averagedailyusage}, 0)
// Reorder flag
CASE WHEN {quantityonhand} <= {reorderpoint} THEN 'REORDER' ELSE '' END
// Margin percentage
ROUND(({rate} - {costestimate}) / NULLIF({rate}, 0) * 100, 2) Professional Services
// Utilization rate
ROUND(({billablehours} / NULLIF({availablehours}, 0)) * 100, 1) || '%'
// Budget burn rate
ROUND(({actualcost} / NULLIF({budgetedcost}, 0)) * 100, 1)
// Project health
CASE
WHEN {percentcomplete} >= {percenttimecomplete} THEN 'On Track'
WHEN {percentcomplete} >= {percenttimecomplete} * 0.8 THEN 'At Risk'
ELSE 'Behind'
END Retail
// Average transaction value
{totalrevenue} / NULLIF({transactioncount}, 0)
// Discount percentage
ROUND((1 - ({actualprice} / NULLIF({listprice}, 0))) * 100, 1) || '%'
// Sell-through rate
ROUND(({quantitysold} / NULLIF({quantityreceived}, 0)) * 100, 1) Formula Troubleshooting
Common Errors
| Error | Cause | Solution |
|---|---|---|
| Invalid Expression | Syntax error in formula | Check parentheses, quotes, field names |
| Field Not Found | Wrong field ID or unavailable join | Verify field ID in Records Browser |
| Type Mismatch | Mixing incompatible types | Use TO_CHAR, TO_NUMBER, TO_DATE conversions |
| Division by Zero | Denominator can be zero | Use NULLIF({field}, 0) in denominator |
| Blank Results | NULL values in calculation | Use NVL() to handle nulls |
| Incorrect Results | Wrong formula context | Check if summary function needed |
Debugging Tips
- Break down complex formulas — Test each part separately
- Use Records Browser — Verify exact field IDs
- Check data types — Ensure field types match expected formula types
- Test with simple values — Use literals first, then replace with fields
- Watch for null propagation — Any operation with NULL returns NULL
Formulas are calculated at runtime. Complex formulas on large result sets can slow searches significantly. For performance-critical reports, consider pre-calculating values via scheduled scripts and storing in custom fields.
