Three ways our status guard let a shipped order slip
Blocking a shipped fulfillment from going back to packed sounds like ten lines of user event script. Version one missed a whole button. Version two missed every stale page. Each miss was a reasonable assumption about NetSuite that turned out to be wrong.
The business case is in the order shipped, the invoice never came. This is the build: a beforeSubmit user event on item fulfillment that refuses to let a shipped record move backward. It took three versions to get right, and each version fixed an assumption that sounded true.
Pitfall 1: not every save is an EDIT
The first version gated on the obvious event types. Creating a fulfillment can’t regress anything, so only guard edits:
if (context.type !== context.UserEventType.EDIT &&
context.type !== context.UserEventType.XEDIT) {
return;
}
It worked in every test we ran, because every test changed the status dropdown and saved. Then a shipped fulfillment reverted anyway, with no block and no notification.
The revert had come from the Mark Packed button, not the dropdown. That button submits the record with event type PACK. The guard returned on the first line, before any logic ran. The silence was the evidence: had someone used the dropdown, the old version would have blocked it and sent an email. The one blind spot and the one path used were the same path.
const GUARDED = [
context.UserEventType.EDIT,
context.UserEventType.XEDIT,
context.UserEventType.PACK
];
if (GUARDED.indexOf(context.type) === -1) return;
We deliberately left SHIP out. A ship event moves status forward by definition, and blocking anywhere near it risks interfering with the shipping integration’s real work.
Pitfall 2: people and integrations need different answers
Not every revert is wrong. A shipping integration that voids a label should un-ship the fulfillment, and so should a scheduled cleanup. Blocking those would trade a silent revenue leak for a stuck integration, which is worse.
So the guard branches on execution context. In the UI, a revert is blocked with a message written for the person at the screen. Anywhere else it’s allowed, logged and emailed:
const ctx = runtime.executionContext;
if (ctx !== runtime.ContextType.USER_INTERFACE) {
log.audit('Non-UI revert allowed', `${transition} | ${ctx} | ${context.type}`);
notifyOwners(rec, transition, ctx);
return;
}
if (overrideTicked(newRec)) {
clearOverride(newRec); // single-use: resets on this save
log.audit('Revert allowed by override', transition);
return;
}
throw error.create({
name: 'STATUS_REVERT_BLOCKED',
message: 'This fulfillment was already shipped, possibly after you opened ' +
'this page. Refresh the record to see its current status.'
});
Two small details matter more than they look. Every log line carries context.type, so the next incident says which path it took instead of leaving us to infer it. And the message says the record may have shipped after you opened the page, because that is usually the truth. A warehouse user should not feel accused by a race condition.
Pitfall 3: oldRecord is only as fresh as the page
Version two held for a few weeks. Then five more fulfillments reverted from shipped to packed through the UI, with the PACK gate in place.
The guard compared context.oldRecord to context.newRecord: was it shipped before, and is it packed now? The trouble is what oldRecord actually is on a UI submit. It reflects the record as the page knew it, and the page had been opened before the shipping integration shipped the fulfillment in the background. To the script, the record was going from picked to packed — a normal forward move. Nothing to block.
The fix is to stop trusting the page for the one fact the guard exists to protect, and ask the database:
function currentStatus(id) {
const rows = query.runSuiteQL({
query: 'SELECT status FROM transaction WHERE id = ?',
params: [id]
}).asMappedResults();
return rows.length ? rows[0].status : null;
}
const pageSaysShipped = isShipped(context.oldRecord);
const dbSaysShipped = isShipped(currentStatus(newRec.id));
if ((pageSaysShipped || dbSaysShipped) && !isShipped(newRec)) {
// it is a regression, whatever the page thought
}
If either source says shipped, it is shipped. The guard also logs both values on every guarded save, so if they ever disagree, the record of it is sitting in the execution log.
A side note on investigating this kind of thing
System notes are the obvious place to see who changed a status. Be careful how you read them. In our case, notes written by other users showed in the record’s System Notes tab but didn’t come back through a SuiteQL query run under a different role. A query that returns nothing is not proof that nothing was logged. Before concluding a change was unaudited, look at the record in the UI.
The general lesson
- Guard the action, not the field. Buttons are event types, and every one needs a decision.
- Decide separately for people and for processes. The right answer is usually different.
- On a UI submit,
oldRecordreflects what the user loaded. When freshness is the whole point, read the database. - Log the evidence you’ll want next time — event type, context, both status sources — before you need it.
Related: the order shipped, the invoice never came, and MAP_REDUCE_ALREADY_RUNNING is not an error.