← Zpět · 10. května 2026 · 16 min čtení

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 musím u každého EU B2B plnění před vystavením faktury v režimu reverse charge ověřit, že příjemce je plátce DPH v jiném členském státu. Defakto jediná cesta je VIES — evropský endpoint provozovaný Evropskou Komisí. Občas pomalý, občas nefunkční. A když po pěti letech přijde FÚ na kontrolu, musíš mu předložit důkaz, že jsi si opravdu ověřil že v době vystavení faktury měl zákazník platné DIČ.

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ě před tím, než přes captchaapi proteče první ostrá EU B2B platba — všechno popsané jsem dotahoval při developmentu, 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 tu validaci musím udělat já. Když zákazníkovo VAT ID neexistuje nebo už neplatí, formálně se to plnění dá reklasifikovat jako B2C a finanční úřad mi může doměřit českou DPH plus penále — což můj účetní rozhodně dělat 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 po mně chce VIES a zákon o DPH

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 důkazem, že jsem se v daném okamžiku ptal a co mi systém odpověděl.

Tady je důležité jedno upřesnění, které mi původně uniklo: zákon o DPH nikde explicitně neříká „použij VIES“. Není v něm žádný paragraf, který by stanovil minimální frekvenci kontroly nebo formát důkazu. Důvod, proč VIES přesto musím spouštět, plyne nepřímo:

  • § 9 odst. 1 ZDPH — uplatnění reverse charge mám podmíněné tím, že příjemce je osoba povinná k dani v jiném členském státě.
  • § 102 ZDPH — do souhrnného hlášení, které se podává do 25. dne po skončení kalendářního měsíce, musím uvést DIČ příjemce. Pokud uvedu DIČ, které v okamžiku plnění neplatilo, FÚ může plnění zpětně reklasifikovat na tuzemské B2C, doměřit českou DPH a přidat penále a úrok z prodlení.
  • § 24a ZDPH — kdy plnění přiznat: ke dni uskutečnění služby nebo přijetí úplaty, podle toho, co nastane dřív. U měsíčního předplatného to v praxi koresponduje s datem úplaty.

Důkazní břemeno je tedy obecné: musím být schopen prokázat, že jsem v dobré víře pokládal příjemce za osobu registrovanou k DPH. Je to test péče řádného hospodáře, ne ostré časové okno. VIES je defakto standardní cesta, jak ten test splnit, a consultation number je v EU obvyklý důkaz.

Tohle je důvod, proč tvrdím, že interní polštář „VIES proof není starší než tři dny“ je provozní rozhodnutí — důsledek toho, jak často mi jezdí pre-flight kontroly. Ne zákonná lhůta. Daňový kontrolor se nedívá, jestli je consultation number 47 nebo 71 hodin starý; dívá se, jestli existuje, jestli pokrývá období blízké uskutečnění plnění a jestli z něj plyne, že jsem byl důkladný.

Co po mně VIES tedy reálně chce — z pohledu toho, jak jsem se rozhodl plnit ZDPH:

  1. Ověřit VAT ID dřív, než vystavím fakturu v režimu reverse charge.
  2. Uložit důkaz ověření tak, aby přežil mě i moji databázi za pět let.
  3. 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 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 a vyšší, „enterprise-grade" dashboard a fakturu, kterou se před auditorem dá líp ohánět než „já si na to napsal 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, ale payload 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.

Když jsem to ale začal pouštět proti reálnému VIES (jen v dev prostředí, žádné ostré platby), velmi rychle se ukázaly čtyři problémy, které tuto naivní verzi vyřadily:

1. Žádný audit. Kdyby si daňový kontrolor za rok zavolal a zeptal se „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 znamenalo: zákazník dokončí objednávku, Stripe strhne peníze, ale 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ž při kontrole z FÚ.

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“ — což je z pohledu důkazního břemene falešně negativní výsledek. Nemám právo takhle reklasifikovat plnění.

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ž FÚ za pět let zaklepe, ukážu mu 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 (důkaz pro případnou kontrolu). 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 důkaz. Druhý postřeh, který se mi vyplatil až později: i pokus, který skončil down, je „důkaz snahy“. Test péče řádného hospodáře se ptá „byl jsi důkladný?“, ne „povedlo se ti to 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. Důkaz, ž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 stále zbývá: 80 s retry je nepřijatelný ve flow blokujícím UX.

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 — 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 tak bych vyrobil buď příliš pomalý checkout, nebo příliš agresivní cron.

Iterace 4: renewal cron a „čerstvý" důkaz

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_verifications nejnovější řádek s verdiktem Valid (≤ 3 dny starý) a použije jeho consultation_number jako důkaz uplatnění reverse charge na faktuře.

Tady přijde rozdíl, na kterém mi obzvlášť záleží. Hranice „≤ 3 dny“ v kódu není zákonná lhůta. Není to nic, co by ZDPH explicitně vyžadoval — zákon žádný hodinový strop nestanovuje. Důkazní břemeno je obecné, test je péče řádného hospodáře, ne ostrá časová podmínka.

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ů, není to samo o sobě legální problém, jen je to slabší důkaz.

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 agregace Valid/Invalid byla porušení daňové povinnosti; 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. Ale users.* pravda se vždycky přepíše a nic mi neřekne o historii ověření a jak jsem se k ní dostal. Kontrola za pět let 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. Zákon mě k tomu nenutí, stavět na tom nemůžu, a hlavně nesmím zaměnit svůj polštář se zákonnou lhůtou ve vlastní hlavě. FÚ se ptá „byl jsi důkladný?“, a ne „byl jsi do hodiny přesný?“. 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.

Stačí psát část slova · ⌘K otevře hledání odkudkoliv

No results found