
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:
-
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. -
Sales orders
Fields such as OrderID, OrderDate, CustomerID, OrderAmount, OrderQuantity. -
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.

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.

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:
| Finding | Details |
|---|---|
| Invoice without order | INV9999 – OrderID unknown |
| Orders without invoice | ORD1002, ORD1006 |
| Orders without delivery | ORD1003, ORD1006 |
| Amount variance | ORD1004: €400 vs €450 |
| Quantity variance | ORD1005: 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.

