Porting NetSuite bulk approval to a custom approval engine, and the bugs production found

Published on
-
8 mins read
Authors

In the previous article, I built a bulk approval tool that triggers existing SuiteFlow workflows from a Map/Reduce. A second client wanted the same thing for Journal Entries. The catch: their approvals don't run on SuiteFlow at all. They run on a custom approval-route engine built in SuiteScript.

In this article, I'll cover how the design carried over, what had to change, and the bugs that showed up after go-live. As before, client details are anonymized and the code is simplified.

We'll cover:

  • How a custom approval-route engine works
  • What carried over from the workflow version, and what didn't
  • Five production bugs and how each was fixed

Prerequisites

You should know SuiteScript 2.1 Suitelets, Map/Reduce scripts, saved searches (including summary searches), and SDF deployments. Reading the first article helps, but isn't required.

How the approval-route engine works

When a Journal Entry is submitted, the engine generates route records, one per approver per level. Each route has a sequence (the level), an approver, the approver's role at the time, and a status. Levels can use OR logic: if three people at level 2 can approve, any one of them is enough, and the other two routes are closed.

Every action also writes an approval log row. Level 0 is the requestor's "Submitted" row.

One Journal Entry through the approval routes

Step 1 / 5
Approverlevel 2Route engineaction libraryRoutescustom recordsJournal Entryheader status1Approve2Close my route3Close OR siblings4Next pending level?5Promote or finalize

1. Approve

An approver at level 2 approves. In bulk, this comes from the Map/Reduce instead of a button.

Click an arrow to jump to that step
Approving at a level closes sibling routes for the same role, then either promotes to the next level or finalizes the JE.

What carried over and what didn't

The batch record, line record, Suitelet, and Map/Reduce structure carried over almost unchanged. The difference is one function: what "approve this line" means.

In the workflow version, the Map/Reduce sets a trigger field and calls workflow.trigger(). Here, there's no workflow to trigger. So I wrote an action library that runs the same steps as the single-record Approve and Reject buttons, and the Map/Reduce calls it:

function approveJournalEntry({ tranId, actingUserId, actingRoleId, memo }) {
const modify = getLatestActiveModify(tranId)
const sequence = getNextPendingSequence(modify.id)
const route = closeOwnRoute(modify.id, sequence, actingUserId, memo)
closeSameRoleOrRoutes(modify.id, sequence, route.id, route.role)
const nextSeq = getNextPendingSequence(modify.id)
if (nextSeq) {
promoteToNextLevel(tranId, modify.id, nextSeq)
} else {
finalizeApproval(tranId)
}
}

The single-record buttons and the bulk tool now share this library, so there's still only one approval path.

This client also asked for two extras: a Journal Category filter, and an Export to Excel button on the pending list. The export builds a real .xlsx in the browser with a vendored SheetJS build, patched so it loads through NetSuite's AMD module loader.

Bug 1: "Current Approver" didn't restrict anything

The page has a Current Approver field, so an admin can approve on behalf of a specific approver. Testing showed an admin could approve records that weren't waiting for that approver at all.

This bug showed up in both clients' versions of the tool. There were two causes:

  • The Suitelet stored the logged-in user's role on the batch, not the Current Approver chosen on the page
  • The Map/Reduce deployment runs as Administrator, and the eligibility check had an "admin can do anything" bypass. Since the script always runs as admin, the check never ran

The fix: store the chosen approver on the batch, remove the bypass, and always compare the record's live current approver with the batch approver. A mismatch marks the line Skipped with a reason.

Execute as role

When a script deployment runs as Administrator, any "skip this check for admins" logic inside it is always true. Check against the user the action is for, not the user the script runs as.

Bug 2: OR-logic siblings stayed open

After a bulk approval, some levels still showed open routes for other approvers, even though one approver at that level had already approved.

The sibling clean-up matched routes by joining to each approver's current employee role. If an employee's role changed after the routes were generated, the join returned a different role, and those siblings were never closed.

The fix: match on the role captured on the route when it was generated. While in that code, I also added two statuses to the list of terminal statuses that clean-up never touches:

  • Submitted, the requestor's level-0 log row
  • Skipped, used by force-approved routes

Without them, a clean-up sweep could mark the requestor's own row as Cancelled. I also wrapped the route regeneration after a reject in its own try/catch, so a regeneration error can't skip the header status update that follows it.

Bug 3: EXCEEDED_MAX_FIELD_LENGTH on the Suitelet

The bulk page crashed for some filters with EXCEEDED_MAX_FIELD_LENGTH. The cause was one long memo in a saved search column.

NetSuite caps sublist cells of type TEXT at 300 characters. A single value over that limit fails the whole page, not just the cell. The fix: unbounded columns (memo, error message, and some hidden fields) use TEXTAREA, and every value is trimmed to a safe length before setSublistValue.

const LIMITS = { text: 300, textarea: 4000 }
function safeValue(value, type) {
const s = value == null ? '' : String(value)
const max = type === serverWidget.FieldType.TEXTAREA ? LIMITS.textarea : LIMITS.text
return s.length > max ? s.slice(0, max - 1) + '…' : s
}

To show total debit and credit per Journal Entry, the pending saved search changed to a summary search grouped by JE. After that, every row's internal ID came back empty, so nothing could be selected.

On a summary search, result.getValue('internalid') doesn't resolve, because the column is really internalid with summary type GROUP. The fix: find the actual column object in result.columns and read the value through it, so the summary type is kept.

function getRowId(result) {
const col = result.columns.find((c) => c.name === 'internalid')
return col ? result.getValue(col) : result.id
}

Bug 5: objects that would silently vanish from the next deploy

This one never reached users. The project generates deploy.xml from a whitelist. The bulk approval custom records and scripts were in the committed deploy.xml, but someone had added them by hand. They weren't in the whitelist, and their IDs didn't match the generator's pattern either.

The next time anyone regenerated deploy.xml, bulk approval would have silently dropped out of Production deploys. The fix was four lines in the whitelist. The lesson is to treat generated files as output, and fix the generator.

Conclusion

The architecture from the first version transferred well because the "approve one record" step was isolated. Swapping a workflow trigger for an action library was a contained change.

The bugs were all in places where an assumption held in testing but not in production:

  • Scripts run as a different role than the user
  • Employee roles change after routes are generated
  • Real memos are longer than test memos
  • Saved searches change shape after go-live
  • Generated files get edited by hand

If you build something similar, test those cases on purpose.