PO vs Invoice Matcher (PHP)
Updated
What it does#
Upload a purchase order and the invoice that bills it, extract both, then compare quantities, unit prices, line amounts, tax and header totals. Differences appear side by side with the expected and actual values.
Source: po-invoice-matcher in the examples repository.

Run it#
Requires PHP 8.1+ with the curl and json extensions, and Composer.
cd po-invoice-matcher
composer install
composer start
Open http://127.0.0.1:3103 and choose Explore sample data. The PO orders four monitors at 200.00 and four keyboards at 40.00. The invoice bills five monitors and raises the keyboard to 45.00. The matcher reports the quantity, price, line amount, tax and header total differences.
composer start runs PHP's built-in server with -t public and a router, so only the public assets and the API are reachable. The app is for local use and ships no shared-hosting rewrite rules.
The SDK calls#
public/api.php has three actions. Two of them call DocXtract.
use DocXtract\DocXtract;
use DocXtract\Exception\DocXtractException;
$dx = new DocXtract(['api_key' => $key, 'max_retries' => 2]);
$models = $dx->models(); // costs no credits
Extraction writes the upload to a private temp directory and removes it in finally.
$options = ['document_type' => $settings['documentType'] ?? 'invoice', 'store_db' => false];
if (trim($model) !== '') $options['model'] = trim($model);
try {
set_time_limit(0);
$result = $dx->extract($file, $options);
echo json_encode(['raw' => $result->data(), 'meta' => $result->meta(), 'complete' => $result->isComplete()]);
} catch (Throwable $e) {
http_response_code($e instanceof DocXtractException ? 502 : 400);
echo json_encode(['error' => preg_replace('/sk_[A-Za-z0-9_-]+/', '[redacted]', $e->getMessage())]);
} finally {
if ($file && is_file($file)) unlink($file);
if ($folder && is_dir($folder)) rmdir($folder);
}
Two things worth copying. set_time_limit(0) because a long PDF goes through the multi-page flow and can exceed PHP's default execution limit. And the key redaction in the error path, because SDK exceptions can echo request details.
The PO and the invoice each have their own document type and model. The invoice defaults to invoice. The PO starts at purchase_order, which is a starting template name and not a promise that every key has it enabled. Press Test connection & load models and pick one that is.
The matching rules#
- Prefer a unique item or SKU code. If the invoice line has none, allow an exact normalised description match. Never match by array position and never guess a fuzzy description.
- Duplicate identities and ambiguous candidates go to review. Two different non-empty SKUs never match on description.
- For paired lines compare quantity, unit price, amount and line tax. A missing tax stays unknown. If a document has no line tax at all, the reconciliation stays flagged even when the header tax agrees.
- Compare currency, vendor, PO reference, subtotal, total tax and grand total. A currency difference is an exception. No conversion is attempted.
- Money tolerance defaults to 0.01 and quantity tolerance to 0. Both are absolute and configurable from 0 to 100.
- Every unmatched line on either side is kept and shown.
The scope is one complete PO against one invoice. Partial deliveries, many-to-one line aggregation and tax-inclusive schemas need a person. The comparison does not approve payment.
Field mapping#
Header keys: number, poReference, currency, vendor, subtotal, tax, total, items. Row keys: sku, description, quantity, rate, amount, lineTax. The invoice should carry the PO reference as po_number or a mapped equivalent. The PO and the invoice have separate mappings.
{"number":"Header.InvoiceNumber","poReference":"Header.PurchaseOrder","items":"Details","quantity":"Qty","rate":"UnitPrice","lineTax":"TaxAmount"}
Where things are#
| File | Purpose |
|---|---|
public/api.php | Connect, extract and match actions, SDK calls, temp-file cleanup |
includes/matching.php | Normalisation and one-to-one comparison |
includes/config.php | SDK defaults and upload limit |
public/index.php | Page assembled from require_once __DIR__ view includes |
router.php | Routes for the built-in server |
tests/matching_test.php | Ambiguous line identity, currency mismatches, tolerances |
composer test
Something wrong or missing on this page? Tell us.