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, allowing you to build everything from simple field validations to complex integrations and custom applications.
- 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, etc.)
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, field validation rules | Complex cross-field logic, external data validation |
| Auto-populate fields | Sourcing, default values, formulas | Complex calculations, external lookups |
| Approval workflows | SuiteFlow workflows | Complex routing logic, external system integration |
| Scheduled tasks | Saved search email alerts, reminders | Data processing, integration syncs, complex reports |
| Custom UI | Custom forms, custom records | Custom pages, portlets, dynamic interfaces |
| Integrations | Native connectors (if available) | Custom APIs, bidirectional sync, complex mapping |
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
Reserve SuiteScript for requirements that genuinely cannot be met through configuration.
Script Types Overview
NetSuite provides different script types optimized for specific use cases:
| 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, record transformations |
| Scheduled Script | Server (time-based) | Batch processing, data cleanup, scheduled integrations |
| Map/Reduce Script | Server (parallel processing) | Large data sets, complex transformations, bulk operations |
| Suitelet | Server (on-demand page) | Custom UI pages, wizards, internal tools |
| Restlet | Server (HTTP endpoint) | REST APIs, external integrations, web services |
| Portlet Script | Server (dashboard) | Custom dashboard widgets |
| Workflow Action Script | Server (within workflow) | Custom workflow actions |
| Mass Update Script | Server (mass update) | Bulk record modifications |
| Bundle Installation Script | Server (SuiteApp install) | Post-installation configuration |
Governance Model
NetSuite uses a governance system to ensure fair resource allocation across all customers on the shared platform. 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
record.copy() 10
record.transform() 10
search.create().run() 10
search.lookupFields() 1
email.send() 20
http.request() 10
file.load() 10
file.save() 20
N/query (SuiteQL) 10
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) => {
// Runs before record is displayed
log.debug('Before Load', `Record ID: ${context.newRecord.id}`);
};
const beforeSubmit = (context) => {
// Runs before record is saved to database
const rec = context.newRecord;
// Validation or modification logic
};
const afterSubmit = (context) => {
// Runs after record is saved to database
// Good for creating related records, sending emails
};
return {
beforeLoad,
beforeSubmit,
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 |
HTTP requests | get, post, put, delete, request |
N/https |
HTTPS requests | get, post (with SSL) |
N/runtime |
Runtime information | getCurrentUser, getCurrentScript |
N/ui/serverWidget |
UI components (Suitelets) | createForm, createList |
N/task |
Trigger scheduled scripts | create (MapReduceTask, ScheduledTask) |
Script Deployment
Every script requires two components: the Script record and the Script Deployment record.
Upload .js file to File Cabinet (SuiteScripts folder recommended)
Select script file, configure script parameters if needed
Link script to record types/contexts, set execution roles
Change deployment status from Testing to Released for production use
Deployment Settings
| Setting | Purpose | Best Practice |
|---|---|---|
| Applies To | Record types script runs on | Be specific; avoid "All Records" |
| Execute As Role | Role context for script execution | Use dedicated script role with minimum permissions |
| Log Level | Minimum log level recorded | DEBUG for testing, ERROR for production |
| Status | Testing, Released, Not Scheduled | Test thoroughly before releasing |
| All Roles/Specific Roles | Which roles trigger script | Be specific when possible |
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 {
// Main script logic here
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}`
});
// Optionally: send alert email to admin
}
};
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 for data validation, transformation, and integration.
What Are User Event Scripts?
User Event scripts run on the server whenever a record is loaded, created, edited, or deleted. They're the most commonly used script type because they execute automatically with record operations—no user action required beyond normal record interaction.
- beforeLoad — Runs before record is displayed to user (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 and adding data to the form.
| 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 |
| Inject HTML/CSS | Add warning banner to form |
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
*/
define(['N/ui/serverWidget'], (serverWidget) => {
const beforeLoad = (context) => {
// Only run in view/edit modes
if (context.type !== context.UserEventType.VIEW &&
context.type !== context.UserEventType.EDIT) {
return;
}
const form = context.form;
// Add custom button
form.addButton({
id: 'custpage_generate_pdf',
label: 'Generate PDF',
functionName: 'generatePDF' // Client script function
});
// Add client script to handle button click
form.clientScriptModulePath = './generate_pdf_cs.js';
};
return { beforeLoad };
});
- Cannot modify record values (changes won't persist)
- Only runs for UI access (not CSV import, web services, scripts)
- Must check context.type to avoid running on wrong operations
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 |
| External validation | Verify address with external API |
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
*/
define(['N/record', 'N/error'], (record, error) => {
const beforeSubmit = (context) => {
// Skip for delete operations
if (context.type === context.UserEventType.DELETE) {
return;
}
const rec = context.newRecord;
// Validation: Ensure discount doesn't exceed 30%
const discount = rec.getValue('discountrate') || 0;
if (discount > 30) {
throw error.create({
name: 'DISCOUNT_TOO_HIGH',
message: 'Discount cannot exceed 30%. Current: ' + discount + '%',
notifyOff: false
});
}
// Auto-populate: Set approval routing based on total
const total = rec.getValue('total');
if (total > 10000) {
rec.setValue('custbody_approval_level', 'MANAGER');
} else if (total > 50000) {
rec.setValue('custbody_approval_level', 'DIRECTOR');
} else {
rec.setValue('custbody_approval_level', 'AUTO_APPROVE');
}
// Data transformation: Normalize external ID
const externalId = rec.getValue('externalid');
if (externalId) {
rec.setValue('externalid', externalId.toUpperCase().trim());
}
};
return { beforeSubmit };
});
afterSubmit
Executes after the record is saved. Use for operations that require the saved record ID or affect other records.
| 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 |
| Trigger workflows | Initiate approval process |
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
*/
define(['N/record', 'N/email', 'N/search', 'N/log'], (record, email, search, log) => {
const afterSubmit = (context) => {
// Only run on create
if (context.type !== context.UserEventType.CREATE) {
return;
}
try {
const rec = context.newRecord;
const recordId = rec.id;
const customerId = rec.getValue('entity');
const salesRepId = rec.getValue('salesrep');
// Create follow-up task
const task = record.create({ type: record.Type.TASK });
task.setValue('title', 'Follow up on Sales Order ' + rec.getValue('tranid'));
task.setValue('assigned', salesRepId);
task.setValue('company', customerId);
task.setValue('startdate', new Date());
task.setValue('duedate', addDays(new Date(), 7));
task.setValue('message', 'Follow up with customer on order delivery');
const taskId = task.save();
log.audit('Task Created', 'Task ID: ' + taskId);
// Send confirmation email to customer
const customerEmail = search.lookupFields({
type: search.Type.CUSTOMER,
id: customerId,
columns: ['email', 'firstname']
});
if (customerEmail.email) {
email.send({
author: salesRepId,
recipients: customerEmail.email,
subject: 'Order Confirmation - ' + rec.getValue('tranid'),
body: 'Dear ' + customerEmail.firstname + ',\n\n' +
'Thank you for your order. Your order number is ' +
rec.getValue('tranid') + '.\n\nWe will notify you when it ships.'
});
}
} catch (e) {
log.error('afterSubmit Error', e.message);
}
};
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
return { afterSubmit };
});
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 |
UserEventType Constants
context.UserEventType.CREATE // New record being created
context.UserEventType.EDIT // Existing record being edited
context.UserEventType.DELETE // Record being deleted
context.UserEventType.VIEW // Record being viewed (beforeLoad only)
context.UserEventType.COPY // Record being copied
context.UserEventType.PRINT // Record being printed
context.UserEventType.EMAIL // Record being emailed
context.UserEventType.XEDIT // Inline edit (quick edit)
context.UserEventType.APPROVE // Record being approved
context.UserEventType.CANCEL // Record being cancelled
context.UserEventType.PACK // Pick/pack operation
context.UserEventType.SHIP // Ship operation
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;
// Check if status changed
const oldStatus = oldRec.getValue('status');
const newStatus = newRec.getValue('status');
if (oldStatus !== newStatus) {
log.audit('Status Changed', `From ${oldStatus} to ${newStatus}`);
// Only send email when status changes to 'Approved'
if (newStatus === 'approved' && oldStatus !== 'approved') {
// Trigger approval notification
}
}
// Check if amount increased by more than 20%
const oldAmount = oldRec.getValue('total') || 0;
const newAmount = newRec.getValue('total') || 0;
const percentChange = ((newAmount - oldAmount) / oldAmount) * 100;
if (percentChange > 20) {
log.audit('Large Amount Increase', `${percentChange.toFixed(1)}% increase`);
}
};
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
- Document execution contexts: Note when script should/shouldn't run
- 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 that respond to field changes in real-time.
Client Scripts use the N/currentRecord module to interact with form data. Unlike server scripts that use N/record, the current record module provides synchronous access to the record currently being edited.
Client Script Entry Points
Client Scripts have six entry points, each triggered by different user actions on the form:
| Entry Point | Trigger | Common Uses | Governance |
|---|---|---|---|
pageInit |
Form loads in browser | Initialize field values, hide/show fields, set defaults | 10,000 units |
fieldChanged |
User changes field value | Dynamic sourcing, conditional logic, calculated fields | 10,000 units |
postSourcing |
After sourcing completes | Override sourced values, additional lookups | 10,000 units |
lineInit |
New sublist line selected | Set line defaults, initialize line behavior | 10,000 units |
validateField |
User attempts to change field | Field-level validation, prevent invalid entries | 10,000 units |
validateLine |
User commits sublist line | Line-level validation, required line fields | 10,000 units |
validateInsert |
User inserts line (not at end) | Insert-specific validation | 10,000 units |
validateDelete |
User deletes sublist line | Prevent deletion of required lines | 10,000 units |
sublistChanged |
After line commit/delete | Update header totals, recalculate | 10,000 units |
saveRecord |
User clicks Save | Final validation, confirmation dialogs | 10,000 units |
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:
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 |
| User experience | Real-time feedback | Error after page reload |
| Bypassable? | Yes (browser tools, API calls) | No (runs on server) |
| Best for | UX improvement, non-critical validation | Critical business rules, data integrity |
Always duplicate critical validations on the server. Client Scripts only run in the browser. Records created via CSV import, web services, SuiteScript, or browser developer tools bypass Client Script validation entirely. Use Client Scripts for user experience; use User Event Scripts for enforcement.
Common Client Script Patterns
Pattern 1: Dynamic Field Show/Hide
const pageInit = (context) => {
const rec = context.currentRecord;
toggleFieldVisibility(rec);
};
const fieldChanged = (context) => {
if (context.fieldId === 'custbody_payment_type') {
toggleFieldVisibility(context.currentRecord);
}
};
const toggleFieldVisibility = (rec) => {
const paymentType = rec.getValue('custbody_payment_type');
const creditCardField = rec.getField({ fieldId: 'custbody_cc_number' });
const checkField = rec.getField({ fieldId: 'custbody_check_number' });
// Show/hide based on payment type
if (paymentType === 'credit_card') {
creditCardField.isDisplay = true;
checkField.isDisplay = false;
} else if (paymentType === 'check') {
creditCardField.isDisplay = false;
checkField.isDisplay = true;
} else {
creditCardField.isDisplay = false;
checkField.isDisplay = false;
}
};
Pattern 2: Calculated Fields
const fieldChanged = (context) => {
const rec = context.currentRecord;
const fieldId = context.fieldId;
// Recalculate margin when cost or price changes
if (fieldId === 'custbody_unit_cost' || fieldId === 'custbody_sale_price') {
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 // Prevent recursion
});
}
};
Pattern 3: 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
});
});
}
};
Pattern 4: 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, so use browser developer tools (F12) for debugging:
- Console:
console.log()statements appear here - Sources: Set breakpoints, step through code
- Network: Monitor AJAX calls from
N/httpsorN/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, Emergency | Debug in Sandbox, Audit in Production |
| Execute as Role | Role context for script execution | Typically the user's role |
Performance Considerations
Client Scripts run in the browser. Synchronous server calls (like N/search or N/https) block the UI and create poor user experience. Use these sparingly and consider caching results.
Store results in variables or the record for reuse
Batch lookups; avoid calls in frequently-triggered entry points
Prevent recursive fieldChanged calls when setting values programmatically
Move complex logic to beforeLoad for server-side calculation
Industry Patterns
BOM Validation: Validate component availability when selecting assembly items. Calculate lead times based on component stock levels. Prevent selection of components on hold.
Pricing Tiers: Dynamically update pricing when quantity thresholds are reached. Show customer-specific pricing immediately upon customer selection.
Subscription Validation: Calculate prorated amounts when start dates change. Validate term lengths and renewal dates. Auto-populate ARR/MRR fields.
Resource Availability: Check consultant availability when assigning to projects. Calculate utilization impact in real-time. Warn on overbooking.
Client Script Checklist
Scheduled Scripts
Server-side scripts that run on a schedule or on-demand to process batches of records, send notifications, and perform background maintenance tasks.
Scheduled Script Overview
Scheduled Scripts run on NetSuite's servers without user interaction. They're ideal for batch processing, data synchronization, report generation, and any operation that should happen automatically at specific times or intervals.
Scheduled Scripts have 10,000 governance units per execution. For high-volume processing, use the getRemainingUsage() method to check remaining units and yield before hitting limits. Consider Map/Reduce for very large data sets.
Scheduled Script Entry Point
Scheduled Scripts have a single entry point that receives execution context:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @NModuleScope SameAccount
*/
define(['N/search', 'N/record', 'N/email', 'N/runtime', 'N/log'],
(search, record, email, runtime, log) => {
/**
* Main execution entry point
* @param {Object} context
* @param {string} context.type - SCHEDULED, ON_DEMAND, USER_INTERFACE, ABORTED, SKIPPED
*/
const execute = (context) => {
log.audit('Script Start', `Execution Type: ${context.type}`);
try {
// Main processing logic
processOverdueInvoices();
log.audit('Script Complete', 'Processing finished successfully');
} catch (e) {
log.error('Script Error', e.message);
sendErrorNotification(e);
}
};
const processOverdueInvoices = () => {
const invoiceSearch = search.create({
type: search.Type.INVOICE,
filters: [
['status', 'anyof', 'CustInvc:A'], // Open
['duedate', 'before', 'today'],
['mainline', 'is', 'T']
],
columns: ['entity', 'tranid', 'total', 'duedate', 'daysoverdue']
});
let processedCount = 0;
const script = runtime.getCurrentScript();
invoiceSearch.run().each((result) => {
// Check governance before processing
if (script.getRemainingUsage() < 100) {
log.audit('Governance', 'Yielding - low governance remaining');
return false; // Stop iteration
}
// Process each invoice
updateOverdueStatus(result);
processedCount++;
return true; // Continue to next result
});
log.audit('Processing Complete', `Processed ${processedCount} overdue invoices`);
};
const updateOverdueStatus = (result) => {
const invoiceId = result.id;
const daysOverdue = result.getValue('daysoverdue');
record.submitFields({
type: record.Type.INVOICE,
id: invoiceId,
values: {
'custbody_overdue_flag': true,
'custbody_overdue_days': daysOverdue
}
});
};
const sendErrorNotification = (error) => {
email.send({
author: -5, // System user
recipients: ['admin@company.com'],
subject: 'Scheduled Script Error',
body: `Error in Overdue Invoice Processing:\n\n${error.message}\n\nStack: ${error.stack}`
});
};
return { execute };
});
Execution Types
The context.type parameter indicates how the script was triggered:
| Type | Description | Use Case |
|---|---|---|
SCHEDULED |
Triggered by schedule (deployment settings) | Regular automated processing |
ON_DEMAND |
Manually triggered via "Save and Execute" | Testing, one-time runs |
USER_INTERFACE |
Triggered from another script using task.submit() |
Queued from User Event or Suitelet |
ABORTED |
Previous execution was aborted | Recovery logic, cleanup |
SKIPPED |
Previous execution was skipped | Catch-up processing |
Scheduling Options
Configure scheduling in the Script Deployment record:
| 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 |
| Yearly | Specific date annually | Annual review tasks |
| Repeat | Every N minutes (15, 30, 60) | Integration polling |
Scheduled Scripts execute in the time zone of the deployment's owner (the user who created it). For consistent timing across subsidiaries, either set a single owner with a known time zone, or use script parameters to handle offsets.
Governance Management
Scheduled Scripts must manage their 10,000-unit budget carefully:
const processRecords = () => {
const script = runtime.getCurrentScript();
// Reserve units for cleanup
const RESERVE_UNITS = 500;
mySearch.run().each((result) => {
const remaining = script.getRemainingUsage();
// Log periodically
if (remaining % 1000 < 50) {
log.debug('Governance Check', `${remaining} units remaining`);
}
// Stop before hitting limit
if (remaining < RESERVE_UNITS) {
log.audit('Governance Limit', 'Stopping - reschedule needed');
rescheduleScript();
return false;
}
processRecord(result);
return true;
});
};
const rescheduleScript = () => {
const scheduledTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: runtime.getCurrentScript().id,
deploymentId: runtime.getCurrentScript().deploymentId,
params: {
'custscript_last_processed_id': lastProcessedId
}
});
scheduledTask.submit();
};
Script Parameters
Use script parameters to pass configuration and maintain state across executions:
// Access script parameters
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;
// Process from where we left off
const searchFilters = [
['internalid', 'greaterthan', lastProcessedId],
['mainline', 'is', 'T']
];
// Save progress for next run
const saveProgress = (lastId) => {
// Note: Parameters are read-only at runtime
// Use a custom record or scheduled script queue for persistent state
log.audit('Progress', `Last processed ID: ${lastId}`);
};
Triggering Scheduled Scripts Programmatically
Queue Scheduled Scripts from other scripts using the N/task module:
/**
* Queue a scheduled script from a User Event
*/
const afterSubmit = (context) => {
if (context.type !== context.UserEventType.CREATE) return;
const rec = context.newRecord;
// Check if processing needed
if (rec.getValue('custbody_needs_processing')) {
queueProcessingScript(rec.id);
}
};
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
}
});
const taskId = scheduledTask.submit();
log.audit('Task Queued', `Task ID: ${taskId} for Order: ${recordId}`);
} catch (e) {
if (e.name === 'FAILED_TO_SUBMIT_JOB_REQUEST_1') {
// Script already queued - this is OK
log.debug('Already Queued', 'Script execution already pending');
} else {
throw e;
}
}
};
Checking Script Status
const checkScriptStatus = (taskId) => {
const taskStatus = task.checkStatus({
taskId: taskId
});
log.debug('Task Status', JSON.stringify({
status: taskStatus.status,
percentComplete: taskStatus.getPercentageCompleted()
}));
// Possible statuses:
// PENDING, PROCESSING, COMPLETE, FAILED
return taskStatus.status;
};
Common Patterns
Pattern 1: Batch Email Sender
const execute = (context) => {
const script = runtime.getCurrentScript();
const batchLimit = script.getParameter({ name: 'custscript_email_batch' }) || 100;
// Find pending emails
const emailQueue = search.create({
type: 'customrecord_email_queue',
filters: [['custrecord_eq_status', 'is', 'Pending']],
columns: ['custrecord_eq_recipient', 'custrecord_eq_subject', 'custrecord_eq_body']
});
let sentCount = 0;
emailQueue.run().each((result) => {
if (sentCount >= batchLimit) return false;
if (script.getRemainingUsage() < 100) return false;
try {
email.send({
author: script.getParameter({ name: 'custscript_sender' }),
recipients: result.getValue('custrecord_eq_recipient'),
subject: result.getValue('custrecord_eq_subject'),
body: result.getValue('custrecord_eq_body')
});
// Mark as sent
record.submitFields({
type: 'customrecord_email_queue',
id: result.id,
values: { 'custrecord_eq_status': 'Sent' }
});
sentCount++;
} catch (e) {
record.submitFields({
type: 'customrecord_email_queue',
id: result.id,
values: {
'custrecord_eq_status': 'Failed',
'custrecord_eq_error': e.message
}
});
}
return true;
});
log.audit('Email Batch Complete', `Sent ${sentCount} emails`);
};
Pattern 2: Data Synchronization
const execute = (context) => {
const lastSyncDate = getLastSyncDate();
// Get records modified since last sync
const modifiedRecords = search.create({
type: search.Type.CUSTOMER,
filters: [
['lastmodifieddate', 'onorafter', lastSyncDate],
['isinactive', 'is', 'F']
],
columns: ['entityid', 'companyname', 'email', 'lastmodifieddate']
});
const script = runtime.getCurrentScript();
let syncedCount = 0;
let latestModified = lastSyncDate;
modifiedRecords.run().each((result) => {
if (script.getRemainingUsage() < 200) {
log.audit('Pause Sync', 'Will continue next run');
return false;
}
// Send to external system
const success = syncToExternalSystem(result);
if (success) {
syncedCount++;
const modDate = result.getValue('lastmodifieddate');
if (modDate > latestModified) {
latestModified = modDate;
}
}
return true;
});
// Save sync checkpoint
saveLastSyncDate(latestModified);
log.audit('Sync Complete', `Synced ${syncedCount} customers`);
};
Pattern 3: Report Generation
const execute = (context) => {
const script = runtime.getCurrentScript();
const reportDate = new Date();
// Generate report data
const reportData = generateSalesReport(reportDate);
// Create file
const reportFile = file.create({
name: `Sales_Report_${formatDate(reportDate)}.csv`,
fileType: file.Type.CSV,
contents: reportData,
folder: script.getParameter({ name: 'custscript_report_folder' })
});
const fileId = reportFile.save();
// Email report
email.send({
author: -5,
recipients: script.getParameter({ name: 'custscript_report_recipients' }).split(','),
subject: `Daily Sales Report - ${formatDate(reportDate)}`,
body: 'Please find attached the daily sales report.',
attachments: [file.load({ id: fileId })]
});
log.audit('Report Sent', `File ID: ${fileId}`);
};
Deployment Best Practices
| Setting | Recommendation | Rationale |
|---|---|---|
| Status | Testing → Released | Test thoroughly before releasing |
| Log Level | Audit (Production) | Capture key events without noise |
| Execute As Role | Dedicated script role | Controlled permissions, no user dependencies |
| Queue Limit | Set based on processing time | Prevent queue overflow |
| Yield Handling | Enable for long processes | Allow recovery from interruption |
Industry Patterns
MRP Processing: Nightly scheduled scripts to calculate material requirements, generate planned orders, and identify shortages. Batch update lead times based on supplier performance data.
Price Updates: Scheduled overnight to apply cost changes, update margin calculations, and synchronize prices with e-commerce platforms. Avoid peak business hours.
Usage Processing: Hourly scripts to pull usage metrics, calculate overages, and trigger billing events. Daily scripts for license compliance checks and usage reporting.
Donor Communications: Scheduled scripts for acknowledgment letter generation, campaign progress updates, and recurring gift processing reminders.
- 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
Scheduled Script Checklist
Map/Reduce Scripts
High-volume processing scripts that leverage parallel execution for massive data operations, with automatic governance management and yield/resume capability.
Map/Reduce Overview
Map/Reduce Scripts are NetSuite's solution for processing large data volumes that exceed Scheduled Script governance limits. They automatically handle governance, enable parallel processing, and support yield/resume for long-running operations.
- Processing more than 10,000 records
- Operations that benefit from parallel execution
- Long-running processes that may need to yield
- Data migrations and mass updates
- Complex multi-step transformations
The Four Stages
Map/Reduce Scripts execute in four sequential stages:
| Stage | Governance | Execution | Purpose |
|---|---|---|---|
| getInputData | 10,000 units | Single thread | Return search, array, or object for processing |
| map | 1,000 units per key | Parallel (up to 50) | Transform and emit key-value pairs |
| reduce | 5,000 units per key | Parallel (up to 50) | Process grouped values by key |
| summarize | 10,000 units | Single thread | Report results, handle errors, cleanup |
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) => {
/**
* Stage 1: Define the data to process
* @returns {Array|Object|Search} Input data
*/
const getInputData = () => {
log.audit('Stage', 'getInputData started');
// Option 1: Return a saved search
return search.create({
type: search.Type.SALES_ORDER,
filters: [
['status', 'anyof', 'SalesOrd:B'], // Pending Fulfillment
['mainline', 'is', 'T']
],
columns: ['entity', 'tranid', 'total', 'trandate']
});
// Option 2: Return an array
// return [1, 2, 3, 4, 5];
// Option 3: Return a search ID
// return search.load({ id: 'customsearch_pending_orders' });
// Option 4: Return an object
// return { key1: 'value1', key2: 'value2' };
};
/**
* Stage 2: Process each input element
* @param {Object} context
* @param {string} context.key - Unique key (search result ID or index)
* @param {string} context.value - JSON string of the data
*/
const map = (context) => {
const searchResult = JSON.parse(context.value);
const orderId = searchResult.id;
const customerId = searchResult.values.entity.value;
log.debug('Map', `Processing Order ${orderId}`);
try {
// Perform some transformation or lookup
const orderData = processOrder(orderId);
// Emit key-value pair for reduce stage
// Group by customer for aggregation
context.write({
key: customerId,
value: {
orderId: orderId,
total: orderData.total,
status: orderData.status
}
});
} catch (e) {
log.error('Map Error', `Order ${orderId}: ${e.message}`);
// Errors here don't stop other map executions
}
};
/**
* Stage 3: Process grouped values
* @param {Object} context
* @param {string} context.key - The grouping key
* @param {Iterator} context.values - Iterator of values for this key
*/
const reduce = (context) => {
const customerId = context.key;
let totalAmount = 0;
let orderCount = 0;
// Iterate through all values for this key
context.values.forEach((valueJson) => {
const value = JSON.parse(valueJson);
totalAmount += value.total;
orderCount++;
});
log.debug('Reduce', `Customer ${customerId}: ${orderCount} orders, $${totalAmount}`);
// Update customer record with aggregated data
try {
record.submitFields({
type: record.Type.CUSTOMER,
id: customerId,
values: {
'custentity_pending_order_total': totalAmount,
'custentity_pending_order_count': orderCount
}
});
// Write success for summarize
context.write({
key: 'success',
value: customerId
});
} catch (e) {
context.write({
key: 'error',
value: { customerId, error: e.message }
});
}
};
/**
* Stage 4: Summarize results
* @param {Object} summary - Contains execution summary
*/
const summarize = (summary) => {
log.audit('Summarize', 'Processing complete');
// Log usage statistics
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
}));
// Count successes and errors
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;
});
// Handle any errors from map/reduce stages
summary.mapSummary.errors.iterator().each((key, error) => {
log.error('Map Stage Error', `Key: ${key}, Error: ${error}`);
return true;
});
summary.reduceSummary.errors.iterator().each((key, error) => {
log.error('Reduce Stage Error', `Key: ${key}, Error: ${error}`);
return true;
});
// Send completion notification
sendSummaryEmail(successCount, errorList);
};
// Helper functions
const processOrder = (orderId) => {
const orderRecord = record.load({
type: record.Type.SALES_ORDER,
id: orderId
});
return {
total: orderRecord.getValue('total'),
status: orderRecord.getValue('status')
};
};
const sendSummaryEmail = (successCount, errors) => {
email.send({
author: -5,
recipients: ['admin@company.com'],
subject: 'Map/Reduce Processing Complete',
body: `Processed ${successCount} customers successfully.\n\nErrors: ${errors.length}`
});
};
return {
getInputData,
map,
reduce,
summarize
};
});
Parallel Execution
Map and Reduce stages run in parallel across multiple processors:
By default, NetSuite uses up to 5 parallel processors. This can be configured in the deployment up to a maximum of 50 concurrent processes, depending on your account's service tier.
// Check concurrency in summarize
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);
// Process each record independently
record.submitFields({
type: record.Type.CUSTOMER,
id: data.id,
values: { 'custentity_processed': true }
});
// Don't call context.write() - skip reduce stage
};
// Return null to skip reduce
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');
// Skip header row, return data rows
return lines.slice(1).map((line, index) => ({
lineNumber: index + 2,
data: line
}));
};
const map = (context) => {
const input = JSON.parse(context.value);
const lineData = input.data.split(',');
// Process CSV row
// ...
};
Pattern 4: Yield Point Recovery
const getInputData = () => {
const script = runtime.getCurrentScript();
// Check if resuming from a yield
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 the error - doesn't stop other map executions
log.error('Map Error', {
key: context.key,
error: e.message,
stack: e.stack
});
// Optionally write error for summarize tracking
context.write({
key: 'error',
value: JSON.stringify({
key: context.key,
error: e.message
})
});
}
};
const summarize = (summary) => {
// Check for input stage errors
if (summary.inputSummary.error) {
log.error('Input Error', summary.inputSummary.error);
sendAlertEmail('getInputData failed', summary.inputSummary.error);
return;
}
// Iterate all map errors
let mapErrorCount = 0;
summary.mapSummary.errors.iterator().each((key, error) => {
mapErrorCount++;
log.error('Map Error', `Key ${key}: ${error}`);
return true; // Continue iteration
});
// Iterate all reduce errors
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
Return a search object rather than running it and returning results. Let NetSuite stream the data.
Each map invocation has only 1,000 units. Move heavy processing to reduce (5,000 units).
Keys determine grouping in reduce. Too few unique keys = underutilized parallelism. Too many = no aggregation benefit.
record.submitFields() uses 2 units vs 20+ for full record save.
Monitoring Progress
// Check status from another script
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
BOM Cost Rollup: Map: Calculate component costs. Reduce: Aggregate by assembly. Process thousands of assemblies in parallel to update standard costs.
Mass Price Updates: Map: Calculate new prices per item. Reduce: Update items by vendor or category. Handle hundreds of thousands of items efficiently.
Usage Billing: Map: Process usage events. Reduce: Aggregate by customer and billing period. Generate invoices for high-volume usage data.
Time Entry Processing: Map: Validate time entries. Reduce: Aggregate by project/employee. Process month-end billing for thousands of consultants.
Map/Reduce Scripts share the same queue as Scheduled Scripts. During busy periods, scripts may wait in queue. Plan execution timing for large processes accordingly.
Map/Reduce Checklist
Suitelets & Restlets
Custom endpoints for building custom UI pages (Suitelets) and RESTful web service APIs (Restlets) that extend NetSuite's functionality.
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
* @NModuleScope SameAccount
*/
define(['N/ui/serverWidget', 'N/search', 'N/record', 'N/redirect'],
(serverWidget, search, record, redirect) => {
/**
* Entry point for Suitelet
* @param {Object} context
* @param {ServerRequest} context.request - HTTP request
* @param {ServerResponse} context.response - HTTP response
*/
const onRequest = (context) => {
if (context.request.method === 'GET') {
showForm(context);
} else {
processSubmission(context);
}
};
const showForm = (context) => {
// Create a form using N/ui/serverWidget
const form = serverWidget.createForm({
title: 'Custom Data Entry Form'
});
// Add fields
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'
});
// Add submit button
form.addSubmitButton({
label: 'Save Record'
});
// Write form to response
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;
// Create or update record
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 to the new record
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: 'phone', type: serverWidget.FieldType.PHONE, label: 'Phone' });
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' });
form.addField({ id: 'item', type: serverWidget.FieldType.SELECT, label: 'Item', source: 'item' });
// Other fields
form.addField({ id: 'check', type: serverWidget.FieldType.CHECKBOX, label: 'Active' });
form.addField({ id: 'file', type: serverWidget.FieldType.FILE, label: 'Upload File' });
form.addField({ id: 'html', type: serverWidget.FieldType.INLINEHTML, label: '' }).defaultValue = '<p>Custom HTML</p>';
Sublists
// Add a sublist
const sublist = form.addSublist({
id: 'custpage_items',
type: serverWidget.SublistType.INLINEEDITOR, // or LIST, STATICLIST
label: 'Line Items'
});
// Add sublist columns
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
// Add tabs
form.addTab({ id: 'maintab', label: 'Main Info' });
form.addTab({ id: 'detailstab', label: 'Details' });
// Add field groups
form.addFieldGroup({ id: 'addressgroup', label: 'Address', tab: 'maintab' });
// Add fields to tab/group
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
});
In the deployment record, checking "Available Without Login" makes the Suitelet publicly accessible. Use this carefully and implement your own authentication for sensitive data.
Restlet Basics
Restlets provide RESTful API endpoints for external integrations:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/search', 'N/log'],
(record, search, log) => {
/**
* GET request handler - retrieve data
* @param {Object} requestParams - URL query parameters
* @returns {Object} Response data
*/
const get = (requestParams) => {
log.debug('GET Request', JSON.stringify(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 };
}
};
/**
* POST request handler - create data
* @param {Object} requestBody - Request body (parsed JSON)
* @returns {Object} Response data
*/
const post = (requestBody) => {
log.debug('POST Request', JSON.stringify(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 successfully'
};
} catch (e) {
return { success: false, error: e.message };
}
};
/**
* PUT request handler - update data
* @param {Object} requestBody - Request body
* @returns {Object} Response data
*/
const put = (requestBody) => {
log.debug('PUT Request', JSON.stringify(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: values
});
return {
success: true,
message: 'Customer updated successfully'
};
} catch (e) {
return { success: false, error: e.message };
}
};
/**
* DELETE request handler - remove data
* @param {Object} requestParams - URL parameters
* @returns {Object} Response data
*/
const doDelete = (requestParams) => {
log.debug('DELETE Request', JSON.stringify(requestParams));
if (!requestParams.id) {
return { error: 'Record ID required for deletion' };
}
try {
record.delete({
type: 'customrecord_my_record',
id: requestParams.id
});
return {
success: true,
message: 'Record deleted successfully'
};
} catch (e) {
return { success: false, error: e.message };
}
};
return {
get: get,
post: post,
put: put,
delete: doDelete // 'delete' is reserved word
};
});
Restlet Authentication
Restlets require authentication. Common methods:
| Method | Use Case | Header Format |
|---|---|---|
| Token-Based Auth (TBA) | Recommended for production integrations | 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
// Example: Calling a Restlet from JavaScript (external)
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));
// From SuiteScript (internal)
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
Customer Portal: Suitelets for self-service license management, usage dashboards, and support ticket submission. Restlets for product activation and usage reporting APIs.
B2B Portal: Suitelet-based order entry forms for customers. Restlet APIs for real-time inventory checks and order status from partner systems.
Shop Floor Integration: Restlets receiving production data from IoT devices and MES systems. Suitelets for work order kiosks on the floor.
Time Entry Portal: Suitelet for consultants to enter time and expenses without full NetSuite access. Restlets for mobile app time submission.
Suitelet/Restlet Checklist
Workflow Automation
Design and implement business process automation using SuiteFlow workflows.
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
Core Elements
| 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 default values, 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
Navigation: Customization → Workflow → Workflows → New
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 |
"Keep Instance and Allow Re-initiate" - Enable this for approval workflows to ensure the workflow continues when records are edited during the approval process. Without this, editing a record cancels the workflow.
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's the standard pattern:
States
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 |
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
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:
Email Notifications
Dynamic Email Content
Use merge fields in email templates:
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
Navigation: Record → System Information → Workflow History subtab
- 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
SuiteFlow Advanced
Advanced workflow techniques including custom actions, sublist handling, and workflow-script integration.
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
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
Sublist Modification in Custom Actions
Standard workflow actions (Set Field Value) cannot modify sublist fields directly. Use custom actions for any sublist modifications. The record must be loaded in dynamic mode for line operations.
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
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
Workflow-Script Integration
Workflows and scripts can work together for comprehensive automation.
Initiating Workflows from SuiteScript
Triggering Workflow Transitions
Reading Workflow State in Scripts
Parallel & Sequential Approvals
Sequential Approval Pattern
Parallel Approval Pattern
NetSuite workflows don't have native parallel states. Implement parallel approvals using custom checkbox fields for each approver. All approvers can edit the record to set their approval field, and a transition fires when all fields are checked.
Escalation & Delegation
Time-Based Escalation
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
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 |
SuiteFlow Advanced Checklist
Formula Fields & Expressions
Master formula syntax for calculated fields, saved searches, and workflow conditions.
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}) |
| LTRIM() / RTRIM() | Remove left/right spaces | LTRIM({zipcode}, '0') |
| 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
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}) |
| SQRT() | Square root | SQRT({area}) |
| SIGN() | Return -1, 0, or 1 | SIGN({balance}) |
| GREATEST() | Maximum of values | GREATEST({qty1}, {qty2}, 0) |
| LEAST() | Minimum of values | LEAST({available}, {ordered}) |
Common Numeric Patterns
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 |
| YY | 2-digit year | 24 |
| MM | Month (01-12) | 03 |
| Mon | Month abbreviation | Mar |
| Month | Full month name | March |
| DD | Day of month | 15 |
| DY | Day abbreviation | Fri |
| Day | Full day name | Friday |
| HH24 | Hour (00-23) | 14 |
| MI | Minutes | 30 |
| SS | Seconds | 45 |
| Q | Quarter | 1 |
| WW | Week of year | 11 |
Common Date Patterns
Conditional Logic
CASE Expressions
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
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
Saved Search Formula Patterns
Join Field Access
Common Search Formulas
Industry Formula Applications
Manufacturing
Wholesale/Distribution
Professional Services
Retail
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 at /app/help/helpcenter.nl?fid=section_n3359618.html
- 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