Chapter 7.1

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 Versions
  • 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
💼 The Configuration-First Principle

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.

⚠️ Governance Limits by Script Type
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

Governance Unit Consumption
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:

Basic Script Structure (SuiteScript 2.1)
/**
 * @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.

1
Upload Script File

Upload .js file to File Cabinet (SuiteScripts folder recommended)

2
Create Script Record

Select script file, configure script parameters if needed

3
Create Deployment

Link script to record types/contexts, set execution roles

4
Set Status to Released

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

💡 SuiteScript Development Tips
  • 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
Error Handling Pattern
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:

ℹ️ SDF Benefits
  • 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

Chapter 7.2

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.

ℹ️ User Event Entry Points
  • 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
beforeLoad Example - Add Custom Button
/**
 * @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 };
});
⚠️ beforeLoad Limitations
  • 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
beforeSubmit Example - Validation & Auto-Population
/**
 * @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
afterSubmit Example - Create Related Records & Send Email
/**
 * @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 Type 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:

Detecting Field 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:

Checking Execution Context
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

💼 User Event Script 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
⚠️ Common Mistakes
  • 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

Safe Record Update Pattern
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

Chapter 7.3

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.

SuiteScript 2.1 Module

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:

1
pageInit
Form loads
2
fieldChanged
User edits field
3
postSourcing
Sourcing completes
4
validateField
Before value set
5
lineInit
Select new line
6
validateLine
Commit line
7
sublistChanged
Line committed
8
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
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
Critical: Duplicate Validation

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

Browser Developer Tools

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/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, Emergency Debug in Sandbox, Audit in Production
Execute as Role Role context for script execution Typically the user's role

Performance Considerations

Avoid Synchronous Server Calls

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.

1
Cache Lookup Results

Store results in variables or the record for reuse

2
Minimize Server Calls

Batch lookups; avoid calls in frequently-triggered entry points

3
Use ignoreFieldChange

Prevent recursive fieldChanged calls when setting values programmatically

4
Consider User Event Alternative

Move complex logic to beforeLoad for server-side calculation

Industry Patterns

🏭 Manufacturing

BOM Validation: Validate component availability when selecting assembly items. Calculate lead times based on component stock levels. Prevent selection of components on hold.

📦 Wholesale

Pricing Tiers: Dynamically update pricing when quantity thresholds are reached. Show customer-specific pricing immediately upon customer selection.

💻 Software/SaaS

Subscription Validation: Calculate prorated amounts when start dates change. Validate term lengths and renewal dates. Auto-populate ARR/MRR fields.

🔧 Services

Resource Availability: Check consultant availability when assigning to projects. Calculate utilization impact in real-time. Warn on overbooking.

Client Script Checklist

Chapter 7.4

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.

Governance: 10,000 Units

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
Time Zone Awareness

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

🏭 Manufacturing

MRP Processing: Nightly scheduled scripts to calculate material requirements, generate planned orders, and identify shortages. Batch update lead times based on supplier performance data.

📦 Wholesale

Price Updates: Scheduled overnight to apply cost changes, update margin calculations, and synchronize prices with e-commerce platforms. Avoid peak business hours.

💻 Software/SaaS

Usage Processing: Hourly scripts to pull usage metrics, calculate overages, and trigger billing events. Daily scripts for license compliance checks and usage reporting.

❤️ Nonprofit

Donor Communications: Scheduled scripts for acknowledgment letter generation, campaign progress updates, and recurring gift processing reminders.

When to Use Scheduled vs Map/Reduce
  • 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

Chapter 7.5

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.

When to Use Map/Reduce
  • 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:

1
getInputData
Define data set
2
map
Process each input
3
reduce
Aggregate by key
4
summarize
Final processing
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:

Concurrency Settings

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

1
Minimize getInputData Work

Return a search object rather than running it and returning results. Let NetSuite stream the data.

2
Keep Map Functions Light

Each map invocation has only 1,000 units. Move heavy processing to reduce (5,000 units).

3
Choose Keys Wisely

Keys determine grouping in reduce. Too few unique keys = underutilized parallelism. Too many = no aggregation benefit.

4
Use submitFields Over record.save()

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

🏭 Manufacturing

BOM Cost Rollup: Map: Calculate component costs. Reduce: Aggregate by assembly. Process thousands of assemblies in parallel to update standard costs.

📦 Wholesale

Mass Price Updates: Map: Calculate new prices per item. Reduce: Update items by vendor or category. Handle hundreds of thousands of items efficiently.

💻 Software/SaaS

Usage Billing: Map: Process usage events. Reduce: Aggregate by customer and billing period. Generate invoices for high-volume usage data.

🔧 Services

Time Entry Processing: Map: Validate time entries. Reduce: Aggregate by project/employee. Process month-end billing for thousands of consultants.

Queue Priority

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

Chapter 7.6

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
});
Available Without Login

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

💻 Software/SaaS

Customer Portal: Suitelets for self-service license management, usage dashboards, and support ticket submission. Restlets for product activation and usage reporting APIs.

📦 Wholesale

B2B Portal: Suitelet-based order entry forms for customers. Restlet APIs for real-time inventory checks and order status from partner systems.

🏭 Manufacturing

Shop Floor Integration: Restlets receiving production data from IoT devices and MES systems. Suitelets for work order kiosks on the floor.

🔧 Services

Time Entry Portal: Suitelet for consultants to enter time and expenses without full NetSuite access. Restlets for mobile app time submission.

Suitelet/Restlet Checklist

Chapter 7.7

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
Important Setting

"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

┌─────────────────────────────────────────────────────────────────────┐ │ APPROVAL WORKFLOW FLOW │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Draft │────▶│ Pending Approval │────▶│ Manager Review │ │ │ │ │ │ │ │ │ │ │ └──────────┘ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ │ │ │ Reject │ │ │ ▼ ▼ ▼ │ │ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ N/A │ │ Rejected │◀────│ Approved │ │ │ │ │ │ │ │ │ │ │ └──────────┘ └──────────────────┘ └──────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘

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

// 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} --- This is an automated message from NetSuite.

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

Chapter 7.8

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

/** * @NApiVersion 2.1 * @NScriptType WorkflowActionScript * @NModuleScope SameAccount */ define(['N/record', 'N/search', 'N/runtime', 'N/log'], (record, search, runtime, log) => { /** * Entry point for workflow action * @param {Object} context * @param {Record} context.newRecord - Record being processed * @param {Record} context.oldRecord - Previous state (if applicable) * @param {string} context.workflowId - Workflow internal ID * @param {string} context.type - Execution type (create, edit, etc.) * @returns {string|number|boolean} - Value to return to workflow */ const onAction = (context) => { try { const rec = context.newRecord; const customerId = rec.getValue('entity'); // Perform custom logic const creditLimit = lookupCreditLimit(customerId); const orderTotal = rec.getValue('total'); if (orderTotal > creditLimit) { // Return value can be used in workflow conditions 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

/** * Custom action to process all item lines */ 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 result for workflow condition return hasBackorder ? 'BACKORDER' : 'AVAILABLE'; };

Sublist Modification in Custom Actions

/** * Add line items via custom action * NOTE: Must load record in dynamic mode */ const onAction = (context) => { const recId = context.newRecord.id; const recType = context.newRecord.type; // Load record in dynamic mode for line edits const rec = record.load({ type: recType, id: recId, isDynamic: true }); // Add new line rec.selectNewLine({ sublistId: 'item' }); rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: 123 // Item internal ID }); rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: 1 }); rec.commitLine({ sublistId: 'item' }); rec.save(); return 'LINE_ADDED'; };
Sublist Limitations

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

// 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 (with saved search) // Condition: Custom field {custbody_accrual_type} = "Auto" // Action: Custom action to create reversal // 4. Anniversary Notifications // Schedule: Daily // Record: Customer // Condition: MONTH({custentity_anniversary}) = MONTH({today}) // AND DAY({custentity_anniversary}) = DAY({today}) // Action: Send Campaign Email

Workflow Buttons

Add custom buttons to records that trigger workflow transitions.

Creating Workflow Buttons

  1. Create a state with desired button action
  2. Add transition from that state
  3. Set transition trigger to "Button"
  4. 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

/** * Trigger workflow from User Event Script */ define(['N/workflow'], (workflow) => { const afterSubmit = (context) => { if (context.type === context.UserEventType.CREATE) { // Initiate specific workflow workflow.initiate({ recordType: context.newRecord.type, recordId: context.newRecord.id, workflowId: 'customworkflow_approval' }); } }; return { afterSubmit }; });

Triggering Workflow Transitions

/** * Trigger workflow transition from script */ 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

/** * Check current workflow state */ 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

┌─────────────────────────────────────────────────────────────────────┐ │ SEQUENTIAL APPROVAL │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Submit │───▶│ Manager │───▶│ Director │───▶│ VP │ │ │ │ │ │ Approval │ │ Approval │ │ Approval │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ │ │ │ Reject │ Reject │ │ │ ▼ ▼ ▼ │ │ ┌────────────────────────────────────────┐ │ │ │ Rejected │ │ │ └────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ // Transitions based on amount thresholds Manager → Director: Amount > $10,000 Director → VP: Amount > $50,000

Parallel Approval Pattern

┌─────────────────────────────────────────────────────────────────────┐ │ PARALLEL APPROVAL │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ │ │ ┌───▶│ Finance Approval │───┐ │ │ │ └──────────────────┘ │ │ │ ┌──────────┤ ├──▶┌──────────────┐ │ │ │ Submit │ │ │ All Approved │ │ │ │ │ ┌──────────────────┐ │ └──────────────┘ │ │ └──────────┼───▶│ Legal Approval │───┘ │ │ │ └──────────────────┘ │ │ │ │ │ │ │ │ ┌──────────────────┐ │ │ │ └───▶│ Ops Approval │───┘ │ │ └──────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ // Implementation using checkbox fields Transition to "All Approved" when: {custbody_finance_approved} = T AND {custbody_legal_approved} = T AND {custbody_ops_approved} = T
Parallel Approval Strategy

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

// Scheduled workflow for escalation // Runs daily at 9:00 AM // Record: Sales Order (or custom record tracking approvals) // 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

/** * Custom action to find delegate approver */ const onAction = (context) => { const rec = context.newRecord; const originalApprover = rec.getValue('custbody_approver'); // Check for active delegation 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 delegate ID to set via workflow return delegateSearch[0].getValue('custrecord_delegate_to'); } // No delegation - return original 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

SuiteFlow Advanced Checklist

Chapter 7.9

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

// 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 with concatenation NVL({altname}, {entityid}) // Extract file extension SUBSTR({name}, INSTR({name}, '.', -1) + 1)

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

// Margin calculation ROUND(({amount} - {costestimate}) / NULLIF({amount}, 0) * 100, 2) // Prevent division by zero {numerator} / NULLIF({denominator}, 0) // Calculate days between orders {trandate} - NVL({custbody_lastorder}, {trandate}) // 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
YYYY4-digit year2024
YY2-digit year24
MMMonth (01-12)03
MonMonth abbreviationMar
MonthFull month nameMarch
DDDay of month15
DYDay abbreviationFri
DayFull day nameFriday
HH24Hour (00-23)14
MIMinutes30
SSSeconds45
QQuarter1
WWWeek of year11

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 // First day of fiscal year (July) CASE WHEN EXTRACT(MONTH FROM SYSDATE) >= 7 THEN TO_DATE(EXTRACT(YEAR FROM SYSDATE) || '-07-01', 'YYYY-MM-DD') ELSE TO_DATE((EXTRACT(YEAR FROM SYSDATE) - 1) || '-07-01', 'YYYY-MM-DD') END // Quarter name 'Q' || TO_CHAR({trandate}, 'Q') || ' ' || TO_CHAR({trandate}, 'YYYY') // Business days calculation (simplified - excludes weekends) {enddate} - {startdate} - (TRUNC(({enddate} - {startdate}) / 7) * 2)

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') // Equivalent to CASE CASE {custbody_status} WHEN 1 THEN 'New' WHEN 2 THEN 'In Progress' WHEN 3 THEN 'Completed' WHEN 4 THEN 'On Hold' ELSE 'Unknown' END

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 (use in formula column with same summary) {amount} / SUM({amount}) * 100 // Running total (requires specific search setup) SUM({amount}) OVER (ORDER BY {trandate}) // 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 // Transaction link (HTML in saved search) '' || {tranid} || '' // Status indicator with color '' || {status} || ''

Industry Formula Applications

Manufacturing

// Scrap percentage ROUND(({quantityscrapped} / NULLIF({quantity}, 0)) * 100, 2) || '%' // Production efficiency ROUND(({quantitycompleted} / NULLIF({quantityplanned}, 0)) * 100, 1) // Days until due date {custcol_due_date} - TRUNC(SYSDATE) // Yield rate ROUND(({quantitycompleted} / NULLIF({quantitystarted}, 0)) * 100, 2)

Wholesale/Distribution

// Inventory turns {quantitysold} / NULLIF({averagecost}, 0) // 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) // Hours remaining {estimatedhours} - {actualhours} // 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) // Units per transaction {unitsold} / 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 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

Formula Fields Checklist