Building a bulk approval tool in NetSuite with a Suitelet and Map/Reduce

Published on
-
8 mins read
Authors

Month end in a multi-subsidiary NetSuite account often looks the same: an approver opens an expense report, clicks Approve, goes back to the list, opens the next one, and repeats that a few hundred times. NetSuite has no built-in bulk approval for most custom approval flows, so this becomes a real cost in time and in late closes.

In this article, I'll walk through a bulk approval tool I built for a client on a NetSuite partner team. Client details are anonymized, and the code is simplified to show the pattern. The same design was later reused for a second client with a completely different approval model, which I cover in the follow-up article.

We'll cover:

  • The constraints that shaped the design
  • The architecture: a Suitelet, two custom records, and a Map/Reduce
  • Reusing the existing approval workflow instead of replacing it
  • Making new record types a config change, not a code change
  • How results are reported back to the approver

Prerequisites

To follow along, you should be comfortable with:

  • SuiteScript 2.1, especially Suitelets and Map/Reduce scripts
  • SuiteFlow workflows and the N/workflow module
  • Custom records and saved searches
  • SuiteCloud Development Framework (SDF) for deployments

The problem and the constraints

The client approves several record types (expense reports, payment requests, and a few custom records) through SuiteFlow workflows. Each workflow has its own states, approver rules, and side effects such as emails and journal creation. These workflows were already tested and audited.

That gave us four hard constraints:

  1. No second approval path. Bulk approval must run the same logic as clicking Approve on a single record. If it sets approvalstatus directly, it skips everything the workflow does.
  2. No long requests. Approving 300 records inside one Suitelet request would hit governance limits and the request timeout.
  3. Traceable results. The approver must be able to see, after the fact, which records succeeded, which failed, and why.
  4. Extensible. Finance will ask for more record types later. Adding one should not need a new script.

The architecture

The design has four parts:

  • A Suitelet where the approver picks a category, filters the list, marks records, and submits Approve or Reject with a memo
  • A batch custom record for the request as a whole: category, action, submitter, acting approver, memo, status, counts, and task ID
  • A line custom record for each selected transaction: record type, record ID, status, error message, and processed date
  • A Map/Reduce script that processes the lines in the background

The Suitelet does almost no work on submit. It writes the batch and its lines, queues the Map/Reduce with the batch ID as a parameter, and returns. Step through the flow below:

Bulk approval, from click to result

Step 1 / 7
ApproverbrowserSuiteletbulk approval pageMap/Reducebackground taskWorkflowexisting approval1Filter + select2Submit Approve3Create batch + lines4task.submit()5map(): trigger per line6Approved7summarize(): roll up

1. Filter + select

The approver picks a category such as Expense Report, filters by subsidiary, date, or amount, and marks the records to approve. The list comes from a saved search for that category.

Click an arrow to jump to that step
The Suitelet only records intent. The Map/Reduce does the work, one line per map call, through the existing workflow.

Reusing the workflow instead of replacing it

This is the most important design decision. The Map/Reduce never sets an approval status itself. Instead, each workflow got one extra transition, triggered by a custom body field that only the bulk tool writes:

function approveLine(line, config) {
// 1. Tell the workflow what to do
record.submitFields({
type: config.recordType,
id: line.recordId,
values: {
[config.bulkActionField]: line.action, // 'APPROVE' or 'REJECT'
[config.bulkMemoField]: line.memo,
[config.bulkBatchField]: line.batchId,
},
options: { enableSourcing: false, ignoreMandatoryFields: true },
})
// 2. Let the existing workflow run its own approval transition
workflow.trigger({
recordType: config.recordType,
recordId: line.recordId,
workflowId: config.workflowId,
})
// 3. Clear the trigger so a later manual edit does not re-run it
record.submitFields({
type: config.recordType,
id: line.recordId,
values: { [config.bulkActionField]: '' },
options: { enableSourcing: false, ignoreMandatoryFields: true },
})
}

Because the workflow does the approval, every rule, email, and downstream action still runs. If the workflow changes next year, bulk approval gets the change for free.

Clear the trigger field

Step 3 matters. If the action field stays set, any later save that re-evaluates the workflow can fire the bulk transition again.

Checking eligibility at processing time

Records can change between the moment the approver marks them and the moment the Map/Reduce reaches them. Another approver might act first, or the record might move to a different level. So each map call looks up the record's current status and current approver before doing anything:

function map(context) {
const line = JSON.parse(context.value)
const config = getRecordConfig(line.category)
updateLine(line.lineId, { status: 'Processing' })
try {
const current = search.lookupFields({
type: config.recordType,
id: line.recordId,
columns: [config.statusField, config.currentApproverField],
})
if (!isEligible(config, current, line)) {
updateLine(line.lineId, {
status: 'Skipped',
error: 'Current approver does not match the batch approver.',
})
return
}
approveLine(line, config)
updateLine(line.lineId, { status: 'Success', error: '' })
context.write({ key: line.batchId, value: 'Success' })
} catch (e) {
updateLine(line.lineId, { status: 'Failed', error: e.message })
context.write({ key: line.batchId, value: 'Failed' })
}
}

A record that is no longer eligible is marked Skipped with a reason, not Failed. That distinction helps the approver: Skipped usually means "someone already handled it", Failed means "look at this".

Making record types a config change

Each category is a config entry. The Suitelet, the Map/Reduce, and the library all read from it:

const BULK_APPROVAL_CONFIG = {
expensereport: {
label: 'Expense Report',
recordType: 'expensereport',
savedSearchId: 'customsearch_bulk_pending_er',
statusField: 'approvalstatus',
currentApproverField: 'custbody_next_approver',
currentApproverType: 'employee',
workflowId: 'customworkflow_er_approval',
bulkActionField: 'custbody_bulk_action',
},
// paymentrequest: { ... },
}

The list in the Suitelet comes from the category's saved search, and its columns are built from the search's own columns. Finance can add a column to the saved search without a deployment.

A validateRecordConfig check runs before any line is processed. If a category is half-configured, for example the workflow ID is still a placeholder, its lines fail with Config missing: workflowId instead of doing something unexpected.

Reporting results

The batch record is the receipt. The approver sees the status, the counts, the start and end time, and a sublist of lines with each record's link, status, and error message.

summarize sets the final batch status from the line statuses:

function rollUp(lines) {
const failed = lines.filter((l) => l.status === 'Failed').length
const success = lines.filter((l) => l.status === 'Success').length
if (failed === 0) return 'Completed'
if (success === 0) return 'Failed'
return 'Partial Success'
}

The library functions (config validation, filter building, search result mapping, and status roll-up) are pure enough to unit test with Jest, and they have tests. The NetSuite-specific calls stay thin.

Conclusion

The tool itself is not complicated. What made it work in production was a few decisions:

  • The Map/Reduce triggers the existing workflow instead of writing approval fields
  • Eligibility is checked again when each line runs, not only when it is selected
  • One line failing never fails the batch
  • Record types live in config, with a check for incomplete config

Two follow-ups cover what happened next: porting the same tool to a client with a custom approval-route engine, and a bug where large batches rolled approvals back.