Porting NetSuite bulk approval to a custom approval engine, and the bugs production found
- Published on
- -8 mins read
- Authors
- Name
- Andy Nur (Andy)
- @andynur
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 / 51. Approve
An approver at level 2 approves. In bulk, this comes from the Map/Reduce instead of a button.
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}Bug 4: row IDs disappeared on a summary search
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.