DEV Community

Cover image for How do you match a Proof of Delivery to an Invoice?
Swathi Rajagopal
Swathi Rajagopal

Posted on AI-assisted

How do you match a Proof of Delivery to an Invoice?

A practical guide to the matching layer that decides whether a payment is released, held or disputed.

Extracting data from a proof of delivery (POD) is only half the job.

Once you know who signed, when, and how many cartons arrived, the real question follows: does this match what the supplier or transporter is billing for?

That's the matching layer. It's where extracted POD data turns into a decision: release the payment, hold it, or raise a dispute. This article walks through how to build it, with small Python examples you can adapt.

Why matching a POD to an Invoice matters

An invoice says what someone wants to be paid for. A POD says what was actually received.

When the two agree, payment can move with confidence. When they don't, the mismatch is exactly what a finance team needs to catch before the money leaves the account, not after.

For Indian logistics, the chain usually includes four documents: the supplier invoice, the delivery challan, the lorry receipt (LR) and the transporter's freight bill. The POD is the one that confirms what really happened at the door.

How to structure extracted POD data

Matching only works if every POD lands in the same shape, whatever format it arrived in. A simple record might look like this:

from dataclasses import dataclass, field


@dataclass
class PODRecord:
    pod_id: str
    invoice_number: str | None
    order_number: str | None
    lr_number: str | None
    delivery_date: str | None
    recipient: str | None
    signature_present: bool
    qty_received: dict[str, float] = field(default_factory=dict)  # item code -> quantity
    remarks: str = ""
    source_file: str = ""
    source_page: int = 1
Enter fullscreen mode Exit fullscreen mode

The example uses Python's built-in dataclasses, which keep records like this short and readable.

Two fields are easy to overlook but matter later: remarks, which carries notes like "2 cartons damaged", and the source reference (source_file and source_page), which we'll come back to.

How to choose match keys between POD, invoice, LR and freight bill

Think of an old cinema ticket torn in two. Each half is useless on its own, but the matching serial number proves they belong together.

Match keys are those serial numbers. They're the reference numbers that appear on both documents:

  • POD to supplier invoice: invoice number or order number
  • POD to delivery challan: order number or challan number
  • POD to LR and freight bill: LR number

A good approach is to try keys in order of reliability, and stop at the first clean match:

def find_invoice(pod, invoices):
    """Return (invoice, key_used) or (None, reason)."""
    candidates = [
        ("invoice_number", pod.invoice_number),
        ("order_number", pod.order_number),
        ("lr_number", pod.lr_number),
    ]
    for key, value in candidates:
        ref = normalise_ref(value)
        if not ref:
            continue
        hits = [inv for inv in invoices if normalise_ref(inv.get(key)) == ref]
        if len(hits) == 1:
            return hits[0], key
        if len(hits) > 1:
            return None, f"ambiguous_{key}"
    return None, "no_match"
Enter fullscreen mode Exit fullscreen mode

Notice that an ambiguous match is treated as a failure, not a guess. If two invoices share the same order number, a person should decide which one the POD belongs to.

How to normalise reference numbers before matching

The same LR number can appear as LR No. 004521 on the POD and lr-4521 on the freight bill. Compared as plain strings, they don't match.

Normalising references first fixes most of these near misses:

import re


def normalise_ref(ref):
    """Turn 'LR No. 004521' and 'lr-4521' into the same key: '4521'."""
    if not ref:
        return None
    ref = ref.upper().strip()
    # Remove common prefixes such as 'LR No.', 'INV#' or 'PO:'
    ref = re.sub(r"^(LR|INV|PO)[\s\-:#/.]*(NO\.?)?[\s\-:#/.]*", "", ref)
    # Keep only letters and digits
    ref = re.sub(r"[^A-Z0-9]", "", ref)
    return ref.lstrip("0") or "0"
Enter fullscreen mode Exit fullscreen mode

Be careful with rules like stripping leading zeros. They help in most cases, but check them against your own data, since some organisations use zero-padded numbers that are genuinely different.

How to compare quantities and flag short, damaged or refused deliveries

Once a POD is matched to an invoice, compare what was billed with what was received, item by item:

def compare_quantities(billed, received):
    """Compare billed and received quantities per item code."""
    issues = []
    for item, billed_qty in billed.items():
        got = received.get(item, 0)
        if got < billed_qty:
            issues.append({"item": item, "type": "short", "billed": billed_qty, "received": got})
        elif got > billed_qty:
            issues.append({"item": item, "type": "over", "billed": billed_qty, "received": got})
    for item in received.keys() - billed.keys():
        issues.append({"item": item, "type": "not_billed", "received": received[item]})
    return issues
Enter fullscreen mode Exit fullscreen mode

Quantities don't tell the whole story, though. Damage and refusals usually show up in the receiver's handwritten remarks, so it's worth scanning those too:

REMARK_FLAGS = {
    "damaged": r"damag|broken|torn|leak",
    "refused": r"refus|reject|returned",
}


def flags_from_remarks(remarks):
    """Find exception keywords in free-text remarks."""
    text = remarks.lower()
    return [flag for flag, pattern in REMARK_FLAGS.items() if re.search(pattern, text)]
Enter fullscreen mode Exit fullscreen mode

Keyword rules like these are a starting point. Real remarks are messy, abbreviated and sometimes in regional languages, so production systems usually add AI-based classification on top.

How to route each exception to the right team

A generic "exception" bucket isn't enough. Each type of problem needs a specific owner:

ROUTES = {
    "short": "receiving, then accounts payable (credit note)",
    "over": "receiving",
    "not_billed": "accounts payable",
    "damaged": "receiving, then accounts payable (credit note)",
    "refused": "returns and disputes (hold invoice)",
    "missing_signature": "manual review or transporter confirmation",
    "no_match": "accounts payable exceptions",
}


def decide(pod, invoice, match_result):
    """Return a release or hold decision with routed exceptions."""
    exceptions = []
    if invoice is None:
        exceptions.append("no_match")
    else:
        for issue in compare_quantities(invoice["qty_billed"], pod.qty_received):
            exceptions.append(issue["type"])
    if not pod.signature_present:
        exceptions.append("missing_signature")
    exceptions += flags_from_remarks(pod.remarks)

    return {
        "pod_id": pod.pod_id,
        "matched_on": match_result if invoice else None,
        "decision": "hold" if exceptions else "release",
        "exceptions": [{"type": e, "route_to": ROUTES.get(e, "accounts payable exceptions")} for e in exceptions],
        "source": f"{pod.source_file}#page={pod.source_page}",
    }
Enter fullscreen mode Exit fullscreen mode

The rule is simple: a clean match releases the payment, and anything else holds it and tells someone exactly what to look at.

Why every record should keep a reference to its source document

Look at the last line of decide(). Every decision carries a pointer back to the original file and page.

That one field makes the whole system defensible. When a supplier disputes a credit note, or an auditor asks why an invoice was held, the reviewer can open the signed receipt in one click instead of searching a shared drive.

Without it, your structured data is faster to query but much harder to trust. It’s the same principle as making credit signals explainable: always show your working.

Wrap up

Remember the torn cinema ticket. The matching layer's job is to find the two halves that belong together, check that they really fit, and raise a hand when they don't.

The examples here are deliberately simple. Production systems handle more document types, smarter remark classification and fuzzier matching. But the shape stays the same: structure the data, normalise the keys, compare carefully, route every exception to an owner, and always keep the source one click away.

Getting the extraction right is the first step. Document understanding SaaS solution like DocuGenie.AI's POD automation handle that part across PDFs, scans and phone photos. The matching layer is what turns that data into decisions.

Top comments (0)