Locking approved records in NetSuite: three attempts and what each one taught me
- Published on
- -7 mins read
- Authors
- Name
- Andy Nur (Andy)
- @andynur
"Approved means locked" sounds like a one-line requirement. In NetSuite, it isn't. Depending on how you lock a record, you can lock out the wrong people, show a confusing permission error, or flood admins with error emails.
In this article, I'll walk through how I locked approved purchase order variations for a client, including the two approaches that didn't survive contact with real users. Client details are anonymized and the code is simplified.
We'll cover:
- Why a workflow lock alone wasn't enough
- Attempt 1: redirect to View mode, and the permission error it caused
- Attempt 2: block the save on the native purchase order, and the error emails
- The final layered lock: UI lock, save-time block, and client-side validation
Prerequisites
You should know user event scripts (beforeLoad, beforeSubmit), client scripts (saveRecord), runtime.executionContext, and how NetSuite roles and permissions work.
The problem
The client tracks changes to purchase orders and term contracts as variation records: custom records with header fields and line sublists. Each variation goes through approval. Once approved, its lines feed budget and commitment figures, so editing them afterwards breaks the audit trail.
The approval workflow was supposed to lock approved variations. In practice, users could still open some approved records in Edit mode and save changes. One variation type had no lock at all.
Deciding what "approved" means
The first question was how a script can tell a record is approved. The status field alone wasn't reliable on older records, so the check also requires evidence of approval: an approval date, a final approver, or a last approval authority level.
function hasApprovalEvidence(rec) { return ( !!rec.getValue('custrecord_vo_approve_date') || !!rec.getValue('custrecord_vo_final_approver') || !!rec.getValue('custrecord_vo_last_approver_level') )}
function isApproved(rec) { return Number(rec.getValue('custrecord_vo_approval_status')) === APPROVED && hasApprovalEvidence(rec)}
function canBypassLock() { return BYPASS_ROLES.indexOf(Number(runtime.getCurrentUser().role)) !== -1}Finance admins and a support role can bypass the lock, because they sometimes need to correct data.
Attempt 1: redirect Edit to View
The first version was the obvious one. If a non-bypass user opens an approved record in Edit mode, beforeLoad redirects them to View mode:
function beforeLoad(context) { if ( runtime.executionContext === runtime.ContextType.USER_INTERFACE && context.type === context.UserEventType.EDIT && isApproved(context.newRecord) && !canBypassLock() ) { redirect.toRecord({ type: context.newRecord.type, id: context.newRecord.id, isEditMode: false }) }}A beforeSubmit check sat behind it as a fallback, in case the record was saved some other way.
It worked in testing with an admin-like role. Then a regular user reported this when opening an approved variation: "You do not have privileges to view this page."
The user's role had Edit permission on the record type, so record permission wasn't the problem. The redirect target was. Landing on that View page needed access the role didn't have. The lock turned into a confusing permission error.
Attempt 1: how a redirect became a permission error
Step 1 / 31. Open in Edit
The user clicks Edit on an approved variation.
The fix: stay in Edit mode, and lock the UI instead. The record already had a View-mode UI lock that hides the sublist Edit and Remove links and the Create New buttons. beforeLoad now applies that same lock in Edit mode for approved records. beforeSubmit is unchanged and is still the real enforcement.
function beforeLoad(context) { const isLockedForEdit = runtime.executionContext === runtime.ContextType.USER_INTERFACE && context.type === context.UserEventType.EDIT && isApproved(context.newRecord) && !canBypassLock()
if (context.type === context.UserEventType.VIEW || isLockedForEdit) { lockSublists(context.form) // hide line Edit/Remove links and Create New }}
function beforeSubmit(context) { if ( runtime.executionContext === runtime.ContextType.USER_INTERFACE && (context.type === context.UserEventType.EDIT || context.type === context.UserEventType.XEDIT) && context.oldRecord && isApproved(context.oldRecord) && !canBypassLock() ) { throw error.create({ name: 'APPROVED_RECORD_LOCKED', message: 'This approved record cannot be edited.' }) }}Note that beforeSubmit checks oldRecord, the saved state, not newRecord. Otherwise a user could change the status field in the same edit and slip past the check.
Redirects need permission too
redirect.toRecord sends the user to a page they must be allowed to open. When you lock a record,
prefer locking the current page over sending users somewhere else.
Attempt 2: the same block on the native purchase order
The client also wanted approved native purchase orders locked at line level. The first try reused the same beforeSubmit block on the purchase order.
Two problems came up:
- A thrown error in
beforeSubmiton the native purchase order caused NetSuite to send error notification emails for every blocked save. A lock that users hit as part of normal work turned into inbox noise for admins. - Hiding or disabling columns in the UI was unreliable for several native fields, such as Location, Rate, Amount, and Closed. Some stayed editable no matter what the script did.
So for the native purchase order, the server-side block and the cosmetic UI lock were both dropped.
The final version for native purchase orders
The check moved into the client script's saveRecord. On page load, the client script takes a snapshot of the item and expense lines. On save, it compares the current lines with the snapshot. If anything changed on an approved purchase order, it shows a readable dialog and cancels the save:
let snapshot = nulllet linesTouched = false
function pageInit(context) { if (isApprovedPo(context.currentRecord)) snapshot = takeLineSnapshot(context.currentRecord)}
function sublistChanged() { linesTouched = true // fallback signal if the diff misses a field}
function saveRecord(context) { if (!snapshot || canBypassLock()) return true
const changed = linesTouched || !sameLines(snapshot, takeLineSnapshot(context.currentRecord)) if (!changed) return true
dialog.alert({ title: 'Purchase order is approved', message: 'Item and expense lines cannot be changed after approval. Create a variation instead.', }) return false}This catches the edit before it reaches the server, so there's no error email. The user also sees a message that tells them what to do next, instead of a raw error.
Trade-off
A client-side check only covers edits in the browser. It was acceptable here because the requirement was about users editing approved purchase orders by hand. If scripts, imports, or integrations can also edit the record, you need a server-side answer for those paths.
The final picture
| Record | Edit mode UI | Save time |
|---|---|---|
| PO variation | Sublists locked | beforeSubmit blocks |
| Term contract variation | Sublists locked (new) | beforeSubmit blocks (new) |
| Native purchase order | Not changed | Client saveRecord dialog |
Conclusion
Every attempt here locked the record. What made the first two fail was how the lock felt to the people who hit it: a permission error, or a pile of error emails.
When you lock records in NetSuite:
- Decide what "approved" means with more than one field, and check the saved state
- Keep bypass roles explicit
- Lock the current page instead of redirecting
- Keep the server-side block for custom records where it's quiet
- For native records, a client-side check with a clear message can be the better choice