Logo
AfbeeldingThe AuditBuilder

3-way match (AR): invoices, orders, and deliveries

7 minutesAudit associateAudit data analyst
How do you know every sales invoice traces back to a real order and delivery? A 3-way match validates that revenue, orders, and deliveries line up and immediately highlights ghost invoices, unbilled deliveries, and quantity mismatches.

3-way match receivables

One of the most common revenue analytics is checking whether sales invoices fully and accurately tie back to the underlying sales orders and deliveries.

You want to avoid ghost invoices (revenue without order or delivery), unbilled deliveries (goods out, no revenue), and mismatches in amounts or quantities.

This 3-way control supports both accuracy (does the revenue amount make sense?) and completeness (was everything delivered and invoiced?).

When do you run this analysis?

Revenue work often revolves around cut-off and existence: was everything that got invoiced actually ordered by a customer and shipped? The 3-way match is a great first test for:

  • Cut-off near year-end (delivery after the reporting date, invoice before)
  • Design risks in fast-close processes that rely on manual invoices
  • Process compliance: are exceptions (credit memos, partial shipments) properly recorded?

Running this baseline test early shows you where additional detail testing is needed.

What data do we need?

The analysis uses three tables:

  1. Sales invoices
    Fields such as InvoiceID, InvoiceDate, InvoiceAmount, InvoiceQuantity, CustomerID, OrderID (reference to the originating order) plus line prices or VAT components so you can re-create quantity or price differences.

  2. Sales orders
    Fields such as OrderID, OrderDate, CustomerID, OrderAmount, OrderQuantity.

  3. Delivery notes
    Fields such as DeliveryID, DeliveryDate, DeliveredQuantity, OrderID (reference).

These three datasets cover the full downstream process.

Field tip: Work at line level where possible. One order can contain multiple lines (and deliveries). Create a unique key (e.g. OrderID + OrderLine) and keep that same key in the invoice and delivery data. Only then can you capture quantity or price variances correctly.

We assume the invoice file ties back to the general ledger, so you know you are testing the right population. Also confirm up front whether the extract includes credit memos, returns, and manual adjustments.

3-way match process flow

1. Load the files in Python

Below we read CSV files, but these could just as well be Excel or JSON files—swap read_csv for read_excel or read_json as needed.

import pandas as pd

## Load source data
invoices = pd.read_csv("dummy_sales_invoices.csv", parse_dates=["InvoiceDate"])
orders = pd.read_csv("dummy_sales_orders.csv", parse_dates=["OrderDate"])
deliveries = pd.read_csv("dummy_delivery_notes.csv", parse_dates=["DeliveryDate"])

print(invoices.head())
print(orders.head())
print(deliveries.head())

2. Link invoices to orders

We match invoices to orders via OrderID. The outer join makes sure mismatches remain visible because we keep all records from both datasets.

df_order_invoice = orders.merge(
    invoices,
    on="OrderID",
    how="outer",
    suffixes=("_order", "_invoice")
)

print(df_order_invoice.head())

Working with order lines instead of order headers? Aggregate first so you have total amounts and quantities per order:

order_lines = orders.groupby("OrderID").agg({
    "OrderAmount": "sum",
    "OrderQuantity": "sum"
}).reset_index()

Then join the aggregated data to the invoices as above.

Analysis 1: Orders with no invoice

Start with orders that never received an invoice. These transactions often still need to be billed or were cancelled without an update to the order table.

orders_without_invoice = df_order_invoice[df_order_invoice['InvoiceID'].isna()]
print(orders_without_invoice)

This list shows orders that were never billed.

Analysis 2: Invoices with no order

Next, scan for invoices without an order reference. These could be ghost invoices, manual postings outside the normal order flow, or legitimate invoices that do not require an order.

invoices_without_order = df_order_invoice[df_order_invoice['OrderDate'].isna()]
print(invoices_without_order)

3. Detect amount variances

After the completeness checks, perform an accuracy test: does the invoiced amount equal the order amount? This instantly highlights price changes, add-on charges, or rounding issues.

amount_variances = df_order_invoice[
    (~df_order_invoice['OrderID'].isna()) &
    (~df_order_invoice['InvoiceID'].isna()) &
    (df_order_invoice['OrderAmount'] != df_order_invoice['InvoiceAmount'])
]

print(amount_variances)

Example:

  • €400 ordered
  • €450 invoiced

4. Link delivery notes to orders

Now join orders to deliveries. This shows whether every order was shipped and helps spot deliveries that lack an order (e.g. emergency shipments).

df_order_delivery = orders.merge(
    deliveries,
    on="OrderID",
    how="outer",
    suffixes=("_order", "_delivery")
)

print(df_order_delivery.head())

Analysis 3: Orders with no delivery

These are orders still open or never fulfilled. They usually require follow-up with logistics or order management.

orders_without_delivery = df_order_delivery[df_order_delivery['DeliveryID'].isna()]
print(orders_without_delivery)

Once you have all three datasets, combine them in a single 3-way dataset. That way you see every data point per order line in one go.

three_way = df_order_invoice.merge(
    deliveries,
    on="OrderID",
    how="outer"
)

print(three_way.head())

Always rely on the same unique key (e.g. OrderID + OrderLine). In this combined view you can immediately filter for scenarios such as delivery and invoice without order, delivery without invoice, or invoice issued before the delivery without building separate tables.

5. Quantity differences

Finally, compare ordered versus delivered quantities. This reveals partial shipments or over-deliveries that may later drive amount variances during invoicing.

quantity_variances = df_order_delivery[
    (~df_order_delivery['OrderID'].isna()) &
    (~df_order_delivery['DeliveryID'].isna()) &
    (df_order_delivery['OrderQuantity'] != df_order_delivery['DeliveredQuantity'])
]

print(quantity_variances)

For example:

  • 10 ordered
  • 8 delivered

6. Cut-off and timing

A classic revenue risk is invoicing in the old year while shipping in the new year.

3-way match cut-off

So we can add a test that compares order, delivery, and invoice dates:

cutoff_issues = df_order_invoice.merge(
    deliveries[['OrderID', 'DeliveryDate']],
    on='OrderID',
    how='left'
)

cutoff_issues = cutoff_issues[
    (~cutoff_issues['InvoiceDate'].isna()) &
    (~cutoff_issues['DeliveryDate'].isna()) &
    (cutoff_issues['InvoiceDate'] < pd.Timestamp("2025-12-31")) &
    (cutoff_issues['DeliveryDate'] > pd.Timestamp("2025-12-31"))
]

print(cutoff_issues[['OrderID','InvoiceDate','DeliveryDate','InvoiceAmount']])

Adjust the date boundaries to your fiscal year and investigate whether these transactions were recognized correctly.

7. Quick dashboard of counts and amounts

Individual lists are helpful, but a summary table with counts and totals makes it easier to communicate with the audit team or the client:

dashboard = {
    "orders_without_invoice": {
        "count": len(orders_without_invoice),
        "amount": orders_without_invoice['OrderAmount'].sum()
    },
    "invoices_without_order": {
        "count": len(invoices_without_order),
        "amount": invoices_without_order['InvoiceAmount'].sum()
    },
    "orders_without_delivery": {
        "count": len(orders_without_delivery),
        "amount": orders_without_delivery['OrderAmount'].sum()
    },
    "amount_variances": {
        "count": len(amount_variances),
        "difference": (amount_variances['InvoiceAmount'] - amount_variances['OrderAmount']).sum()
    },
    "quantity_variances": {
        "count": len(quantity_variances),
        "difference": (quantity_variances['DeliveredQuantity'] - quantity_variances['OrderQuantity']).sum()
    }
}

dashboard = pd.DataFrame(dashboard).T
print(dashboard)
dashboard.to_excel("3way_match_dashboard.xlsx")

This gives you an instant Excel output for the audit file. Sample output:

FindingDetails
Invoice without orderINV9999 – OrderID unknown
Orders without invoiceORD1002, ORD1006
Orders without deliveryORD1003, ORD1006
Amount varianceORD1004: €400 vs €450
Quantity varianceORD1005: 10 ordered / 8 delivered

8. Interpretation: tolerance, currency, and exceptions

The 3-way match results are a starting point for follow-up, not an automatic conclusion. Keep in mind:

  • Tolerances: define thresholds for amount and quantity differences (e.g. accept rounding gaps up to €1 or 0.5%) and convert all values to a single base currency before comparing.
  • VAT and add-on charges: do you invoice with VAT or with shipping/insurance charges embedded? Normalize or split out those components so you compare like-for-like.
  • Credit memos and cancelled orders: remove cancelled or credited orders or link them to the original order so you do not flag them twice.
  • Manual invoices or emergency deliveries: document which processes run outside the standard order flow and expect deliberate exceptions. Flag those records while loading the data.

With this context you can prioritize findings and follow up with the process or client team in a targeted way.

Conclusion

An accounts receivable 3-way match gives auditors a powerful assistant. With a handful of Python statements you can run a full analysis that would normally take hours in Excel and can reduce a big sample.

Help us improve The Audit Analytics

Was this article helpful?

Let us know if this page is useful so we can keep improving our library.

Reader sentimentNo feedback yet