πΌ Business problem
Ops wants correct tax without users picking codes by hand:
- Different nexus by subsidiary/region
- GST/HST/PST (Canada), state tax (US), VAT (EU)
- Honor customer tax exemption
- Must work on bulk loads (CSV, integrations)
π§ Approach
- User Event (beforeSubmit) on Sales Order and Invoice
- Read Ship-To (country/state), Subsidiary, and Customer flags
- Choose a rule from a JSON mapping (script parameter)
- If SuiteTax is enabled β set Nexus and (optionally) line tax code
- If Legacy tax β set taxitem at body or line taxcode
- Skip if customer is exempt (or set exemption code)
π§ Prerequisites
- (Optional) Customer checkbox
custentity_tax_exempt
(or use native fields/Exemption Certs) - Script parameter (Free-Form Text):
custscript_tax_rules_json
with region rules (see below) - (Optional) Body field
custbody_tax_applied_note
(text) to write what rule fired
Example JSON rules (put in the parameter)
[
{ "country":"CA", "state":"ON", "subsidiary": "2", "nexus": "5", "taxcode": "1234", "taxitem": "", "note":"Canada HST ON" },
{ "country":"CA", "state":"BC", "subsidiary": "2", "nexus": "6", "taxcode": "2345", "taxitem": "", "note":"Canada GST+PST BC" },
{ "country":"US", "state":"CA", "subsidiary": "1", "nexus": "10", "taxcode": "3456", "taxitem": "", "note":"US CA State Tax" },
{ "country":"US", "state":"", "subsidiary": "1", "nexus": "11", "taxcode": "4567", "taxitem": "", "note":"US no-state default" },
{ "country":"GB", "state":"", "subsidiary": "3", "nexus": "20", "taxcode": "5678", "taxitem": "", "note":"UK VAT Std" }
]
nexus
= internal ID of Nexus (SuiteTax)taxcode
= internal ID of Tax Code (SuiteTax or legacy line code)taxitem
= internal ID of Tax Item (legacy only; leave empty for SuiteTax)
Tip: Keep rules specific β first match wins (country+state), then a country-only default.
π§© User Event Script (SuiteScript 2.1)
Deploy on Sales Order and Invoice (both), beforeSubmit
.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Title: UE | Auto-Apply Tax/Nexus by Region
* Author: The NetSuite Pro
*/
define(['N/runtime','N/record','N/log'], (runtime, record, log) => {
const P_RULES = 'custscript_tax_rules_json';
const F_EXEMPT = 'custentity_tax_exempt'; // change if you use another flag
const F_APPLIED_NOTE = 'custbody_tax_applied_note'; // optional
const F_NEXUS = 'nexus'; // SuiteTax body field
const F_TAXITEM_BODY = 'taxitem'; // Legacy body tax
const F_SHIP_COUNTRY = 'shipcountry';
const F_SHIP_STATE = 'shipstate';
const SUITETAX = runtime.isFeatureInEffect({ feature: runtime.Feature.SUITETAX });
const beforeSubmit = (ctx) => {
if (![ctx.UserEventType.CREATE, ctx.UserEventType.EDIT, ctx.UserEventType.COPY].includes(ctx.type)) return;
const rec = ctx.newRecord;
const type = rec.type; // salesorder or invoice
try {
// Bail out if no shipping country; e.g., service invoices
const shipCountry = (rec.getValue(F_SHIP_COUNTRY) || '').toString().toUpperCase();
const shipState = (rec.getValue(F_SHIP_STATE) || '').toString().toUpperCase();
const subsidiary = (rec.getValue('subsidiary') || '').toString();
if (!shipCountry || !subsidiary) return;
// Customer exemption?
const customerId = rec.getValue('entity');
const isExempt = safeGetCustomerExempt(customerId);
const rules = loadRules();
const rule = findRule(rules, shipCountry, shipState, subsidiary);
if (!rule) {
log.debug('No matching tax rule', { shipCountry, shipState, subsidiary });
return;
}
if (isExempt) {
// For exempt customers: ensure no tax. Behavior differs per setup.
applyExempt(rec);
setNote(rec, `Exempt: no tax applied (${rule.note || 'by rule'})`);
return;
}
if (SUITETAX) {
applySuiteTax(rec, rule);
setNote(rec, rule.note || `SuiteTax: nexus ${rule.nexus}, taxcode ${rule.taxcode}`);
} else {
applyLegacyTax(rec, rule);
setNote(rec, rule.note || `Legacy tax: taxitem ${rule.taxitem || '(none)'} / taxcode ${rule.taxcode || '(line)'}`);
}
} catch (e) {
log.error('Auto Tax UE error', e);
}
};
function loadRules() {
try {
const json = runtime.getCurrentScript().getParameter({ name: P_RULES }) || '[]';
return JSON.parse(json);
} catch (_e) { return []; }
}
function findRule(rules, country, state, subsidiary) {
// First pass: country+state+subsidiary
let r = rules.find(x => eq(x.country,country) && eq(x.state,state) && eq(x.subsidiary,subsidiary));
if (r) return r;
// Fallback: country-only+subsidiary
r = rules.find(x => eq(x.country,country) && !x.state && eq(x.subsidiary,subsidiary));
return r || null;
}
function eq(a,b){ return String(a||'').toUpperCase() === String(b||'').toUpperCase(); }
function safeGetCustomerExempt(customerId) {
try {
if (!customerId) return false;
const c = record.load({ type: record.Type.CUSTOMER, id: customerId });
return !!c.getValue(F_EXEMPT);
} catch (_e) {
return false;
}
}
// ---- SuiteTax path ----
function applySuiteTax(rec, rule) {
if (rule.nexus) rec.setValue({ fieldId: F_NEXUS, value: Number(rule.nexus) });
// If you also maintain explicit tax codes per line:
if (rule.taxcode) {
const lineCount = rec.getLineCount({ sublistId: 'item' });
for (let i = 0; i < lineCount; i++) {
// skip non-taxable lines (e.g., description-only) if needed
const item = rec.getSublistValue({ sublistId:'item', fieldId:'item', line:i });
if (!item) continue;
// In SuiteTax, many accounts allow setting 'taxcode' per line still:
try {
rec.setSublistValue({ sublistId:'item', fieldId:'taxcode', line:i, value: Number(rule.taxcode) });
} catch (_ignore) {
// Some tenants lock taxcode (calculated by engine) β safe to ignore
}
}
}
}
// ---- Legacy tax path ----
function applyLegacyTax(rec, rule) {
if (rule.taxitem) {
rec.setValue({ fieldId: F_TAXITEM_BODY, value: Number(rule.taxitem) });
}
if (rule.taxcode) {
const cnt = rec.getLineCount({ sublistId:'item' });
for (let i = 0; i < cnt; i++) {
const item = rec.getSublistValue({ sublistId:'item', fieldId:'item', line:i });
if (!item) continue;
rec.setSublistValue({ sublistId:'item', fieldId:'taxcode', line:i, value: Number(rule.taxcode) });
}
}
}
// ---- Exempt handling (generic) ----
function applyExempt(rec) {
// Approach 1: set an exempt tax code on lines (preferred if you manage a specific exempt code)
const cnt = rec.getLineCount({ sublistId:'item' });
for (let i = 0; i < cnt; i++) {
try {
// Replace with your exempt code internal ID if you manage one:
// rec.setSublistValue({ sublistId:'item', fieldId:'taxcode', line:i, value: 999 });
// Or mark non-taxable item logic; leave blank here as many tenants calculate zero via certificates.
} catch (_e) {}
}
}
function setNote(rec, msg) {
try {
if (F_APPLIED_NOTE) rec.setValue({ fieldId: F_APPLIED_NOTE, value: msg });
} catch (_e) {}
}
return { beforeSubmit };
});
β Testing checklist
- Set parameter Tax Rules JSON with your IDs.
- Create SO with ship-to CA / ON under subsidiary 2 β verify Nexus/Tax populate.
- Flip to BC β rules switch.
- Mark customer exempt β no tax per your policy.
- CSV/REST import several orders β script runs on CREATE (bulk-safe).
- Transform to Invoice β UE on Invoice enforces the same rule.
π§© Enhancement ideas
- Add
custscript_tax_rules_url
to fetch JSON from a file so Finance can edit without deployments. - Extend matcher to include location or class if tax varies by warehouse.
- Write a nightly audit (MR) that logs/flags transactions missing a nexus/tax code.
- If using exemption certificates, you can check native SuiteTax certificate status instead of a custom flag.
π§ Gotchas & tips
- In some SuiteTax setups, line taxcode is lockedβitβs fine to only set Nexus and let the engine determine codes.
- Always normalize country/state codes to uppercase.
- If ship-to is blank (services), consider billing address or customer default as a fallback.
- Avoid loading the customer repeatedly in bulk flowsβcache if you later convert this to MR.
π Summary
Objective | Result |
---|---|
Correct tax without manual selection | Auto nexus/tax set from JSON rules |
Works across create/edit/copy & bulk | UE beforeSubmit covers all |
SuiteTax & legacy compatible | Feature-detect and branch safely |
Leave a Reply