Customer Invoice Automation

Automate invoice creation when an Item Fulfillment is created in Shipped status.

Criterias:

  • The invoice should be automatically created when an item fulfillment is generated in Shipped status. 
  • Currently pick pack ship feature is not enabled in the account, hence it IF is created directly in the shipped status.  
  • The automation should not execute during edits of item fulfillments. 
  • The invoice should include only the items and quantities that are fulfilled in the corresponding item fulfillment (supports partial fulfillment and partial invoicing). 
  • The automation should apply to all sales orders, not limited to those synced from Shopify 

Additional rules: 

  • Non-fulfillable items (e.g. Description, Markup, Payment, Subtotal items) must be carried over from the sales order and included only in the first invoice during partial fulfillment scenarios. 
  • Freight items (Other Charge type but fulfillable in current setup) will be copied from the item fulfillment to the invoice. These will also appear only in the first invoice when multiple fulfillments exist. 
  • OtherCharge, non-inventory, service with can be fulfilled checkbox unchecked will be added to first invoice only. 
  • Line discount items should be included in the invoice together with their corresponding item. 
  • Kit items are currently handled at the parent level in both sales order and fulfillment. The invoice should include only the parent kit item. 
  • Sales order-level discounts (both flat-rate and percentage) should be prorated and applied across invoices in case of partial invoicing. 
  • The created invoice must include a link back to the originating item fulfillment in a custom field for audit and traceability.

Solution:

We have developed a User Event Script.

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */
/*******************************************************************************
 * Crest Group Ltd-NZ-NS
 *
 * CGLNN-446:  Customer Invoice Automation
 *************************************************************************
 *
 * Author: Jobin & Jismi
 *
 * Date Created : 18-Sep-2025
 *
 * COPYRIGHT © 2025 Jobin & Jismi. All rights reserved.This script is a proprietary product of Jobin & Jismi
 * and is protected by copyright law and international treaties. Unauthorized reproduction or distribution of
 * this script, or any portion of it, may result in severe civil and criminal penalties and will be prosecuted
 * to the maximum extent possible under law.
 *
 * Description :
 * Automated mechanism within NetSuite to generate invoices from item fulfillments (IF) when they are created in shipped status.
 * The automation must trigger during the creation of item fulfillments and meet the following conditions:
 * - The invoice should include only the items and quantities that are fulfilled in the corresponding item fulfillment (supports partial fulfillment and partial invoicing)
 * - Line discount items should be included in the invoice together with their corresponding item.
 * - Non-fulfillable items included only in the first invoice during partial fulfillment scenarios.
 * - Sales order-level discounts (both flat-rate and percentage) should be prorated and applied across invoices in case of partial invoicing
 * - The invoice should include only the parent kit item
 * - Sales order-level discounts (both flat-rate and percentage) should be prorated and applied across invoices in case of partial invoicing
 * REVISION HISTORY
 *
 * @version 1.0 CGLNN-446 : 18-Sep-2025 : Initial Build by JJ0054
 ******************************************************************************/
define(["N/record", "N/search"]
/**
 * @param{record} record
 * @param{search} search
 */, (record, search) => {
        /**
         * Defines the function definition that is executed after record is submitted.
         * @param {Object} scriptContext
         * @param {Record} scriptContext.newRecord - New record
         * @param {Record} scriptContext.oldRecord - Old record
         * @param {string} scriptContext.type - Trigger type; use values from the context.UserEventType enum
         * @since 2015.2
         */
        const afterSubmit = (scriptContext) => {
            try {
                if (scriptContext.type !== scriptContext.UserEventType.CREATE) return;


                let ifRecord = scriptContext.newRecord;
                let ifId = ifRecord.id;
                let salesOrderId = ifRecord.getValue({ fieldId: "createdfrom" });
                if (!salesOrderId) return;
                let qtyByOrderLine = {};
                let itemIdByOrderLine = {};
                let ifLineCount = ifRecord.getLineCount({ sublistId: "item" });
                for (let i = 0; i < ifLineCount; i++) {
                    try {
                        let orderline = ifRecord.getSublistValue({
                            sublistId: "item",
                            fieldId: "orderline",
                            line: i,
                        });
                        if (!orderline) continue;
                        let itemId = ifRecord.getSublistValue({
                            sublistId: "item",
                            fieldId: "item",
                            line: i,
                        });
                        let qty =
                            Number(
                                ifRecord.getSublistValue({
                                    sublistId: "item",
                                    fieldId: "quantity",
                                    line: i,
                                })
                            ) || 0;
                        if (qty > 0) {
                            qtyByOrderLine[orderline] = qty;
                            itemIdByOrderLine[orderline] = itemId;
                        }
                    } catch (e) {
                        log.error("error @ reading IF line", e.message);
                        return;
                    }
                }
                let soLineMap = buildSalesOrderLineMap(salesOrderId); // updated below
                let invoiceRecord = record.transform({
                    fromType: record.Type.SALES_ORDER,
                    fromId: salesOrderId,
                    toType: record.Type.INVOICE,
                    isDynamic: true,
                });


                let invLineCount = invoiceRecord.getLineCount({ sublistId: "item" });
                for (let j = invLineCount - 1; j >= 0; j--) {
                    invoiceRecord.selectLine({ sublistId: "item", line: j });


                    let itemType = invoiceRecord.getCurrentSublistValue({
                        sublistId: "item",
                        fieldId: "itemtype",
                    });
                    let invOrderLine = invoiceRecord.getCurrentSublistValue({
                        sublistId: "item",
                        fieldId: "orderline",
                    });
                    let itemId = invoiceRecord.getCurrentSublistValue({
                        sublistId: "item",
                        fieldId: "item",
                    });
                    let isFulfillable = invoiceRecord.getCurrentSublistValue({
                        sublistId: "item",
                        fieldId: "fulfillable",
                    });
                    let invAmount = invoiceRecord.getCurrentSublistValue({
                        sublistId: "item",
                        fieldId: "amount",
                    });


                    let genericNonFulfillable =
                        ["Subtotal", "Description", "Payment", "Markup", "Discount"].includes(
                            itemType
                        ) ||
                        (["Service", "OthCharge", "NonInvtPart"].includes(itemType) &&
                            !isFulfillable);
                    let soMeta = soLineMap.find((x) => x.soOrderLine === invOrderLine);
                    if (itemType === "Discount") {
                        if (!soMeta || !soMeta.appliesToOrderLine) {
                            invoiceRecord.removeLine({ sublistId: "item", line: j });
                            continue;
                        }


                        // Keep discount ONLY if the applied-to line is fulfilled in this IF
                        let appliedToOL = soMeta.appliesToOrderLine;
                        let fulfilledQty = qtyByOrderLine[appliedToOL];


                        if (!fulfilledQty) {
                            invoiceRecord.removeLine({ sublistId: "item", line: j });
                            continue;
                        }
                        let rate = invoiceRecord.getCurrentSublistValue({
                            sublistId: "item",
                            fieldId: "rate",
                        }); // e.g., "-10.00%" or "-25.00"
                        let isPercent = rate && /%$/.test(String(rate).trim());
                        if (!isPercent) {
                            // prorate fixed amount by (fulfilledQty / originalQty of applied line)
                            let appliedSoLine = soLineMap.find(
                                (x) => x.soOrderLine === appliedToOL
                            );
                            if (appliedSoLine && appliedSoLine.itemQty > 0) {
                                let ratio = Number(fulfilledQty) / Number(appliedSoLine.itemQty);
                                let origAmt = Number(soMeta.itemAmt) || 0; // discount amounts are negative
                                let proratedAmt = +(origAmt * ratio).toFixed(2);
                                invoiceRecord.setCurrentSublistValue({
                                    sublistId: "item",
                                    fieldId: "amount",
                                    value: proratedAmt,
                                });
                            }
                        }
                        invoiceRecord.commitLine({ sublistId: "item" });
                        continue;
                    }
                    if (!genericNonFulfillable) {
                        let fulfilledQty = qtyByOrderLine[invOrderLine];
                        if (!fulfilledQty) {
                            invoiceRecord.removeLine({ sublistId: "item", line: j });
                            continue;
                        }
                        invoiceRecord.setCurrentSublistValue({
                            sublistId: "item",
                            fieldId: "quantity",
                            value: fulfilledQty,
                        });


                        invoiceRecord.setCurrentSublistValue({
                            sublistId: "item",
                            fieldId: "amount",
                            value: invAmount,
                        });


                        // If NonInvtPart that was marked fulfillable on SO, adjust amount proportionally
                        if (itemType === "NonInvtPart" && soMeta) {
                            let invoiceAmt =
                                (Number(soMeta.itemAmt) / Number(soMeta.itemQty || 1)) *
                                Number(fulfilledQty);
                            invoiceRecord.setCurrentSublistValue({
                                sublistId: "item",
                                fieldId: "amount",
                                value: +invoiceAmt.toFixed(2),
                            });
                        }


                        invoiceRecord.commitLine({ sublistId: "item" });
                        continue;
                    }
                    if (soMeta && soMeta.qtyBilled) {
                        invoiceRecord.removeLine({ sublistId: "item", line: j });
                        continue;
                    }
                }
                let invoiceId;
                try {
                    let invLineCountNew = invoiceRecord.getLineCount({ sublistId: "item" });
                    if (invLineCountNew > 0) {
                        invoiceId = invoiceRecord.save({
                            enableSourcing: true,
                            ignoreMandatoryFields: false,
                        });
                    }
                } catch (e) {
                    try {
                        let errorLog = record.create({
                            type: "customrecord_jj_inv_failed_records_439",
                            isDynamic: true,
                        });
                        errorLog.setValue({
                            fieldId: "custrecord_jj_item_fulfilment",
                            value: ifId,
                        });
                        errorLog.setValue({
                            fieldId: "custrecord_jj_invoice_error_log",
                            value: e.message,
                        });
                        errorLog.save();
                    } catch (e2) {
                        log.error("Failed to create error log record", e2);
                        return;
                    }
                    return;
                }


                if (invoiceId) {
                    record.submitFields({
                        type: record.Type.ITEM_FULFILLMENT,
                        id: ifId,
                        values: { custbody_jj_associated_invoice: invoiceId },
                        options: { enableSourcing: false, ignoreMandatoryFields: true },
                    });
                }
            } catch (e) {
                log.error("error @ afterSubmit", e);
                return;
            }
        };


        /**
         * Builds a map of sales order line items for a given sales order, including item details and discount associations.
         *
         * @param {string|number} salesOrderId - The internal ID of the sales order to process.
         * @returns {Array<Object>} An array of objects, each containing line item details (line number, item ID, item type, amount, quantity, and optional appliesTo for discounts).
         * @throws {Error} If the sales order cannot be loaded or required parameters are invalid.
         */
        function buildSalesOrderLineMap(salesOrderId) {
            try {
                let lineMap = [];
                let soRec = record.load({
                    type: record.Type.SALES_ORDER,
                    id: salesOrderId,
                    isDynamic: false,
                });
                let lineCount = soRec.getLineCount({ sublistId: "item" });


                for (let i = 0; i < lineCount; i++) {
                    let itemId = soRec.getSublistValue({
                        sublistId: "item",
                        fieldId: "item",
                        line: i,
                    });
                    let itemType = soRec.getSublistValue({
                        sublistId: "item",
                        fieldId: "itemtype",
                        line: i,
                    });
                    let itemAmt =
                        Number(
                            soRec.getSublistValue({
                                sublistId: "item",
                                fieldId: "amount",
                                line: i,
                            })
                        ) || 0;
                    let itemQty =
                        Number(
                            soRec.getSublistValue({
                                sublistId: "item",
                                fieldId: "quantity",
                                line: i,
                            })
                        ) || 0;
                    let soOrderLine = soRec.getSublistValue({
                        sublistId: "item",
                        fieldId: "line",
                        line: i,
                    });
                    let qtyBilled =
                        Number(
                            soRec.getSublistValue({
                                sublistId: "item",
                                fieldId: "quantitypickpackship",
                                line: i,
                            })
                        ) || 0;


                    let entry = {
                        line: i,
                        itemId,
                        itemType,
                        itemAmt,
                        itemQty,
                        soOrderLine,
                        qtyBilled,
                    };


                    if (itemType === "Discount") {
                        // Link to the nearest *applicable* SO line ABOVE by storing its SO orderline number
                        for (let j = i - 1; j >= 0; j--) {
                            let prevType = soRec.getSublistValue({
                                sublistId: "item",
                                fieldId: "itemtype",
                                line: j,
                            });
                            if (
                                [
                                    "InvtPart",
                                    "Assembly",
                                    "Kit",
                                    "NonInvtPart",
                                    "Service",
                                    "OthCharge",
                                    "Markup",
                                    "Subtotal",
                                ].includes(prevType)
                            ) {
                                let appliesToOrderLine = soRec.getSublistValue({
                                    sublistId: "item",
                                    fieldId: "line",
                                    line: j,
                                });
                                entry.appliesToOrderLine = appliesToOrderLine;
                                break;
                            }
                        }
                    }


                    lineMap.push(entry);
                }
                return lineMap;
            } catch (e) {
                log.error("error @ buildSalesOrderLineMap", e);
                return [];
            }
        }
        return { afterSubmit };
    });

 

Leave a comment

Your email address will not be published. Required fields are marked *