In the article about synchronous VIES I ended with a promise: next time I would follow up with an article about ČNB exchange rates, specifically the "rate for DUZP", for when ČNB occasionally does not publish a fixing. I am keeping that promise here, just differently from what I expected. The ČNB rate turned out to be only a smaller part of a bigger problem: letting AI read received invoices and build the basis for the monthly VAT return (DPHDP3) from them.
Billify prepares a monthly VAT overview for an identified person. Such a person buys services from abroad - Hetzner, Forge, OpenAI, Cloudflare, Vercel, JetBrains - and from every invoice has to get into the return the supplier, their VAT ID, the amount, the currency, the DUZP and the ČNB rate for that DUZP. By hand it is a chore and a source of errors at the same time. A chore because it is ten almost identical PDFs a month. And a source of errors because that "almost" means every PDF is formatted differently and the DUZP is worked out differently for each one.
(DUZP, the date of the taxable supply, is the day the Czech VAT liability arises.)
This article is about how an AI agent reads them - and how I slipped it, as a tool, a service I already had written for something completely different.
Why not OCR, but structured output
My first idea for "read the PDF" was OCR plus regular expressions. I dropped it right away. An invoice from Hetzner and an invoice from OpenAI share no common layout you could write a parser for. What they share is meaning: both carry a supplier, an amount and a date somewhere. And exactly here today's models work better than any set of rules I would be willing to maintain.
It stands on the laravel/ai package. The agent is an ordinary class with attributes:
#[Provider(Lab::Anthropic)]
#[UseCheapestModel]
#[Temperature(0.0)]
#[MaxTokens(2048)]
#[Timeout(180)]
final class ReceivedInvoiceExtractionAgent implements Agent, HasStructuredOutput, HasTools
{
use Promptable;
public function __construct(public readonly FetchCnbRateTool $fetchCnbRateTool) {}
}
A few deliberate decisions in those attributes:
UseCheapestModelpicks Claude Haiku. Pulling structured fields out of one invoice is not a job for the most expensive model. Dozens of invoices go through it every month, so the price per piece decides.Temperature(0.0). This is not creative writing but a tax basis. I want the same invoice to come out the same every time.Timeout(180). During one run the agent calls the ČNB tool, so even that network detour has to fit within the timeout.
I send the PDF to the model natively, no OCR intermediate step:
$response = $extractionAgent->prompt(
'Extract invoice metadata from the attached PDF and call FetchCnbRateTool to obtain the ČNB rate.',
attachments: [Document::fromStorage($receivedInvoice->pdf_path, disk: $receivedInvoice->pdf_disk)],
);
Document::fromStorage() wraps the file in a document block and laravel/ai handles the serialization. I never touch base64 or content blocks.
The output is bound by a schema - eleven required fields. A shortened excerpt:
public function schema(JsonSchema $schema): array
{
return [
'supplier_name' => $schema->string()
->description('Legal name of the supplier exactly as printed on the invoice.')
->required(),
'taxable_supply_at' => $schema->string()
->description('DUZP in YYYY-MM-DD format. Prefer service_period_end, fall back to issue_date.')
->required(),
'foreign_currency_code' => $schema->string()
->enum(ForeignCurrency::values())
->required(),
'czk_rate' => $schema->number()
->description('CZK exchange rate per 1 unit of foreign currency, returned by FetchCnbRateTool.')
->required(),
'czk_amount' => $schema->number()
->description('foreign_amount * czk_rate rounded to 2 decimals (half-up).')
->required(),
'notes_for_review' => $schema->string()
->description('Empty string when extraction is unambiguous, otherwise a short note for the human reviewer.')
->required(),
// ... supplier_country_code, supplier_vat_id, invoice_number, issue_date, foreign_amount
];
}
The domain lives in the prompt, not in the code
The schema itself is simple. What matters is in the instructions, because that is where the tax domain is encoded, which I would otherwise have had to write into ifs.
There are two problems the prompt has to stand up to. The first is the date format:
DATE FORMAT WARNING: many EU invoices print dates in DD.MM.YYYY (German, Czech, French - e.g. "02.05.2026" means 2 May 2026). US invoices use MM/DD/YYYY or YYYY-MM-DD. Use the supplier country and surrounding context to disambiguate. Never assume US format on a German / Czech / French supplier.
02.05.2026 means 2 May on a German invoice and 5 February on an American one. No parser can tell from that string alone - the deciding information is in the supplier's country three lines above. The model sees that connection.
The second problem is the DUZP itself. The day the tax liability arises is not the "issue date". Under § 24 of the Czech VAT Act (ZDPH) it is the earlier of the day the service was provided and the day of payment:
taxable_supply_at = min(issue_date, service_period_end)
That covers two common patterns, which the agent distinguishes explicitly in the prompt: prepaid SaaS (Forge, OpenAI, Vercel - the invoice at the start of the period, use the issue date) versus billing in arrears (Hetzner, metered AWS - the invoice at the start of the next month for the previous month, use the end of the period). On top of that a rule for unpaid invoices with a due date in the future. All of this is in the prompt, not in the code.
When I say "the domain belongs in the prompt", it does not mean "throw everything in". It means: decisions that require reading a document with context, give to the model. Calculations that money hangs on, keep in code. The boundary between the two is this whole article.
And the rate stands exactly on that boundary.
From an internal service to an agent tool
I could handle the ČNB rate for the DUZP in two ways. Either I tell the model "return the rate too" and hope it remembers it. But it does not, and with taxes "I hope" can end very badly. Or I fetch the rate myself in code after the extraction.
The second variant has a catch: the rate is bound to the DUZP (§ 4(5) ZDPH says it is converted at the rate on the day the liability arises), and which date is the DUZP was decided a moment ago by the model following that § 24 logic above. It is better to leave the decision about the date and the fetching of the rate in one step, with whoever determines the date.
So I made the rate an agent tool. And above all: I did not write that logic again. The ExchangeRateService, which downloads rates from ČNB, caches them and supports a manual override, I already have from the work on synchronous invoicing. All I had to do was wrap it. The whole tool is thirty lines:
final class FetchCnbRateTool implements Tool
{
public function __construct(private readonly ExchangeRateService $exchangeRateService) {}
public function description(): string
{
return 'Fetches the official Czech National Bank (ČNB) exchange rate per 1 unit '
.'of the given foreign currency, valid on the given date (used as DUZP - the '
.'day the tax liability arises, § 4 odst. 5 ZDPH). Returns the rate as a decimal '
.'string. Call this once per invoice after extracting the foreign currency and the DUZP date.';
}
public function schema(JsonSchema $schema): array
{
return [
'foreign_currency_code' => $schema->string()
->enum(ForeignCurrency::values())
->required(),
'taxable_supply_date' => $schema->string()
->description('DUZP date in YYYY-MM-DD format.')
->required(),
];
}
public function handle(Request $request): string
{
try {
$rate = $this->exchangeRateService->getRate(
sourceCurrency: (string) $request['foreign_currency_code'],
date: CarbonImmutable::parse((string) $request['taxable_supply_date']),
);
} catch (Throwable $exception) {
return sprintf('ERROR: Unable to fetch ČNB rate for %s on %s: %s',
$request['foreign_currency_code'], $request['taxable_supply_date'], $exception->getMessage());
}
return number_format($rate, 6, '.', '');
}
}
Registering it with the agent is one line:
public function tools(): iterable
{
return [$this->fetchCnbRateTool];
}
And in the instructions a step that tells the model when to call the tool:
Call FetchCnbRateTool once with the foreign_currency_code and the taxable_supply_at date you decided on (ČNB rate is keyed to the day the duty arises). Return the rate it gives you in czk_rate, and compute czk_amount = foreign_amount * czk_rate rounded to 2 decimals using standard half-up rounding.
That creates a simple agent loop: the model reads the currency from the PDF and decides the DUZP → calls FetchCnbRateTool(currency, duzp) → gets the rate as a string → multiplies and returns czk_amount. The multiplication and the HTTP call to ČNB both happen in code that I have tested. The model only steers it all - it decides the currency and date, the rest it leaves to the tool.
Two small things in the tool that matter. The tool returns the rate as a string with six decimal places. The tool result reaches the model as text anyway, so I format it into exact digits right away. The model then just copies them into the numeric czk_rate field in the structured output, instead of working them out itself and rounding "just in case". And it returns an error as ERROR: ... text, not as an exception. When ČNB does not provide the rate, the model reads it, notes it in notes_for_review and lets the invoice go on to user review, instead of the whole extraction crashing.
The walkback: what I promised last time
Here is the promised part. ČNB does not publish a fixing on weekends and holidays. But an invoice with a Sunday DUZP needs a rate. ExchangeRateService solves this by stepping back day by day, at most seven days, until it hits the last published rate:
for ($offset = 0; $offset < self::WALKBACK_DAYS; $offset++) {
$tryDate = $date->subDays($offset);
$result = $this->queryCnbFor($sourceCurrency, $tryDate);
if ($result === null) {
continue;
}
// Cache under the REQUESTED date, not the one we succeeded on.
ExchangeRate::updateOrCreate(
['date' => $date->toDateString(), 'source_currency' => $sourceCurrency, /* ... */ 'source' => 'cnb'],
['rate' => $result['rate'], 'rate_date' => $result['validFor']],
);
return $result['rate'];
}
throw new ExchangeRateUnavailableException(/* ... */);
A detail that matters: the cache is keyed under the requested date (the Sunday DUZP), but rate_date stores the actual day of the fixing (the Friday one) that ČNB returned in the validFor field. That way the invoice has both traceable - the day I wanted the rate for and where the rate really comes from.
And one more trap when parsing ČNB data: some currencies are quoted per N units (100 JPY = 14.21 CZK), not per one. But the caller always wants the rate per one unit:
// ČNB publishes the rate per N units. The caller always wants it per 1.
return [
'rate' => $rawRate / $amount,
'validFor' => (string) ($row['validFor'] ?? $date->toDateString()),
];
If I had left out this division, an invoice in yen would come out a hundred times higher. The AI does not know about this trap and does not need to - it is hidden in the tested service under the tool.
Around the AI: quota, audit, one attempt, a human at the end
The agent itself is half of it. The other half is the infrastructure that keeps it under control. This is about money and taxes, after all.
A single access point. No part of the application starts extraction directly. Everything goes through TaxExtractionDispatcher, which in one transaction locks the whole team (the tenant structure), checks the monthly quota and only then dispatches the job:
public function dispatch(ReceivedInvoice $invoice, ExtractionReason $reason): void
{
DB::transaction(function () use ($invoice, $reason): void {
$team = Team::query()->lockForUpdate()->find($invoice->team_id);
if ($team->tier !== Tier::Unlimited) {
$this->enforceQuota($team);
}
TaxExtractionLog::query()->create([
'team_id' => $team->id,
'received_invoice_id' => $invoice->id,
'attempted_at' => CarbonImmutable::now(),
'reason' => $reason,
'model' => self::AI_MODEL, // for the audit: which model produced which number
]);
ExtractReceivedInvoiceJob::dispatch($invoice->id);
});
}
Model audit. Every attempt records in the log which model processed it (claude-haiku-4-5-20251001). When in a year I ask "where did this DUZP come from", I want to be able to answer including the model version. With taxes a number must not fall from the sky without a trace.
One attempt, no retry. The job is ShouldBeUnique and has tries = 1. When extraction fails, I do not quietly try again. I log the error and store it in ai_notes_for_review, and the invoice stays in draft with a message about what went wrong.
A human at the end. The agent never confirms anything. It produces a Draft that the user reviews and confirms in the UI. temperature = 0, a log with the model version, notes_for_review for anything ambiguous and human confirmation are four layers between "the model wrote something" and "it is in the return".
What it showed
1. Structured output beats OCR plus regex on semi-structured documents. Invoices share a meaning, not a layout. The schema is a contract that turns the model into a source of typed fields instead of a source of text to parse.
2. The domain belongs in the prompt, money in the code. The model decides what requires reading a document with context (the date format, which date is the DUZP). The code computes what the crowns hang on (the rate, the multiplication). The whole design is about where to draw that boundary.
3. A tool is just a function a non-deterministic model can call. I did not rewrite the rate logic into the AI layer - I just wrapped what already worked. The model therefore never touches HTTP or the financial maths.
4. AI produces a draft, not final data. temperature = 0, a log with the model version, notes for the reviewer and mandatory human confirmation. Where the output ends up in a tax return, the audit trail is just as important as the extraction itself.
Next time I will return to how to connect a manually confirmed draft with the input VAT deduction - and why the classification of the return line (§ 9 reverse charge versus the import of a service from a third country) deliberately stayed outside the agent, in deterministic code.