The business problem
AR teams want orders blocked (or flagged) when a customer:
- exceeds their credit limit, and/or
- has overdue AR beyond a threshold.
Doing this by hand is risky and slow. We’ll automate it with SuiteScript and keep it safe for high volumes.
The approach (2 layers)
Layer A — Nightly enforcement (Map/Reduce):
- Input = a saved search that returns Customer internal IDs who violate your policy (over-limit and/or overdue).
- Script sets
creditholdoverride = ON
. - Optionally auto-releases customers who are now compliant by setting
creditholdoverride = AUTO
.
Layer B — Real-time guard (User Event on Sales Order):
- If the selected customer is on credit hold, we prevent the order (or mark it On Hold) and show a friendly error.
Why two layers? Nightly keeps data clean at scale; real-time prevents edge-case orders in the daytime before the nightly job runs.
Configuration you’ll set once
1) Saved Search (Transactions → Invoices → summary by Customer)
Type: Transaction
Criteria (suggested):
- Type = Invoice
- Status = Open
- Main Line = false
- Due Date = on or before “days ago” (parameterized number, e.g., 15/30)
Results (Summary):
- Group by Customer (Entity / Internal ID as one column too)
- Sum of Amount Remaining
- (Optional) Summary Filter: Sum(Amount Remaining) greater than some threshold (e.g., 100)
Name it something like
Credit Hold Candidates (Overdue)
and note its Internal ID, e.g.,customsearch_credit_hold_candidates
.
You can also create a separate Customer search for “over credit limit” using Formula (Numeric):CASE WHEN {balance} > {creditlimit} THEN 1 ELSE 0 END
with Summary filter > 0, or simply combine logic in the script.
Script A — Map/Reduce (SuiteScript 2.1)
What it does
- Reads parameters (search IDs, thresholds, auto-release, etc.)
- Iterates customers to apply credit hold
- Optionally scans a “compliant customers” search to release holds back to
AUTO
- Uses
submitFields
(fast, governance-friendly)
Create script parameters (Deployment level) so Finance can tune behavior without code changes.
Parameters (suggested)
custscript_hold_search_id
(Free-Form Text): Internal ID of “candidates to hold” saved search (Transaction summary).custscript_release_search_id
(Free-Form Text, optional): Internal ID of “candidates to release” (Customer search where balance ≤ creditlimit and overdue ≤ threshold).custscript_auto_release
(Checkbox): Auto release compliant customers? (T/F)custscript_reason_text
(Free-Form Text): Default hold reason text (stored on a custom entity field, optional).custscript_page_size
(Integer): Default 100–1000 (governance + throughput).custscript_dry_run
(Checkbox): Log only; don’t update.
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @author The NetSuite Pro
* Title: MR | Enforce Customer Credit Hold
*/
define(['N/search','N/record','N/runtime','N/log'], (search, record, runtime, log) => {
const F_CREDITHOLD = 'creditholdoverride'; // values: 'AUTO','ON','OFF'
const F_REASON = 'custentity_credit_hold_reason'; // optional custom entity field
const getParams = () => {
const s = runtime.getCurrentScript();
return {
holdSearchId: (s.getParameter('custscript_hold_search_id') || '').trim(),
releaseSearchId: (s.getParameter('custscript_release_search_id') || '').trim(),
autoRelease: s.getParameter('custscript_auto_release') === 'T',
reasonText: s.getParameter('custscript_reason_text') || 'Over limit or overdue AR',
pageSize: parseInt(s.getParameter('custscript_page_size') || '500', 10),
dryRun: s.getParameter('custscript_dry_run') === 'T'
};
};
const searchToCustomerIds = (searchId, pageSize) => {
if (!searchId) return [];
const srch = search.load({ id: searchId });
const paged = srch.runPaged({ pageSize: Math.min(Math.max(pageSize, 100), 1000) });
const ids = [];
paged.pageRanges.forEach(range => {
const page = paged.fetch({ index: range.index });
page.data.forEach(r => {
// Try to derive a customer internal ID from summary grouping or explicit field:
let id = null;
const entity = r.getValue({ name: 'entity', summary: search.Summary.GROUP });
const internalid = r.getValue({ name: 'internalid', summary: search.Summary.GROUP }) ||
r.getValue({ name: 'internalid' });
id = internalid || entity;
if (id) ids.push(String(id));
});
});
return [...new Set(ids)];
};
const putOnHold = (customerId, reasonText, dryRun) => {
if (dryRun) {
log.audit('DRY-RUN putOnHold', { customerId, reasonText });
return;
}
record.submitFields({
type: record.Type.CUSTOMER,
id: customerId,
values: {
[F_CREDITHOLD]: 'ON',
...(F_REASON ? { [F_REASON]: reasonText } : {})
},
options: { enableSourcing: false, ignoreMandatoryFields: true }
});
};
const releaseHold = (customerId, dryRun) => {
if (dryRun) {
log.audit('DRY-RUN releaseHold', { customerId });
return;
}
record.submitFields({
type: record.Type.CUSTOMER,
id: customerId,
values: {
[F_CREDITHOLD]: 'AUTO'
},
options: { enableSourcing: false, ignoreMandatoryFields: true }
});
};
const input = () => {
const p = getParams();
const holdIds = searchToCustomerIds(p.holdSearchId, p.pageSize);
const releaseIds = p.autoRelease ? searchToCustomerIds(p.releaseSearchId, p.pageSize) : [];
return { params: p, holdIds, releaseIds };
};
const getInputData = () => {
const { params, holdIds, releaseIds } = input();
// Build a single stream with type flags:
const rows = [];
holdIds.forEach(id => rows.push({ action: 'HOLD', id }));
releaseIds.forEach(id => rows.push({ action: 'RELEASE', id }));
return rows;
};
const map = (ctx) => {
const row = JSON.parse(ctx.value);
ctx.write({ key: row.action, value: row.id });
};
const reduce = (ctx) => {
const p = getParams();
const action = ctx.key; // HOLD or RELEASE
const ids = ctx.values || [];
ids.forEach(customerId => {
try {
if (action === 'HOLD') putOnHold(customerId, p.reasonText, p.dryRun);
else if (action === 'RELEASE') releaseHold(customerId, p.dryRun);
} catch (e) {
log.error(`Failed ${action} for ${customerId}`, e);
}
});
};
const summarize = (summary) => {
log.audit('Credit Hold MR Summary', {
usage: summary.usage,
concurrency: summary.concurrency,
yields: summary.yields
});
summary.inputSummary.error && log.error('Input Error', summary.inputSummary.error);
summary.mapSummary.errors.iterator().each((k, e) => (log.error(`Map Err ${k}`, e), true));
summary.reduceSummary.errors.iterator().each((k, e) => (log.error(`Reduce Err ${k}`, e), true));
};
return { getInputData, map, reduce, summarize };
});
Deployment tips
- Schedule nightly (e.g., 1:00 AM) and optionally hourly during busy season.
- Set
pageSize
= 500 (start safe), then tune. - Start with
dry_run = T
once to validate logs.
Script B — Sales Order User Event (guardrail)
What it does
- On beforeSubmit of Sales Order: if the customer’s
creditholdoverride
isON
, either:- Throw an error to block creation, or
- Set the SO field
orderstatus
/onhold
or a custom approval field to stop downstream processing.
Choose one approach below. Blocking with an error is crystal-clear to users.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Title: UE | Block SO if Customer on Credit Hold
*/
define(['N/record','N/error'], (record, error) => {
const BLOCK_WITH_ERROR = true; // Set false if you prefer to just flag the order.
const beforeSubmit = (ctx) => {
if (ctx.type !== ctx.UserEventType.CREATE && ctx.type !== ctx.UserEventType.COPY && ctx.type !== ctx.UserEventType.EDIT) return;
const so = ctx.newRecord;
const customerHold = so.getValue({ fieldId: 'entitycredithold' }) // read-only mirror on SO
|| so.getValue({ fieldId: 'creditholdoverride' }); // fallback if available
// In many accounts, SO exposes 'entitycredithold' (T when hold = ON).
const isOnHold = (String(customerHold).toUpperCase() === 'ON' || customerHold === true);
if (!isOnHold) return;
if (BLOCK_WITH_ERROR) {
throw error.create({
name: 'CUSTOMER_ON_CREDIT_HOLD',
message: 'This customer is on Credit Hold. Please contact Accounts Receivable.',
notifyOff: false
});
} else {
// Soft-guard example:
// so.setValue({ fieldId: 'onhold', value: true }); // if your account uses this field
// or set a custom approval field:
// so.setValue({ fieldId: 'custbody_needs_finance_review', value: true });
}
};
return { beforeSubmit };
});
If your account doesn’t expose
entitycredithold
on SO, you can look it up viaN/search
on the Customer byinternalid = entity
.
Optional: custom “Reason” field (nice to have)
Create a Customer field:
- ID:
custentity_credit_hold_reason
(type: Free-Form Text, display only) - Our MR sets this with a friendly explanation (e.g., “Over limit or overdue AR”).
Rollback / release logic
- Add a second saved search (Customer type) for compliant customers:
- Formula (Numeric):
CASE WHEN {balance} <= {creditlimit} THEN 1 ELSE 0 END
(Summary > 0) - Also ensure overdue sum ≤ threshold (via a joined transaction summary, or simply use another transaction search and merge results—light customization acceptable).
- Formula (Numeric):
- Point
custscript_release_search_id
to it and enablecustscript_auto_release = T
.
Testing checklist (quick)
- Turn DRY RUN = T and execute MR → confirm logs list intended customers.
- Turn DRY RUN = F, run MR → inspect a few customers (Credit Hold = ON, reason set).
- Try to create a Sales Order for a held customer → blocked (or flagged).
- Receive payments to clear balance/overdue → run MR with auto release → hold returns to AUTO.
Governance & performance notes
- submitFields avoids full record load → minimal governance.
- Use paged results and a tunable page size.
- Map/Reduce keeps you safe for thousands of customers.
- Start with nightly schedule; if AR is volatile, consider every 2–4 hours.
Troubleshooting (common)
- Saved search returns no rows: loosen criteria; verify Main Line = false for invoice lines; confirm overdue date filter.
- Not finding customer ID in results: add Internal ID (Group) as a results column.
- Orders still sneaking through: ensure the UE is deployed on CREATE/COPY/EDIT and that no workflow or other script flips
onhold
or bypasses errors.
FAQs
Q: Why set creditholdoverride
and not credithold
?creditholdoverride
is the supported field to programmatically force hold (ON
), release (OFF
), or revert to system (AUTO
).
Q: Can we base hold on % over limit?
Yes—add a Customer search returning {balance}
and {creditlimit}
; filter with a formula like {balance} > ({creditlimit} * 1.10)
for 110%.
Q: Can Finance temporarily allow one order?
Yes—flip the customer to OFF
(or add a role-based bypass in the UE via runtime.getCurrentUser().role
).
What you can copy next
- Paste the two scripts above as:
customscript_mr_credit_hold
(Map/Reduce)customscript_ue_so_credit_guard
(User Event on Sales Order)
- Create the two saved searches and set parameters.
- Deploy, run in dry run, then go live.
If you want, I can tailor the saved-search formulas to your exact policy (e.g., “over 30 days overdue OR over 120% of credit limit”) and wire them into the MR so Finance can tweak thresholds in parameters instead of editing searches.
Leave a Reply