PAYATHON 2026

使用 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);

否则可能产生未应用付款或部分付款,也可能因为金额不合法而导致提交失败。

仅仅创建 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。外部搜索结果的数组下标不能当作付款子列表的行号。

备注:内容仅供参考。