The extraction looks perfect.
The model reads the PDF, returns a vendor name, an invoice number, a date, a currency, and a total of 1.190,00. Confidence: 0.98. Someone wires the output straight into a database insert. For two weeks, it works.
Then an invoice arrives with 1,190.00, another arrives twice through two different mailboxes, and a third has a subtotal that does not add up to the total. Somewhere in your production database, there is now a record that looks authoritative but was never actually verified.
That is the failure mode of most AI document pipelines: extraction is treated as verification.
A model can tell you what a document appears to say. It cannot tell you whether that document is a legitimate obligation your business should record. Those are different problems, and they need different owners.
Here is a practical architecture that separates them: n8n orchestrates the workflow, an extraction service proposes fields, and Laravel owns normalization, verification, identity, and the database write. The result is not “AI inserts invoices.” It is “AI proposes a draft, and the system either verifies it or sends it to a human.”
TL;DR
- n8n handles triggering, sequencing, retries, and routing.
- Laravel owns money normalization, business rules, identity, and writes.
- The model produces a draft contract, never a database row.
- Every document gets a content hash before extraction.
- Every invoice gets a verification result before commitment.
- Every uncertain case goes to a review queue, not straight to posting.
- Retries are safe because identity and uniqueness live in the database.
📋 Table of Contents
- The Real Problem
- 1. Draw the Boundary Before You Draw the Workflow
- 2. Give the Document an Identity Before the Model Sees It
- 3. Extract Into a Draft Contract, Not a Database Row
- 4. Parse Money in PHP, Not in the Prompt
- 5. Verify Against Your Ledger, Not Against the PDF
- 6. Commit With a Transaction, a Lock, and a Unique Index
- 7. Send Uncertainty to a Human Queue, Not to Production
- 8. Make Retries Boring With Three Idempotency Boundaries
- 9. Keep Provenance or Lose the Argument
- 10. Test With Hostile Invoices, Not Just Clean Ones
- Choosing Where Each Rule Lives
- Production Checklist
The Real Problem
An invoice is not a JSON object with a few friendly fields.
It is a business artifact with identity, a supplier relationship, a reporting period, a currency, a tax treatment, a payment obligation, and possibly a purchase order behind it. Some of that information is on the document. Some of it only exists in your own systems.
A model can read the first part. Only your application can establish the second.
Consider these cases:
| What the document says | What actually needs checking |
|---|---|
| Vendor: “Acme Office Supply” | Is this the same legal entity as vendor ID 412? |
Invoice number: R-2026-0042 | Has this vendor already submitted that number? |
Total: 1.190,00 | Is that 1190.00, 1.19, or something else? |
Tax: 190,00 | Does it match the applicable rate and rounding policy? |
Date: 14.02.2026 | Is it within the expected period and contract terms? |
Confidence: 0.98 | What does that number actually mean? |
If your workflow answers these questions with prompt instructions, you have made the least deterministic component responsible for your most deterministic requirements.
The architecture below fixes that.
1. Draw the Boundary Before You Draw the Workflow
Scenario
Your first version has a Code node in n8n that parses totals, matches vendors, and decides whether an invoice is valid. It works until finance changes the rounding rule. Now the rule exists in two places: a PHP service and a JavaScript snippet inside a workflow nobody remembers owning.
Why the obvious split fails
The tempting split is “n8n does the logic because it is visual.” That works for glue and fails for invariants.
A workflow engine is excellent at orchestration. It is not a good place to hide financial truth, because:
- the logic is hard to unit test
- the logic is hard to review as a diff
- the logic often bypasses your domain model
- the logic gets duplicated when a second workflow appears
- the logic is invisible to other parts of your application
Solution
Assign ownership explicitly.
| Layer | Owns | Must not own |
|---|---|---|
| Object storage | The original bytes | Business meaning |
| n8n | Triggers, sequencing, retries, routing | Financial rules, final validity |
| Extraction service | Proposing fields from a document | Deciding what gets stored |
| Laravel | Normalization, verification, identity, writes | Prompt engineering |
| Human reviewer | Resolving ambiguity | Bypassing constraints silently |
The data flow looks like this:
Invoice lands in object storage
↓
n8n trigger
↓
Laravel intake → content hash + intake ID
↓
Extraction service → proposed fields + provenance
↓
n8n Code node → transport adapter only
↓
Laravel draft + normalization
↓
Laravel verification
↓
verified → committed record
needs_review → human queue
Notice what n8n never does: it never decides that an invoice is valid. It carries a verdict between systems.
🔍 Why this matters: When the model is wrong, you want a system that rejects the draft. When the workflow is retried, you want a system that refuses to duplicate the record. Neither of those guarantees comes from a prompt.
2. Give the Document an Identity Before the Model Sees It
Scenario
The same PDF arrives through a mailbox integration and a manual upload. Your workflow extracts it twice. Because the model returned slightly different whitespace in the vendor name, two “different” invoices appear.
Why filename-based deduplication is not enough
Filenames are not identity. invoice_final.pdf, invoice_final_v2.pdf, and a renamed copy can all be the same bytes. Conversely, the same filename can be reused for a different document.
Use the content.
Solution
Create an intake record keyed by the SHA-256 of the original file.
Schema::create('invoice_intakes', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->char('file_sha256', 64)->unique();
$table->string('storage_key', 255);
$table->string('status')->default('received');
$table->json('draft')->nullable();
$table->json('provenance')->nullable();
$table->timestamps();
});
The intake endpoint accepts an object-storage key, not an arbitrary URL.
final class StoreIntakeRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Protected by a scoped service token + policy.
}
public function rules(): array
{
return [
'storage_key' => [
'required',
'string',
'max:255',
'regex:^invoices/[A-Za-z0-9_-]+\.pdf$',
],
];
}
}
That regex is deliberate. Ingestion assigns opaque keys such as invoices/9f3c1a.pdf. Users and workflows never supply filesystem paths or remote URLs, so path traversal and SSRF are not part of the contract.
Hash the object without loading the whole file into memory:
final class DocumentHash
{
public static function for(string $key): string
{
$disk = Storage::disk('invoices');
abort_unless($disk->exists($key), 404);
abort_if($disk->size($key) > 20 * 1024 * 1024, 422, 'File too large');
$stream = $disk->readStream($key);
abort_unless(is_resource($stream), 500);
$context = hash_init('sha256');
try {
while (! feof($stream)) {
$chunk = fread($stream, 1024 * 1024);
abort_if($chunk === false, 500);
hash_update($context, $chunk);
}
return hash_final($context);
} finally {
fclose($stream);
}
}
}
Then make intake idempotent:
$hash = DocumentHash::for($key);
$intake = InvoiceIntake::query()->firstOrCreate(
['file_sha256' => $hash],
[
'storage_key' => $key,
'status' => 'received',
]
);
return response()->json([
'intake_id' => $intake->id,
'file_sha256' => $hash,
'duplicate' => ! $intake->wasRecentlyCreated,
'status' => $intake->status,
], $intake->wasRecentlyCreated ? 201 : 200);
Why this works
The hash is computed from bytes you control, before any probabilistic component touches the document. Retries, duplicate triggers, and re-uploads converge on the same intake.
The unique index is the authority. If two writers race, one may fail; a retry then reads the winning row. That is a perfectly acceptable outcome in an at-least-once pipeline.
⚠️ Gotcha: Content hashing deduplicates documents, not business obligations. A vendor can send you the same invoice as two different PDFs. You still need a business-key uniqueness rule, which we will add at commitment time.
3. Extract Into a Draft Contract, Not a Database Row
Scenario
Your extraction prompt returns whatever the model feels like that day: sometimes total, sometimes invoice_total, sometimes a nested amounts object. Every prompt tweak becomes a workflow incident.
Why this matters
The extraction service needs a stable output contract, independent of the model provider. If your workflow depends on the model’s spontaneous naming, you have coupled your database schema to a prompt.
Solution
Define your own extraction contract. This is an internal API you control, not a vendor-specific API:
{
"fields": {
"vendor_legal_name": "Acme Office Supply GmbH",
"vendor_tax_id": "DE123456789",
"invoice_number": "R-2026-0042",
"invoice_date": "2026-02-14",
"currency": "EUR",
"subtotal_raw": "1.000,00",
"tax_raw": "190,00",
"total_raw": "1.190,00",
"number_format": "de_DE"
},
"confidence": {
"invoice_number": 0.99,
"total": 0.97
},
"provenance": {
"provider": "your-extraction-service",
"model": "configured-at-deploy-time",
"prompt_version": "invoice-extract.v3",
"schema_version": "invoice-extraction.v1"
}
}
Two details matter here.
First, amounts stay raw strings. The model is not asked to do arithmetic or decimal conversion.
Second, number_format is an explicit field. Without it, 1.190,00 is a guessing game.
In n8n, the workflow is deliberately boring:
- Trigger on a new object in the invoices bucket.
- HTTP Request → Laravel intake, receives
intake_id. - HTTP Request → extraction service, receives the draft contract.
- Code node → reshape into the Laravel draft envelope.
- HTTP Request → Laravel draft endpoint.
- Branch on the verification outcome.
The Code node is a transport adapter:
const fields = $json.fields ?? {};
const draft = {
intake_id: $('Create Intake').first().json.intake_id,
schema_version: 'invoice-extraction.v1',
vendor: {
legal_name: fields.vendor_legal_name ?? null,
tax_id: fields.vendor_tax_id ?? null,
},
invoice_number: fields.invoice_number ?? null,
invoice_date: fields.invoice_date ?? null,
currency: fields.currency ?? null,
amounts: {
subtotal_raw: fields.subtotal_raw ?? null,
tax_raw: fields.tax_raw ?? null,
total_raw: fields.total_raw ?? null,
number_format: fields.number_format ?? null,
},
confidence: $json.confidence ?? {},
extraction: $json.provenance ?? {},
};
return [{ json: draft }];
Then the HTTP Request node posts that JSON to Laravel, with the URL built from the intake step:
POST /api/invoice-intakes/{{ $('Create Intake').first().json.intake_id }}/draft
Store the service token in an n8n credential, not in a Code node string.
Why this works
The model’s output is now a proposal with a versioned shape. Laravel validates that shape before anything else happens.
Keep the draft endpoint’s validation strict:
'invoice_number' => ['required', 'string', 'max:64', 'regex:/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/'],
'invoice_date' => ['required', 'date_format:Y-m-d'],
'currency' => ['required', 'string', 'size:3', 'regex:/^[A-Z]{3}$/'],
'amounts.total_raw' => ['required', 'string', 'max:32'],
💡 Practical note:
date_formatis a format check, not a business-date check. A syntactically valid date can still be in the wrong fiscal period. That verification belongs in your domain logic, not in a form request.
4. Parse Money in PHP, Not in the Prompt
Scenario
The invoice says 1.190,00. A well-meaning normalization step removes dots and commas, producing 119000. Another invoice says 1,190.00, and the same logic produces 119000 again. Two different amounts, one database value.
Why this is the most dangerous shortcut in the pipeline
Floats, locale guessing, and “just strip the separators” are a bad combination for financial data.
You need three separate operations:
- Recognize the number format.
- Convert to a canonical decimal string.
- Convert to integer minor units.
Solution
First, normalize the format explicitly:
final class DecimalNormalizer
{
public static function toCanonical(string $raw, string $format): string
{
$value = trim($raw);
return match ($format) {
'en_US' => $value,
'de_DE', 'fr_FR' => self::commaDecimal($value),
default => throw new NeedsReviewException("Unknown number format: {$format}"),
};
}
private static function commaDecimal(string $value): string
{
$grouped = '/^-?[0-9]{1,3}(\.[0-9]{3})*,[0-9]{1,2}$/';
$plain = '/^-?[0-9]+,[0-9]{1,2}$/';
if (! preg_match($grouped, $value) && ! preg_match($plain, $value)) {
throw new NeedsReviewException('Unrecognized grouped decimal');
}
return str_replace(['.', ','], ['', '.'], $value);
}
}
Then convert to minor units without ever touching a float:
final class MinorUnits
{
private const EXPONENTS = [
'EUR' => 2,
'USD' => 2,
'GBP' => 2,
'JPY' => 0,
];
public static function parse(string $canonical, string $currency): int
{
$scale = self::EXPONENTS[$currency]
?? throw new NeedsReviewException("Unsupported currency: {$currency}");
if (! preg_match('/^-?[0-9]+(\.[0-9]{1,2})?$/', $canonical)) {
throw new NeedsReviewException('Amount is not a canonical decimal');
}
$negative = str_starts_with($canonical, '-');
[$whole, $fraction] = array_pad(
explode('.', ltrim($canonical, '-'), 2),
2,
''
);
if ($scale === 0 && $fraction !== '') {
throw new NeedsReviewException('Currency has no minor units');
}
$digits = $whole.str_pad($fraction, $scale, '0');
if (strlen($digits) > 15) {
throw new NeedsReviewException('Amount out of supported range');
}
$minor = (int) $digits;
return $negative ? -$minor : $minor;
}
}
So:
$canonical = DecimalNormalizer::toCanonical('1.190,00', 'de_DE');
$minor = MinorUnits::parse($canonical, 'EUR'); // 119000
Store 119000 in an integer column, alongside EUR. Never store the invoice total as a float.
Why this works
Every step is deterministic and testable. Unknown formats throw a review exception instead of silently producing a plausible number.
This is also where you enforce business semantics: an invoice total below zero is usually a credit note and should follow a different path, not become a negative invoice by accident.
🧠 The important part: If
number_formatis missing or ambiguous, the correct output isneeds_review, not a guess. A pipeline that escalates ambiguity is more valuable than one that hides it.
5. Verify Against Your Ledger, Not Against the PDF
Scenario
The extracted vendor name is correct, the total is parsed correctly, and the invoice number is clean. The invoice is still wrong: it belongs to a vendor you have already paid, or the tax does not match your configured rate, or there is no purchase order behind it.
Why extraction confidence cannot verify anything
Model confidence describes the model’s certainty about reading the document. It says nothing about whether the obligation is legitimate.
A 0.99-confidence reading of a duplicate invoice is still a duplicate.
Solution
Run verification rules against your own data.
| Rule | Source of truth | Failure reason |
|---|---|---|
| Vendor identity | Vendor master, tax ID | vendor_unmatched |
| Duplicate invoice | Existing invoices | duplicate_invoice |
| Arithmetic | Subtotal + tax vs total | total_mismatch |
| Tax treatment | Configured rate + rounding policy | tax_mismatch |
| Reporting period | Fiscal calendar | date_out_of_range |
| Purchase order | Open PO lines and remaining value | po_mismatch |
| Extraction quality | Per-field confidence thresholds | low_confidence |
A compact version of the verifier:
final class VerifyInvoiceDraft
{
public function __invoke(array $draft): array
{
$reasons = [];
$vendor = Vendor::query()
->where('tax_id', $draft['vendor_tax_id'])
->first();
if (! $vendor) {
$reasons[] = 'vendor_unmatched';
}
if ($vendor && Invoice::query()
->where('vendor_id', $vendor->id)
->where('vendor_invoice_number', $draft['invoice_number'])
->where('currency', $draft['currency'])
->exists()
) {
$reasons[] = 'duplicate_invoice';
}
if ($draft['subtotal_minor'] + $draft['tax_minor'] !== $draft['total_minor']) {
$reasons[] = 'total_mismatch';
}
if (! $this->dateWithinPolicy($draft['invoice_date'])) {
$reasons[] = 'date_out_of_range';
}
if (($draft['confidence']['total'] ?? 0) < 0.90) {
$reasons[] = 'low_confidence';
}
return [
'ok' => $reasons === [],
'reasons' => $reasons,
'vendor_id' => $vendor?->id,
'rule_version' => 'invoice-verification.v1',
'checked_at' => now()->toIso8601String(),
];
}
}
Why this works
The verdict is reproducible. Given the same draft and the same ledger state, you get the same result, and you can explain it: vendor_unmatched, not “the AI seemed unsure.”
Match vendors on stable identifiers such as tax IDs, not on fuzzy legal-name similarity. Fuzzy matching is fine for proposing a candidate; it is not fine for silently posting a liability.
⚠️ Gotcha: Vendor-reported or model-reported confidence is not a calibrated probability. Use it as a triage signal. Hard business rules should still be able to reject a “0.99 confidence” invoice.
6. Commit With a Transaction, a Lock, and a Unique Index
Scenario
Two n8n executions finish verification at nearly the same moment. Both see no existing invoice. Both insert one. Your duplicate check passed twice, and your ledger now has two liabilities.
Why a read-then-write check is not enough
Verification is a read. Commitment is a write. Between them, the world can change.
You need concurrency control at the database boundary, not in the workflow.
Solution
Add the business uniqueness rule at the invoice table:
Schema::create('invoices', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('intake_id')->unique()->constrained('invoice_intakes');
$table->foreignId('vendor_id')->nullable()->constrained();
$table->string('vendor_invoice_number', 64);
$table->char('currency', 3);
$table->unsignedBigInteger('total_minor');
$table->string('status')->default('draft');
$table->json('verification')->nullable();
$table->timestamp('verified_at')->nullable();
$table->timestamp('posted_at')->nullable();
$table->timestamps();
$table->unique(['vendor_id', 'vendor_invoice_number', 'currency']);
});
Then commit inside a transaction, locking the intake row:
public function __invoke(string $intakeId): JsonResponse
{
$result = DB::transaction(function () use ($intakeId) {
$intake = InvoiceIntake::query()
->whereKey($intakeId)
->lockForUpdate()
->firstOrFail();
if ($intake->status === 'posted') {
return ['invoice_id' => $intake->invoice->id, 'replayed' => true];
}
$draft = $intake->draft;
$verification = (new VerifyInvoiceDraft())($draft);
if (! $verification['ok']) {
$intake->update([
'status' => 'needs_review',
'provenance' => array_merge(
$intake->provenance ?? [],
['verification' => $verification]
),
]);
return ['needs_review' => true, 'reasons' => $verification['reasons']];
}
$invoice = Invoice::query()->create([
'intake_id' => $intake->id,
'vendor_id' => $verification['vendor_id'],
'vendor_invoice_number' => $draft['invoice_number'],
'currency' => $draft['currency'],
'total_minor' => $draft['total_minor'],
'status' => 'posted',
'verification' => $verification,
'verified_at' => now(),
'posted_at' => now(),
]);
$intake->update(['status' => 'posted']);
return ['invoice_id' => $invoice->id, 'replayed' => false];
});
return response()->json(
$result,
isset($result['needs_review']) ? 422 : 200
);
}
Why this works
Three defenses overlap:
- The row lock serializes concurrent commits for the same intake.
- The unique intake ID prevents two invoices from one document.
- The business unique index prevents two documents from creating the same vendor invoice.
Re-run verification inside the transaction. A verification result computed thirty seconds earlier, outside the lock, is a hint, not a guarantee.
Also note the nullable vendor_id subtlety: a unique index containing a nullable column does not deduplicate unmatched rows. That is intentional here—unmatched vendors go to review, and only resolved vendors reach commitment.
🚨 Production warning: Never mass-assign the raw model payload into your invoice model. Build the insert array from normalized, verified fields. The model should not be able to set
status,vendor_id, orposted_atby inventing a JSON key.
7. Send Uncertainty to a Human Queue, Not to Production
Scenario
A new vendor sends their first invoice. The tax ID is present but not in your master data. The pipeline could “helpfully” create a vendor automatically. It would also be creating a new payment destination based on a document supplied by an unknown party.
Why auto-creating master data is a bad default
Extraction can propose entities. It should not confer trust on them.
Vendor creation, bank-detail changes, and large-value exceptions are exactly the cases where an automated shortcut becomes a fraud vector.
Solution
Make needs_review a first-class outcome.
Your commit endpoint already returns it:
{
"needs_review": true,
"reasons": ["vendor_unmatched"]
}
n8n should branch on that outcome and stop, not retry harder. A vendor_unmatched condition will not fix itself through repetition.
On the Laravel side, the review queue is simply a query over intakes:
InvoiceIntake::query()
->where('status', 'needs_review')
->orderBy('created_at')
->paginate(25);
Each review item should show:
- the original document
- the proposed fields
- the normalized values
- the failed rules
- the extraction provenance
- the action taken
Approval must be an explicit, authenticated action that records who approved what and why. It may override a soft policy such as a confidence threshold. It must not override a uniqueness constraint or silently create a vendor.
Why this works
You get throughput without pretending ambiguity does not exist.
A useful policy split:
| Outcome | Automation |
|---|---|
| All rules pass | Commit |
| Low confidence on a non-critical field | Commit with review flag, if policy allows |
| Unknown vendor | Human review |
| Duplicate business key | Reject or review, never insert |
| Total mismatch | Human review |
| Bank detail change | Human review, always |
💡 Practical note: If you use an n8n Wait node or an approval-style pause, treat the external review system as the source of truth. A workflow waiting for a human can be restarted, duplicated, or expired; the approval record should not depend on the workflow still being alive.
8. Make Retries Boring With Three Idempotency Boundaries
Scenario
The extraction service times out. n8n retries the HTTP Request node. The first request had actually succeeded, and now you have two drafts, two verifications, and potentially two writes.
Why “just enable retries” is not a design
Queue and workflow retries are usually at-least-once. The network does not guarantee that a timed-out request failed.
So the question is not “how do I avoid duplicate requests?” It is “what happens when the same request lands twice?”
Solution
Define three boundaries.
| Boundary | Identity | Effect of a retry |
|---|---|---|
| Document intake | file_sha256 unique | Returns the existing intake |
| Draft attachment | intake_id | Replaces or reuses the same draft |
| Invoice commitment | intake_id unique + business unique index | Returns the existing invoice |
The commitment endpoint’s replayed flag makes this explicit:
{
"invoice_id": "8f7c1d6c-7a3f-4d64-b5f2-1c3f6f6a7d21",
"replayed": true
}
n8n can retry safely because the second call is not a second business operation.
Rules worth following
- Never use a model-generated invoice ID as your idempotency key.
- Never rely on workflow execution IDs for business deduplication.
- Do not retry a verification failure as if it were a network error.
- Keep side effects behind the commit, not before it.
That last point matters. If your workflow sends a notification, updates an accounting system, and then writes the invoice, a failure between those steps leaves you with a notification for a record that does not exist.
Put the invoice write first, then emit downstream work from the same transactional boundary using an outbox-style record or a job dispatched after commit. In Laravel, afterCommit() on queue dispatch is the simple version of this idea: the worker should not receive work that references an uncommitted row.
Why this works
Retries stop being a correctness problem and become a latency problem. That is a much better place to spend your engineering attention.
9. Keep Provenance or Lose the Argument
Scenario
Six months later, someone asks: “Why did we record this invoice as 1190.00 EUR?” Your database has the number. It does not have the document version, the extraction version, the rule version, or the reviewer.
Why provenance is part of the record
A verified database record is only defensible if you can reconstruct how it became verified.
Store provenance alongside the draft, not in a separate log that expires first.
A practical shape:
{
"document_sha256": "…",
"extraction": {
"provider": "your-extraction-service",
"model": "configured-at-deploy-time",
"prompt_version": "invoice-extract.v3",
"schema_version": "invoice-extraction.v1",
"extracted_at": "2026-02-14T10:22:31Z"
},
"normalization": {
"number_format": "de_DE",
"minor_units_scale": 2,
"rule_version": "money.v1"
},
"verification": {
"ok": true,
"reasons": [],
"rule_version": "invoice-verification.v1",
"checked_at": "2026-02-14T10:22:34Z"
},
"review": null
}
When a human approves an exception, replace review: null with actor, timestamp, reason, and the specific rule overridden.
Privacy and operational caveats
Invoice documents contain vendor data, amounts, and sometimes personal information. That means:
- workflow execution logs may contain sensitive payloads
- extraction prompts and responses may contain sensitive text
- retention policies must cover drafts as well as final records
- access to the review queue is access to financial data
Review your n8n execution-data retention and access controls with the same seriousness as your database backups. A workflow engine that happily stores every intermediate payload is not a neutral observer.
10. Test With Hostile Invoices, Not Just Clean Ones
Scenario
Your test suite uses one beautiful, machine-generated PDF. Production sends scanned invoices with stamps, rounded totals, mixed locales, and a vendor who reuses invoice numbers across currencies.
Why happy-path document tests are weak
Document pipelines fail on the edges, and the edges are predictable. You do not need a large annotation dataset to start; you need a small corpus of adversarial cases.
Solution
Build a fixture corpus:
| Fixture | Expected outcome |
|---|---|
| Clean EUR invoice | Verified and committed |
| Same bytes uploaded twice | One intake, one invoice |
| Same vendor + number, different PDF | Duplicate rejected or reviewed |
| Same number, different currency | Allowed if policy allows |
1.190,00 with de_DE | 119000 minor units |
1,190.00 with en_US | 119000 minor units |
1.190,00 with missing format | needs_review |
| Subtotal + tax ≠ total | total_mismatch |
| Unknown vendor tax ID | vendor_unmatched |
| Date outside fiscal period | date_out_of_range |
| Invoice text containing instructions to the model | Treated as document data only |
Then test the deterministic core directly:
public function test_grouped_european_total_becomes_integer_minor_units(): void
{
$canonical = DecimalNormalizer::toCanonical('1.190,00', 'de_DE');
$this->assertSame(119000, MinorUnits::parse($canonical, 'EUR'));
}
public function test_unknown_number_format_requires_review(): void
{
$this->expectException(NeedsReviewException::class);
DecimalNormalizer::toCanonical('1.190,00', 'unknown');
}
And test the concurrency boundary with a Laravel feature test that commits the same intake twice and asserts one invoice row. That single test is worth more than ten tests against pretty PDFs.
Why this works
These tests encode the contract of the pipeline, not the behavior of a particular model. When you swap extraction providers, the corpus stays relevant.
🔍 Why this matters: Prompt-injection-style text inside a document is a real concern. The defensive design is not a better “ignore instructions” prompt. It is an extraction service that returns data, and an application that never executes anything the document says.
Choosing Where Each Rule Lives
| Rule type | Best home | Why |
|---|---|---|
| “A new file arrived” | n8n trigger | Orchestration |
| “Call extraction, then draft” | n8n workflow | Sequencing and retries |
| “Read fields from a PDF” | Extraction service | Probabilistic work |
| “Parse this decimal” | Laravel service | Deterministic, testable |
| “Is this vendor known?” | Laravel domain | Requires your data |
| “Is this invoice duplicate?” | Database constraint | Requires concurrency control |
| “Who approved this exception?” | Laravel audit record | Requires authenticated actors |
If you catch yourself writing financial logic inside a Code node, that is a signal. The Code node should reshape data, not decide policy.
I would also be cautious about this architecture for structured electronic invoices. If your suppliers provide machine-readable XML or JSON e-invoices, parse them deterministically first and use AI only for the documents that resist structure. Using a probabilistic extractor where a schema already exists is a self-inflicted wound.
Production Checklist
- Original file is stored immutably and addressed by content hash.
- Intake endpoint accepts storage keys, not arbitrary URLs.
- Extraction output has a versioned contract.
- Raw amount strings are preserved for audit.
- Money is normalized explicitly and stored in integer minor units.
- Unknown number formats escalate instead of guessing.
- Vendor matching uses stable identifiers.
- Verification rules are versioned and reproducible.
- Commitment re-runs verification inside a transaction.
- Unique indexes enforce document and business identity.
-
needs_reviewis a normal, visible outcome. - Human approvals are authenticated and audited.
- Retries cannot create duplicate invoices.
- Downstream side effects happen after commit.
- Provenance includes document, extraction, normalization, and rule versions.
- Workflow logs and draft retention are reviewed for sensitive data.
- Tests include duplicates, malformed amounts, and unknown vendors.
What I Would Actually Ship
Start narrower than the full architecture.
First, build intake plus a draft table plus a commit endpoint with uniqueness. Run it with manual extraction for a week. You will learn more about your invoice population from those fifty real documents than from any prompt iteration.
Then add the extraction service behind the same contract. Then add verification rules one at a time, each with a reason code and a test. Finally, let n8n orchestrate the whole thing.
The order matters. If you build the model integration first, you will discover your data problems through production incidents. If you build the identity and verification boundary first, you will discover them through a review queue.
A verified database record is not an extraction result with a confidence score attached. It is the output of a system that can explain, reproduce, and defend every step between the document and the row.