πΌ Business problem
Edits to approved Sales Orders can silently reduce revenue: a rep changes a line rate or adds a big header discount. Finance only finds out after invoicing. You want automatic guardrails with clear error messages and a role-based bypass for managers.
π§ Approach
- User Event (beforeSubmit) on Sales Order for
EDIT
/COPY
. - Compare old vs. new totals and line rates (using
context.oldRecord
). - Enforce 3 rules:
- Max total decrease % (e.g., no more than 5% drop vs. saved SO).
- Max per-line rate decrease % (e.g., no more than 10% cut on any item).
- Max header discount % (e.g., body
discountrate
β€ 15%).
- Bypass for specific roles (e.g., Sales Manager, Admin).
- Optional: show a single consolidated error listing all violations.
π§ Script parameters (Deployment)
custscript_guard_max_total_drop_pct
(float, e.g.,5
)custscript_guard_max_line_drop_pct
(float, e.g.,10
)custscript_guard_max_body_disc_pct
(float, e.g.,15
)custscript_guard_bypass_roles
(text CSV of role IDs, e.g.,3,1013
)
Tip: start conservative (higher thresholds), monitor, then tighten.
π§© User Event (SuiteScript 2.1) β block edits that exceed caps
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Title: UE | Sales Order Edit Guard (price/discount caps)
* Author: The NetSuite Pro
*/
define(['N/runtime','N/error','N/format'], (runtime, error, format) => {
const P_MAX_TOTAL_DROP = 'custscript_guard_max_total_drop_pct';
const P_MAX_LINE_DROP = 'custscript_guard_max_line_drop_pct';
const P_MAX_HDR_DISC = 'custscript_guard_max_body_disc_pct';
const P_BYPASS_ROLES = 'custscript_guard_bypass_roles';
const beforeSubmit = (ctx) => {
// Only apply on SO edit/copy
if (ctx.newRecord.type !== 'salesorder') return;
if (![ctx.UserEventType.EDIT, ctx.UserEventType.COPY].includes(ctx.type)) return;
// Role-based bypass
const roleId = runtime.getCurrentUser().role;
const bypass = parseCsv(runtime.getCurrentScript().getParameter({ name: P_BYPASS_ROLES }));
if (bypass.includes(String(roleId))) return;
const newRec = ctx.newRecord;
const oldRec = ctx.oldRecord;
if (!oldRec) return; // No baseline to compare
// Params
const maxTotalDrop = toNum(runtime.getCurrentScript().getParameter({ name: P_MAX_TOTAL_DROP }), 0);
const maxLineDrop = toNum(runtime.getCurrentScript().getParameter({ name: P_MAX_LINE_DROP }), 0);
const maxHdrDisc = toNum(runtime.getCurrentScript().getParameter({ name: P_MAX_HDR_DISC }), 0);
const violations = [];
// --- Rule 1: Total decrease %
const oldTotal = toNum(oldRec.getValue({ fieldId: 'total' }), 0);
const newTotal = toNum(newRec.getValue({ fieldId: 'total' }), 0);
if (oldTotal > 0 && newTotal < oldTotal) {
const dropPct = ((oldTotal - newTotal) / oldTotal) * 100;
if (dropPct > maxTotalDrop) {
violations.push(`Order total decreased by ${round(dropPct)}% (old ${fmt(oldTotal)} β new ${fmt(newTotal)}), exceeds ${maxTotalDrop}%.`);
}
}
// --- Rule 2: Per-line rate decrease %
const newCount = newRec.getLineCount({ sublistId: 'item' }) || 0;
const oldCount = oldRec.getLineCount({ sublistId: 'item' }) || 0;
const lineMax = Math.max(newCount, oldCount);
for (let i = 0; i < lineMax; i++) {
// Skip if line types mismatch or not an item line
const newItem = safe(newRec, i, 'item');
const newType = safe(newRec, i, 'itemtype');
const oldItem = safe(oldRec, i, 'item');
const oldType = safe(oldRec, i, 'itemtype');
// Only evaluate when both old & new are real item rows (ignore discount/memo lines)
const isItemNew = !!newItem && (!newType || newType === 'InvtPart' || newType === 'NonInvtPart' || newType === 'Service' || newType === 'OtherCharge' || newType === 'Assembly');
const isItemOld = !!oldItem && (!oldType || oldType === 'InvtPart' || oldType === 'NonInvtPart' || oldType === 'Service' || oldType === 'OtherCharge' || oldType === 'Assembly');
if (!(isItemNew && isItemOld)) continue;
const oldRate = toNum(oldRec.getSublistValue({ sublistId:'item', fieldId:'rate', line:i }), null);
const newRate = toNum(newRec.getSublistValue({ sublistId:'item', fieldId:'rate', line:i }), null);
if (oldRate == null || newRate == null) continue;
if (newRate < oldRate && oldRate > 0) {
const dropPct = ((oldRate - newRate) / oldRate) * 100;
if (dropPct > maxLineDrop) {
const itemName = newRec.getSublistText({ sublistId:'item', fieldId:'item', line:i }) || `line ${i+1}`;
violations.push(`Line "${itemName}" rate dropped ${round(dropPct)}% (old ${fmt(oldRate)} β new ${fmt(newRate)}), exceeds ${maxLineDrop}%.`);
}
}
}
// --- Rule 3: Header discount cap (discountrate like "-10%")
const dr = (newRec.getValue({ fieldId: 'discountrate' }) || '').toString().trim();
const pct = parsePercent(dr); // returns positive number, e.g., 10 for "-10%"
if (pct != null && pct > maxHdrDisc) {
violations.push(`Header discount ${pct}% exceeds allowed ${maxHdrDisc}%.`);
}
if (violations.length) {
throw error.create({
name: 'SO_EDIT_GUARD',
message:
'Edits exceed allowed limits:\nβ’ ' + violations.join('\nβ’ ') +
'\n\nIf this is intentional, have a manager with bypass role save the change or adjust the caps.',
notifyOff: false
});
}
};
// ---------- helpers ----------
function safe(rec, line, fieldId) {
try { return rec.getSublistValue({ sublistId:'item', fieldId, line }); } catch (_e) { return null; }
}
function toNum(v, dflt) {
const n = Number(String(v || '').toString().replace(/[^\d.-]/g, ''));
return isFinite(n) ? n : dflt;
}
function round(n) { return Math.round(n * 10) / 10; }
function fmt(n) { try { return format.format({ value: n, type: format.Type.CURRENCY }); } catch (_e) { return String(n); } }
function parseCsv(s) {
return (s || '').split(',').map(x => x.trim()).filter(Boolean);
}
function parsePercent(s) {
if (!s) return null;
// Accept "-10%", "10%", "-0.1", "0.10", etc. Return absolute percent number
const str = String(s).replace(/\s/g,'');
if (str.endsWith('%')) {
const n = Number(str.slice(0, -1));
return isFinite(n) ? Math.abs(n) : null;
}
const n = Number(str);
if (!isFinite(n)) return null;
// If between -1 and 1, assume ratio (e.g., -0.1 => 10%)
return Math.abs(Math.abs(n) < 1 ? n * 100 : n);
}
return { beforeSubmit };
});
What this catches (examples)
- Rep lowers a line rate by 25% when the cap is 10% β blocked.
- Someone adds a -30% header discount while the max allowed is 15% β blocked.
- Order total drops by 12% when the cap is 5% β blocked.
- Managers on a bypass role can still save the change.
β Testing checklist
- Set parameters:
5 / 10 / 15
and add your bypass role IDs. - Edit an SO: reduce a line rate by 20% β expect a readable error with the line name.
- Add a header discount
-25%
β error. - Lower several lines so that total falls > 5% even if each line is within 10% β error shows total rule.
- Login as a bypass role β the same edits should save.
π Enhancements (optional)
- Whitelist fields: allow address/notes changes without triggering total rule (track via a hidden βprior totalβ body field and compare only when price fields changed).
- Warning mode: convert thrown error to a soft block (set
custbody_needs_mgr_approval = T
and stop fulfillment via workflow). - Email alert: when blocked, email Sales Manager with the attempted changes.
- Price level guard: block when
price
is set to Custom unless role is in bypass. - CSV/Integration flag: if
createdfrom
or an integration checkbox is set, relax rules or route for approval.
π Summary
Control | Result |
---|---|
Per-line rate drop cap | Prevents deep, line-level underpricing |
Total decrease cap | Catches many small changes that add up |
Header discount cap | Stops one-click heavy discounts |
Role bypass | Keeps managers flexible without slowing reps |
Leave a Reply