NetSuite · Purchasing · 5 August 2026

Blanket purchase orders: which release does this invoice belong to?

Six lines, same item, same rate, same quantity. The only difference is a date. Match on item code and every invoice for the next twelve months posts against release one.

What a blanket looks like in the data

A blanket purchase order buys an annual quantity at a locked price and schedules it out across the year. The buyer gets the price break and doesn't warehouse twelve months of stock; the vendor gets a production schedule.

In NetSuite that is expressed as one line per release. Here is a real one, six releases of the same packaging item:

Line Item Ordered Received Billed Expected
1PKG-BOX-A3,0003,1503,15015 Jul 2026
2PKG-BOX-A3,0000026 Sep 2026
3PKG-BOX-A3,000008 Dec 2026
4PKG-BOX-A3,0000018 Feb 2027
5PKG-BOX-A3,000001 May 2027
6PKG-BOX-A3,0000014 Jul 2027

Same item on every line. Same rate. Same quantity. The only thing that distinguishes one release from another is the expected receipt date.

Why this breaks almost every matching routine

Ask the obvious question — "which PO line does this invoice line belong to?" — and the obvious implementation is to match on item:

const poLine = poLines.find(p => normalise(p.item_name) === normalise(invoiceLine.itemCode));

On a normal PO that is correct. On this one it returns line 1 and stops. Every invoice against this PO for the next twelve months posts to release one.

Consider what that produces. Release one over-bills repeatedly while releases two through six stay at zero billed forever. The PO never closes, because five lines are permanently open. Any report of committed spend is wrong. And nothing errors — each individual bill looks perfectly reasonable on its own.

Worth pausing on: item code is a natural key on most purchase orders and is not a key at all on a blanket. If your matching logic assumes one line per item, a blanket is not an edge case you handle later — it is a silent, compounding wrong answer.

The rule: consume releases in date order

Sort that item's open releases by expected receipt date, then walk them oldest first, skipping any already fully billed, allocating quantity until the invoice line is satisfied:

const releases = poLines
    .filter(l => sameItem(l, invoiceLine))
    .filter(l => l.isclosed !== 'T')
    .sort(byExpectedDateThenSequence);

let remaining = invoiceLine.quantity;

releases.forEach(line => {
    if (remaining <= 0) return;
    const billable = Math.max(line.received - line.billed - alreadyTaken(line), 0);
    const capacity = billable > 0
        ? billable
        : Math.max(line.ordered - line.billed - alreadyTaken(line), 0);
    if (capacity <= 0) return;

    const take = Math.min(remaining, capacity);
    allocate(line, take);
    remaining -= take;
});

Two details in there matter more than they look.

An invoice line can span two releases. If release one has 3,150 billable and the invoice is for 4,000, you allocate 3,150 to release one and 850 to release two — two rows on the bill from one row on the invoice. That is not a workaround; it is what physically happened.

Capacity falls back from received to ordered. Normally you bill what was received. But a release that has not been received yet is still a legitimate target when everything earlier is closed — for a service or an early invoice. Falling back rather than skipping keeps that case working without letting it jump the queue.

Where the run date belongs

The instinct is to pick the release whose expected date is nearest to today. It is the wrong rule, and it is worth understanding why.

Invoices arrive late. Frequently weeks late, sometimes after the next release has already shipped. Select by proximity to today and a stack of invoices worked on a Friday afternoon will scatter across releases according to when somebody happened to open them.

Consuming oldest-open-first is stable regardless of when the invoice is processed, because it depends on the state of the PO rather than the clock.

So the run date becomes a validator instead. If the release the allocator lands on is expected well in the future — say more than 45 days out — and nothing has been received against it, that is flagged for review:

A release expected two months out, with nothing received, usually means an earlier release was billed twice or a receipt was never entered.

That warning does real work. It is the earliest visible symptom of a duplicate payment on this kind of PO, and it appears at the moment somebody can still do something about it.

Closing a release

When a release is fully billed it should close, so the PO stops showing five open lines while only one is live. Two constraints:

Close after the bill saves, not during preview. The billed quantity on a PO line is only correct once the bill exists. Deciding during preview closes lines that were never billed.

Match by line sequence only. This is the one that will bite you. A closure routine that matches by sequence and falls back to item id looks defensive and is actively dangerous here — every release carries the same item, so the fallback closes an arbitrary release, quite possibly one nobody has billed. On a blanket, sequence or nothing.

The general lesson

Every part of this comes from one property of the data: a field that is a unique key in the common case is not a key in the important case. Item code, on a purchase order, is that field.

When you are writing matching logic against transaction lines, the question to ask early is not "how do I identify this line" but "can this identifier repeat, and what distinguishes the duplicates when it does?" Here the answer was a date column nobody had thought about, and it changed the whole shape of the solution.

Related: ACH addenda: telling the vendor what you just paid them for.

New England Systems Group

If there's a process everyone works around instead of through, that's where we start.

SuiteScript development and back-office automation for manufacturers and distributors. Danbury, Connecticut.

hello@nesystemsgroup.com