使用 RESTlet 在 NetSuite 中将 Invoice 支付为 Paid In Full
结论
不要在调用 nlapiCreateRecord('customerpayment') 后,直接拿 Invoice 搜索结果的下标操作 apply 子列表。应该先让 NetSuite 根据目标 Invoice 生成包含有效 apply 行的 Customer Payment。最直接的方式是:
nlapiTransformRecord('invoice', invoiceId, 'customerpayment')
接着遍历 Customer Payment 自身的 apply 子列表,根据 internalid 找到目标 Invoice,设置付款金额并勾选 apply,最后调用 nlapiSubmitRecord()。
实际应用的金额只要覆盖该 Invoice 的全部未结余额,NetSuite 就会自动把状态更新为 Paid In Full。不需要,也不应该直接修改 Invoice 的 status 字段。
为什么原代码会报错
Invoice 搜索结果和 Customer Payment 的 apply 子列表是两组独立数据,行号之间没有对应关系。
原代码中的 r 是搜索结果数组的下标,从 0 开始:
for (var r = 0; r < records.length; r++)
SuiteScript 1.0 的子列表行号则从 1 开始。更重要的是,使用下面的方式创建空白 Customer Payment 时:
nlapiCreateRecord('customerpayment')
NetSuite 不一定已经生成可操作的 apply 行。如果此时用搜索结果中的 r 修改静态 apply 子列表,就可能访问一个不存在的行,进而报错:
You have attempted an invalid sublist or line item operation.
所以,不能把搜索结果的行号直接传给 setLineItemValue()。需要先生成付款记录的应用列表,再根据 Invoice Internal ID 找到对应行。
正确处理步骤
1. 确认 Invoice 仍有未结余额
可以搜索状态为 CustInvc:A 的 Invoice,并加入以下限制:
entity为付款客户;mainline为T;internalid为目标 Invoice ID。
RESTlet 已经收到 Invoice Internal ID 时,没有必要先搜索该客户的所有未结 Invoice 再逐条匹配,直接验证指定的 Invoice 即可。
2. 将 Invoice 转换成 Customer Payment
使用目标 Invoice 创建付款记录:
var payment = nlapiTransformRecord(
'invoice',
invoiceId,
'customerpayment'
);
NetSuite 会根据 Invoice 和客户上下文填充付款记录,其中包括可应用交易的 apply 子列表。
不同 NetSuite 账户、表单和 SuiteScript 环境可能会有差异。如果当前环境不支持这种转换方式,可以在创建 Customer Payment 时通过初始化参数传入客户上下文,然后检查 apply 子列表是否已经填充。无论采用哪种方式,都必须保证要操作的行真实存在于 Customer Payment 的 apply 子列表中。
3. 遍历付款记录的 apply 子列表
子列表行号从 1 开始:
var lineCount = payment.getLineItemCount('apply');
for (var i = 1; i <= lineCount; i++) {
var appliedInvoiceId = payment.getLineItemValue(
'apply',
'internalid',
i
);
if (String(appliedInvoiceId) === String(invoiceId)) {
// 设置金额并勾选应用
}
}
不要假设目标 Invoice 一定位于第一行,也不要使用 Invoice 搜索结果的行号。
4. 设置实际应用金额
如果需要完全结清 Invoice,应用金额必须等于它当前的未结余额。在某些账户或表单中,可以从 apply 子列表的 due 字段读取未结金额;已有示例使用的是 total。具体应该使用哪个字段,需要根据当前账户中字段的实际含义来确定。
例如:
var amountToApply =
payment.getLineItemValue('apply', 'due', i) ||
payment.getLineItemValue('apply', 'total', i);
payment.setLineItemValue('apply', 'amount', i, amountToApply);
payment.setLineItemValue('apply', 'apply', i, 'T');
payment.setFieldValue('payment', amountToApply);
如果 Invoice 已经支付过一部分,total 可能表示原始总额,而不是剩余应付金额。此时应优先使用未结余额,以免超额应用。
5. 提交 Customer Payment
var paymentId = nlapiSubmitRecord(payment, true, true);
提交成功后,NetSuite 会重新计算 Invoice 的剩余余额:
- 剩余余额为
0:状态更新为Paid In Full; - 仍有余额:状态保持未结或部分付款状态;
- 付款没有应用到该 Invoice:即使成功创建了 Customer Payment,Invoice 也不会变成
Paid In Full。
RESTlet 示例
下面的示例只处理指定的 Invoice,并验证付款是否确实应用成功:
function postRESTlet(dataIn) {
try {
var input = dataIn[0] || dataIn;
var invoiceId = input.sid;
var entityId = input.entity;
var requestedAmount = input.amount;
var paymentMethod = input.paymethod;
var paymentAccount = input.payaccount;
if (!invoiceId || !entityId) {
throw nlapiCreateError(
'MISSING_ARGUMENT',
'invoiceId and entityId are required.'
);
}
var invoices = nlapiSearchRecord(
'invoice',
null,
[
new nlobjSearchFilter(
'internalid',
null,
'anyof',
invoiceId
),
new nlobjSearchFilter(
'entity',
null,
'anyof',
entityId
),
new nlobjSearchFilter(
'status',
null,
'is',
'CustInvc:A'
),
new nlobjSearchFilter(
'mainline',
null,
'is',
'T'
)
],
[
new nlobjSearchColumn('internalid')
]
);
if (!invoices || invoices.length === 0) {
return {
success: false,
message: 'Invoice not found, belongs to another customer, or is no longer open.'
};
}
var payment = nlapiTransformRecord(
'invoice',
invoiceId,
'customerpayment'
);
payment.setFieldValue(
'trandate',
nlapiDateToString(new Date())
);
if (paymentMethod) {
payment.setFieldValue('paymentmethod', paymentMethod);
}
if (paymentAccount) {
payment.setFieldValue('account', paymentAccount);
}
var lineCount = payment.getLineItemCount('apply');
var targetFound = false;
var amountApplied = null;
for (var i = 1; i <= lineCount; i++) {
var lineInvoiceId = payment.getLineItemValue(
'apply',
'internalid',
i
);
if (String(lineInvoiceId) === String(invoiceId)) {
var outstandingAmount =
payment.getLineItemValue('apply', 'due', i) ||
payment.getLineItemValue('apply', 'total', i);
amountApplied = requestedAmount || outstandingAmount;
payment.setLineItemValue(
'apply',
'amount',
i,
amountApplied
);
payment.setLineItemValue(
'apply',
'apply',
i,
'T'
);
targetFound = true;
} else {
// 明确保证付款只应用到目标 Invoice
payment.setLineItemValue(
'apply',
'apply',
i,
'F'
);
}
}
if (!targetFound) {
throw nlapiCreateError(
'INVOICE_NOT_IN_APPLY_LIST',
'The target invoice was not found in the payment apply sublist.'
);
}
payment.setFieldValue('payment', amountApplied);
var paymentId = nlapiSubmitRecord(payment, true, true);
return {
success: true,
paymentId: paymentId,
invoiceId: invoiceId,
amountApplied: amountApplied,
message: 'Payment was created and applied to the invoice.'
};
} catch (e) {
nlapiLogExecution(
'ERROR',
e.name || 'PAYMENT_ERROR',
e.message || String(e)
);
return {
success: false,
message: e.message || 'An unexpected error occurred.'
};
}
}
注意事项
Customer Payment 金额必须和应用金额一致
payment 表示本次收款总额,apply.amount 表示应用到某张 Invoice 的金额。只支付一张 Invoice 时,通常应该让这两个值保持一致:
payment.setFieldValue('payment', amountApplied);
payment.setLineItemValue('apply', 'amount', i, amountApplied);
否则可能产生未应用付款或部分付款,也可能因为金额不合法而导致提交失败。
Paid In Full 取决于未结余额
仅仅创建 Customer Payment,并不代表 Invoice 已经结清。付款必须成功应用到该 Invoice,并将其剩余应收金额降为零,状态才会自动更新为 Paid In Full。
如果传入的 requestedAmount 小于未结余额,这次操作只能形成部分付款。如果业务要求始终全额结清,建议不要使用客户端传入的金额,而是直接读取并使用 apply 行上的未结金额。
不要重复设置已经由转换带出的字段
从 Invoice 转换后,客户、币种和部分交易信息通常会由 NetSuite 自动带入。再次设置以下字段,可能触发表单、币种或会计期间方面的校验:
payment.setFieldValue('customer', entityId);
payment.setFieldValue('currency', 1);
payment.setFieldValue('exchangerate', '1.00');
只有业务确实需要,并且账户配置允许时,才应该覆盖这些值。尤其不要硬编码币种和汇率,否则多币种客户的付款金额可能出错。
校验 Invoice 与客户的关系
RESTlet 不能只信任请求中的 entity。提交前应确认目标 Invoice:
- 属于该客户;
- 仍处于未结状态;
- 出现在 Customer Payment 的
apply子列表中。
这些校验还能防止付款被错误地应用到其他客户的交易。
处理并发付款
从搜索完成到提交付款之间,Invoice 可能已经被其他用户或集成支付。因此,提交时需要捕获错误。必要时,应在失败后重新读取 Invoice 余额,而不是直接重复创建付款,否则可能造成重复收款。
处理这类付款时,应该先创建带有正确交易上下文的 Customer Payment,再根据它自身 apply 子列表的行号定位 Invoice。外部搜索结果的数组下标不能当作付款子列表的行号。
备注:内容仅供参考。
