VIES audit trail v Laravelu: 4 iterace návrhu pro EU B2B SaaS
Jak ověřovat VAT ID přes VIES pro EU B2B reverse charge fakturaci v Laravelu — append-only audit log, retry budget, dva entry-pointy pro cron a checkout, a proč 'jednoduchý wrapper' nestačí.
Jako identifikovaná osoba si u každého EU B2B plnění před vystavením faktury v režimu reverse charge ověřuju, že příjemce je plátce DPH v jiném členském státu. Bez toho bych reverse charge neuplatnil. Defakto jediná cesta je VIES - evropský endpoint provozovaný Evropskou Komisí. Občas pomalý, občas nefunkční. A protože jde o daňová data, chci mít po ruce záznam o tom, čím jsem ověření doložil a co mi VIES v danou chvíli odpověděl.
Tenhle článek je o tom, jak jsem tu integraci postavil pro captchaapi.eu, postupně podle toho, jak mi docházelo, kde leží legislativa a kde skutečný stav VIES. Píšu to ještě dřív, než přes captchaapi proteče první ostrá EU B2B platba - všechno popsané jsem dotahoval při vývoji, ne na živých zákaznících.
Co je „identifikovaná osoba“
Pro čtenáře, který tenhle pojem slyší poprvé: identifikovaná osoba je hybrid podle § 6g až 6l zákona č. 235/2004 Sb., o DPH (dále „ZDPH“). Není plátcem DPH v plném smyslu - na faktuře českým zákazníkům neúčtuju DPH a tudíž neodvádím DPH. Ale jsem registrovaný u finančního úřadu pod českým DIČ pro účely přeshraničních EU plnění. Konkrétně mě do toho statusu staví § 6i ZDPH, který říká, že osoba povinná k dani se sídlem v ČR, která není plátcem, se stává identifikovanou osobou ode dne, kdy poskytne službu s místem plnění v jiném členském státě podle § 9 odst. 1 ZDPH.
Jak v tom skončí solo provozovatel SaaS:
- Prodávám digitální službu (CAPTCHA API).
- Mám zákazníky v České republice (žádné DPH, normální faktura).
- Mám zákazníky v jiných státech EU, kteří jsou tamní plátci DPH.
- Mám zákazníky mimo EU + EU spotřebitele bez DIČ.
Pro tu prostřední skupinu (EU B2B) platí pravidlo reverse charge: u služby pro osobu povinnou k dani je místo plnění v zemi příjemce (§ 9 odst. 1 ZDPH), takže já jako poskytovatel daň neúčtuju - daň přizná a odvede zákazník ve své zemi. To je výhoda. Jinak bych musel řešit registraci k DPH v dalších jurisdikcích nebo přejít na OSS. Reverse charge se uplatní, jen pokud je příjemce skutečně osobou registrovanou k DPH v jiném členském státě, a ověření VAT ID si zařizuju sám. Když zákazníkovo VAT ID neexistuje nebo už neplatí, je to fakticky B2C plnění a měl bych ho odbavit jinak - což můj účetní rozhodně řešit nechce.
Třetí skupinu (ROW + EU B2C) jsem v captchaapi vyřešil tak, že ji obsluhuje Lemon Squeezy jako merchant of record - LS účtuje, vybírá DPH, vystavuje fakturu, já dostávám čistý payout. O VIES se v té větvi nestarám. Tenhle článek je čistě o EU B2B větvi přes Stripe + vlastní fakturaci.
Co od ověření přes VIES potřebuju
VIES (VAT Information Exchange System) je evropská služba, ze které se dá zeptat: „existuje VAT ID SK2020317540 a komu patří?“ Odpověď je valid / invalid plus jméno a adresu firmy a tzv. consultation number - krátký řetězec, který je záznamem o tom, že jsem se v daném okamžiku ptal a co mi systém odpověděl.
VIES sám o sobě není v žádném návodu „povinný nástroj“. Jenže je to jediný zdroj, kde se platnost evropského VAT ID dá ověřit, takže ho prakticky nemám čím nahradit.
Proč ho do flow vůbec dávám, mi nejlíp shrnul účetní: reverse charge dává smysl jen tehdy, když je příjemce skutečně plátcem DPH v jiném členském státě. Když na faktuře uvedu VAT ID, které v okamžiku plnění neplatilo, je celé plnění postavené špatně a musel bych ho dodatečně narovnávat. VIES je způsob, jak si tu platnost před vystavením faktury ověřit, a consultation number je u toho obvyklý doplňující záznam.
Nejde o ostré časové okno - jde o to mít po ruce záznam, že jsem ověření udělal poctivě a v rozumném předstihu před plněním.
Proto interní polštář „VIES proof není starší než tři dny“ beru jako provozní rozhodnutí - vychází z toho, jak často mi běží předběžné kontroly. Není v tom žádná magická hranice; 47 nebo 71 hodin starý záznam je pořád ověření v rozumném předstihu před plněním. Čerstvost záznamu si nastavuju podle vlastního provozu.
Co tedy od ověření přes VIES prakticky potřebuju:
- Ověřit VAT ID dřív, než vystavím fakturu v režimu reverse charge.
- Uložit záznam o ověření tak, aby byl dohledatelný i za pět let - přežil migrace schématu, stěhování serveru i přepisy aplikace.
- Ověřit znovu před každým dalším plněním (renewal). Plátce může mezitím přestat být plátcem.
A protože VIES je nepříliš stabilní webová služba, ke které navíc přistupuju z Hetzneru v Norimberku, potřebuju to celé ošetřit tak, aby výpadek na jejich straně neznamenal, že já mám problém.
Proč jsem to nekoupil jako službu
Než jsem se ponořil do vlastní integrace, rozhlédl jsem se po placených „VAT validation" službách s dohozeným SLA. Existuje jich několik, slibují 99.9 % uptime i vyšší, „enterprise-grade" dashboard a fakturu, kterou se před auditorem ohání líp než „napsal jsem si na to kód v PHP".
Háček, který se ukazuje až po prokliku jejich dokumentace: VIES je centrální evropský systém provozovaný Evropskou komisí. Není distribuovaný, neexistuje autoritativní výstup z jiného zdroje. Každé komerční „VAT validation API" je tedy defakto wrapper přes VIES REST API plus možná lehká cache a hezčí response shape.
Důsledek? Když je VIES down, placená služba ti pošle 200 OK, jenže v payloadu bude service is currently unable to resolve VAT ID / MS_UNAVAILABLE / member state service timed out.
Jejich SLA se vztahuje na dostupnost jejich endpointu, ne na úspěšnost ověření. Jejich API je dostupné - ale nedokáže ti odpovědět, protože VIES je down.
Za desítky dolarů měsíčně tak dostaneš stejnou nejistotu jako zadarmo, jen s krásnějším grafem. Závěr byl jednoduchý: vlastní integrace, vlastní retry budget, vlastní audit. Provozně dražší o můj čas, ale aspoň přesně vím, jak se to chová.
Verze 0: naivní řešení
První kód, který jsem napsal, vypadal v podstatě takhle:
public function verify(string $countryCode, string $vatId): bool
{
return Http::get("https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{$countryCode}/vat/{$vatId}")
->json('valid');
}
VIES nabízí jak legacy SOAP endpoint, tak novější REST API (od 2023). Já volám REST - méně boilerplate, lepší error handling. Oba endpointy jsou ekvivalentní co do dat, která vrací.
Třířádkový kód: zavolá VIES, vezme z odpovědi valid, vrátí bool. A jedeme dál.
Jenže když jsem to začal pouštět proti reálnému VIES (jen v dev prostředí, žádné ostré platby), rychle vyplavaly čtyři problémy, které tuhle naivní verzi vyřadily:
1. Žádný audit. Kdyby se za rok kdokoli zeptal „dobře, ten zákazník na faktuře - jak víte, že byl plátce v dubnu 2026?", neměl bych nic. Boolean v paměti procesu, který už dávno skončil. Žádný consultation number, žádný timestamp, nic.
2. Žádný retry. První VIES timeout jsem viděl prakticky hned. Stačilo párkrát za sebou zavolat endpoint a jednou to nevrátí nic.
V ostrém checkoutu by to dopadlo takhle: zákazník dokončí objednávku, Stripe strhne peníze, jenže VIES neodpoví, takže má funkce vrátí false a reverse-charge faktura se vystaví i na neplatné DIČ.
Bez alertu na tento stav bych se to dozvěděl až dávno po vystavení faktury.
3. Boolean maže informaci. Tohle je vlastně nejhorší ze všech. Http::get má dvě dramaticky odlišná selhání: timeout (= VIES je down, nevím, jestli je VAT platný) a 200 OK s valid: false (= vím, že VAT neplatí).
Naivní kód pro oba stavy vrací false. Když VIES vrátí 503, můj systém řekne „VAT je neplatný, žádný reverse charge“ - a to je falešně negativní výsledek. Plnění bych tím překlopil na B2C, aniž bych pro to měl jakýkoli podklad.
4. Single point of failure v UX. Když VIES odpoví za 18 sekund - a v testu jsem se k takovým latencím dostal opakovaně - zákazník kouká 18 sekund na nehybný spinner „Validating…“. V ostrém checkoutu je 20 vteřin čekání + příležitostné 504 cesta, jak ztratit zákazníka, který už měl platební kartu v ruce. To není teoretický scénář, to je očekávané chování VIES.
Tyhle čtyři problémy jsem postupně řešil, každý vlastní iterací.
Iterace 1: audit log
První věc, kterou jsem přidal, byla append-only tabulka vies_verifications. Jeden řádek pro každý pokus. Neperzistuju jen volání 200 ale i selhání. Verdikt je tříhodnotový: valid, invalid, down.
Schéma tabulky vypadá zhruba takhle (zjednodušeno z reálné migrace):
Schema::create('vies_verifications', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')
->nullable()->constrained()->nullOnDelete();
$table->string('country_code', 2);
$table->string('vat_id', 32);
$table->string('verdict', 16); // valid | invalid | down
// populated only on `valid`
$table->string('consultation_number', 32)->nullable();
$table->dateTime('request_date')->nullable();
$table->json('response_payload')->nullable();
$table->timestamps();
$table->index(['user_id', 'verdict', 'created_at']);
});
Pár věcí v tom designu je schválně:
Append-only. Nikdy neupdatuju, nikdy nemažu (až na down řádky starší než 90 dní, viz dál). Každý pokus = jeden řádek. Když je za pět let potřeba něco dohledat, ukážu posloupnost řádků a jejich otisky.
Subscription je nullable. První ověření pro nového EU B2B zákazníka proběhne dřív, než vůbec vznikne Stripe subscription. Až dorazí webhook invoice.paid, sub_id se k odpovídajícímu valid řádku doplní. To dovoluje držet checkout-time verifikaci jako audit i v případě, že platba nedojde.
Country code je snapshot. Když by zákazník o rok později přepsal svoji zemi (legitimní, sídlo se mění), jeho stará faktura zůstane podložená původním country code v auditu. Žádný „současný stav“ → žádný drift.
Retence. valid a invalid zůstávají navždy (záznam pro pozdější dohledání). down řádky se po 90 dnech čistí samostatným cronem - jsou to čistě forenzní data („proč mi v pondělí ve 3 ráno padaly verifikace?“). Nezatěžuju jimi tabulku donekonečna.
Co tenhle krok řeší: první problém z verze 0 - mám záznam. Druhý postřeh, který se mi vyplatil až později: i pokus, který skončil down, je záznam o snaze. Důležité je, jestli jsem ověřoval důkladně, ne jestli se to povedlo vždycky.
A já mám tabulku, ze které vidím každý jeden pokus, hodinu, zemi a výsledek.
Co tahle iterace neřeší: jakmile VIES odpoví down, můj kód pořád padá na výjimce. Boolean je sice pryč, ale chování ne.
Iterace 2: retry budget
Druhá iterace je o tom, jak nakládat s tříhodnotovou logikou. Jednoduché pravidlo, které jsem si dal:
- Valid = zelená, vystavuju reverse-charge fakturu.
- Invalid = červená, neopakuju, blokuju akci, zákazníkovi řeknu: „Tvoje VAT ID je neplatné, oprav ho nebo přejdi na B2C cestu (Lemon Squeezy).“
- Down = žlutá, opakuju, ale s rozumným limitem.
Down ≠ Invalid je klíč. Když VIES vrátí 503, nesmí to spadnout do Invalid větve. Když VIES odpoví valid: false, nesmí to spadnout do Down (= retry).
Pseudokód retry smyčky vypadá takhle:
public function run(User $user, string $countryCode, string $vatId): ViesVerification
{
$lastResult = null;
for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
$result = $this->client->validate($countryCode, $vatId);
$lastResult = $this->repository->persist(
user: $user,
countryCode: $countryCode,
vatId: $vatId,
result: $result,
);
// Valid nebo Invalid - koncový stav, žádné další pokusy
if ($result->verdict !== Verdict::Down) {
return $lastResult;
}
// Sleep skipni na poslední iteraci - trailing sleep nikomu nepomůže
if ($attempt < self::MAX_ATTEMPTS) {
Sleep::for(self::RETRY_INTERVAL_SECONDS)->seconds();
}
}
// Po vyčerpání pokusů vracíme Down - volající rozhodne, co dál
return $lastResult;
}
Konstanty MAX_ATTEMPTS = 3 a RETRY_INTERVAL_SECONDS = 10, plus 20s timeout uvnitř $this->client->validate(). Worst case 3 × 20 + 2 × 10 = 80 s. Pro background flow přijatelné.
Tři důležité detaily:
1. Persistuju každý pokus, i ten, který následně retry. Když je první pokus Down a druhý Valid, mám v auditu oba řádky. Záznam, že jsem ověřoval „důkladně, ne jen jednou".
2. Sleep skipnu na poslední iteraci. Bez tohohle by byl horní celkový čas attempts × timeout + attempts × sleep, což je zbytečné. Trailing sleep za posledním pokusem nikomu nepomůže.
3. Po vyčerpání pokusů vracím Down, ne výjimku. Volající má rozhodnout, co s tím - některé cesty mají fallback (LS), některé chtějí customer-facing alert.
Testovatelnost. Sleep posílám přes Laravel facade Sleep::for(...)->seconds(), ne přes nativní sleep(). V testu zavolám Sleep::fake() a smyčka nečeká.
Žádný příznak „test mode“, žádné větvení v produkčním kódu - jenom fasáda, která má testovací režim built-in.
Tahle iterace dořeší druhý a třetí problém z verze 0. Co pořád zbývá: 80 s retry je ve flow, které blokuje UX, nepřijatelných.
Iterace 3: flow blokující UX je jiný režim
Když nový zákazník dělá první upgrade z Free na placený tarif, na obrazovce kouká na „Probíhá ověření VAT ID…“. Tady nemůžu strávit 80 vteřin - to zákazník opustí stránku a odejde. Zároveň ale potřebuju stejný audit, stejnou tříhodnotovou logiku, stejnou tabulku.
Řešení: dva entry-pointy do stejné třídy, různé retry profily.
ResolveVatVerification::run(user, cc, vat)
→ sequential 3×20s + 10s sleep, ~80s budget
→ background cesty (renewal cron)
ResolveVatVerification::runForCheckout(user, cc, vat)
→ Http::pool(2) paralelně, žádný retry rozpočet
→ synchronous UI (Checkout wizard)
runForCheckout udělá v jednom kole dva paralelní VIES requesty (přes Http::pool). Když některý odpoví Valid / Invalid během běžných 2-3 sekund, vracím verdict. Když oba selžou na timeout, vracím Down hned - bez druhého kola, bez sleep, bez čekání.
Předpokládá to, že paralelní redundance maskuje transientní flakiness lépe než druhý sériový pokus. V dev pozorování mi to sedí: VIES má spíš charakteristiku „jeden node 503, druhý OK“ než „celá služba dole pět minut“. Pool(2) by tedy měl dramaticky snížit míru Down, za cenu dvojnásobného počtu volání. Pro cron to nepoužívám - tam je 80 s retry v pohodě a šetřit zbytečně volání nemá smysl.
Co dělám s Down v checkoutu. Místo aby čekal dál, ukážu zákazníkovi callout se třemi tlačítky:
- Zkusit znovu - jenom rerun stejné akce, často by to napodruhé mělo projít.
- Uložit a vrátit se později - wizard má persistovaný draft (
checkout_drafts, 30denní TTL), takže může zavřít kartu a dokončit upgrade za hodinu. - Pokračovat přes Lemon Squeezy - kompromis. Když mi VIES dál nefunguje a zákazník nechce čekat, dostane se k placení přes LS jako merchant of record. Je to cena za nefunkční VIES. Ztrácí tím reverse charge a B2B fakturaci, ale je to vědomá volba zákazníka. Lepší než o něj přijít úplně.
Tenhle entry-point design je důležitý: stejná perzistence, stejný audit trail, jiný retry profil. Tabulka vies_verifications neví, jestli ji volá cron, nebo Livewire komponenta. Audit je homogenní.
Co se mi tady ukázalo: mít jednu třídu se dvěma veřejnými metodami je rozumnější než mít „one true retry strategy“. Místa volání mají různé tolerance, různá očekávání, různé fallbacky. Kdybych je sjednotil za každou cenu, vyrobil bych buď příliš pomalý checkout, nebo příliš agresivní cron.
Iterace 4: renewal cron a „čerstvý" záznam
Poslední dílek skládačky je obnova předplatného. EU B2B zákazník platí měsíčně. Mezi prvním upgradem (kdy proběhne první VIES verifikace) a první obnovovací fakturou uplyne 30 dní. Za tu dobu může:
- Přestat být plátcem DPH (zrušil firmu, přešel na neplátce, …).
- Změnit VAT ID (převod firmy, restrukturalizace).
- Nezměnit nic - to bude 99 % případů, ale ne záruka.
Před každou obnovovací fakturou tedy musím VIES překontrolovat. Ne v okamžiku vystavování faktury (to je 0:00:01 prvního dne v měsíci a VIES by mě v noci zbytečně blokoval), ale v rozumném předstihu.
Můj rozvrh:
- T-3, T-2, T-1 (= 3, 2, 1 dny před obnovou předplatného) - nightly cron, který pro každého aktivního EU B2B zákazníka zavolá
ResolveVatVerification::run(...). Až do prvního Valid v T-3, T-2, nebo T-1 - pak končí pro tu osobu. - T-0 (den obnovy předplatného) - escalation. Pokud Valid stále chybí, zařazuje zákazníka do VAT grace (14denní okno, kdy mu pošlu e-mail „Tvoje VAT ID se nepodařilo ověřit, prosím napiš mi“ a Stripe subscription se přepne do pause_collection.behavior = 'void' - během pauzy se nevytvářejí žádné invoice, takže neriskuju vystavení reverse-charge faktury na neověřené VAT ID).
- Renewal platba - když dojde, čte z
vies_verificationsnejnovější řádek s verdiktem Valid (≤ 3 dny starý) a jehoconsultation_numberpřipojí k faktuře jako doplňující záznam o ověření reverse charge.
Tady přijde rozdíl, na kterém mi obzvlášť záleží. Hranice „≤ 3 dny“ v kódu je čistě provozní - vychází z toho, jak často mi běží cron, ne z žádné pevně dané hranice. Žádný hodinový strop si nikde vynucený nemám.
Ten třídenní polštář je provozní rozhodnutí, vyplývá z toho, kdy mi cron běží. Spíš se to dá číst jako: „ověřil jsem víckrát, jednou za 24 hodin, v posledních 72 hodinách před uskutečněním plnění.“ To je z hlediska kontroly silnější než jeden 12 měsíců starý otisk. Ale není to absolutní strop - kdybych to z provozních důvodů potřeboval prodloužit na 5 nebo 7 dnů, nic zásadního se neděje, jen mám čerstvost záznamu o něco slabší.
Píšu to sem schválně, protože jsem viděl několik podobných článků, kde si autor sám sobě vyrobil zákonnou lhůtu z vlastního rozvrhu cronu a pak se ji bál posunout. Compliance tu má být pro mě, ne já pro ni.
Co se mi při návrhu ukázalo
Čtyři postřehy z toho návrhu, přenositelné mimo VIES.
1. Tříhodnotový verdikt > binární bool. Jakmile máš external API, jehož stavy zahrnují „down“, potřebuješ pro „down“ vlastní případ. Collapsed do false (= negativní) nebo true (= optimisticky předpokládaný) je vždycky špatně.
V VIES kontextu by sloučení Valid/Invalid znamenalo špatně postavené plnění; v jiných kontextech (auth, billing, healthchecks) má vlastní byznysovou cenu.
A jak ukazuje sekce o placených VIES službách - i komerční wrappery ti tu „down“ jen překládají do hezčího JSONu.
2. Audit trail je důležitější než aktuální stav. Tenhle postřeh byl pro mě nejméně intuitivní. Jako vývojář jsem chtěl mít jednu „source of the truth“ na User modelu (vies_verified_at, vies_consultation_number).
Append-only tabulka mi zpočátku přišla jako overhead. Jenže pravda v users.* se vždycky přepíše a o historii ověření a o tom, jak jsem se k ní dostal, mi neřekne nic.
Pozdější dohledání nechce vidět poslední timestamp ověření, chce vidět historii záznamů. Ze stejného důvodu nikdy nemažu invalid řádky - to, že byl někdo někdy invalid, je taky informace sama o sobě.
3. Jeden klient, dva retry profily. Když máš stejný external call na dvou místech volání s dramaticky odlišnými UX požadavky (background cron vs. UI checkout), nemá smysl je sjednocovat do jedné retry strategie. Dvě veřejné metody na stejné třídě, sdílená persistence, různé retry rozpočty. Sjednocení celého flow pro ověření pro dva různé use-case vede k buď příliš pomalému UX, nebo příliš agresivnímu jobu.
4. Časový provozní polštář není zákon. Když si v rámci spolehlivosti vyrobím interní pravidlo (≤ 3 dny VIES freshness, ≤ 24 hodin retence záloh, ≤ 30 min p99 latency, …), je to provozní kompromis, ne zákon. Nikde to nemám pevně dané, stavět na tom nemůžu, a hlavně si nesmím vlastní polštář splést s nějakou pevnou lhůtou. Důležitá je důkladnost - to, že jsem ověřoval poctivě a opakovaně - ne přesnost na hodinu. Důkladnost se hůř definuje, ale nakonec je to na čem záleží.
Bonus
Pro captchaapi.eu využívám svůj další projekt billify.cz, který vystavuje faktury pro EU B2B zákazníky a interně si znova ověřuje VIES při vystavení faktury v režimu reverse charge. To znamená, že každé reverse-charge plnění mám ověřené dvakrát: jednou v captchaapi.eu (před upgradem nebo renewal), podruhé v billify.cz (při vystavení faktury). Flow v billify je odlišné - tam se faktura vystavuje synchronně s platbou, takže nemůžu použít sériový retry budget. A jak jsem to vyřešil bude obsahem příštího článku.