From AI-Extracted Invoice to Verified Database Record: Build It with n8n and Laravel

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

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 saysWhat actually needs checking
Vendor: “Acme Office Supply”Is this the same legal entity as vendor ID 412?
Invoice number: R-2026-0042Has this vendor already submitted that number?
Total: 1.190,00Is that 1190.00, 1.19, or something else?
Tax: 190,00Does it match the applicable rate and rounding policy?
Date: 14.02.2026Is it within the expected period and contract terms?
Confidence: 0.98What 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.

LayerOwnsMust not own
Object storageThe original bytesBusiness meaning
n8nTriggers, sequencing, retries, routingFinancial rules, final validity
Extraction serviceProposing fields from a documentDeciding what gets stored
LaravelNormalization, verification, identity, writesPrompt engineering
Human reviewerResolving ambiguityBypassing 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:

  1. Trigger on a new object in the invoices bucket.
  2. HTTP Request → Laravel intake, receives intake_id.
  3. HTTP Request → extraction service, receives the draft contract.
  4. Code node → reshape into the Laravel draft envelope.
  5. HTTP Request → Laravel draft endpoint.
  6. 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_format is 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:

  1. Recognize the number format.
  2. Convert to a canonical decimal string.
  3. 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_format is missing or ambiguous, the correct output is needs_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.

RuleSource of truthFailure reason
Vendor identityVendor master, tax IDvendor_unmatched
Duplicate invoiceExisting invoicesduplicate_invoice
ArithmeticSubtotal + tax vs totaltotal_mismatch
Tax treatmentConfigured rate + rounding policytax_mismatch
Reporting periodFiscal calendardate_out_of_range
Purchase orderOpen PO lines and remaining valuepo_mismatch
Extraction qualityPer-field confidence thresholdslow_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, or posted_at by 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:

OutcomeAutomation
All rules passCommit
Low confidence on a non-critical fieldCommit with review flag, if policy allows
Unknown vendorHuman review
Duplicate business keyReject or review, never insert
Total mismatchHuman review
Bank detail changeHuman 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.

BoundaryIdentityEffect of a retry
Document intakefile_sha256 uniqueReturns the existing intake
Draft attachmentintake_idReplaces or reuses the same draft
Invoice commitmentintake_id unique + business unique indexReturns 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:

FixtureExpected outcome
Clean EUR invoiceVerified and committed
Same bytes uploaded twiceOne intake, one invoice
Same vendor + number, different PDFDuplicate rejected or reviewed
Same number, different currencyAllowed if policy allows
1.190,00 with de_DE119000 minor units
1,190.00 with en_US119000 minor units
1.190,00 with missing formatneeds_review
Subtotal + tax ≠ totaltotal_mismatch
Unknown vendor tax IDvendor_unmatched
Date outside fiscal perioddate_out_of_range
Invoice text containing instructions to the modelTreated 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 typeBest homeWhy
“A new file arrived”n8n triggerOrchestration
“Call extraction, then draft”n8n workflowSequencing and retries
“Read fields from a PDF”Extraction serviceProbabilistic work
“Parse this decimal”Laravel serviceDeterministic, testable
“Is this vendor known?”Laravel domainRequires your data
“Is this invoice duplicate?”Database constraintRequires concurrency control
“Who approved this exception?”Laravel audit recordRequires 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_review is 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.

Post a Comment

Previous Post Next Post