B Billify
Všechny články
8 min čtení Vladislav Rajtmajer

Když VIES neodpoví: Billify fakturu nezablokuje

VIES je nestabilní evropská služba. Když u reverse-charge faktury neodpoví, Billify ji přesto vystaví, dořeší ji na pozadí a výsledek pošle webhookem.

Fakturuješ EU B2B v režimu reverse charge? Pak víš, že předtím, než tu fakturu s nulovou DPH vystavíš, musíš ověřit, že odběratel má v jiném členském státě platné DIČ. Jediná autoritativní cesta je VIES, centrální evropská služba provozovaná Evropskou komisí. A ta je notoricky nestabilní: jeden node vrátí 503, druhý se odmlčí na dvacet sekund, občas celý členský stát chvíli neodpovídá.

Tady vzniká nepříjemný spor. Tvůj klient zaplatil, fakturu chce hned. Jenže VIES zrovna mlčí. Co s tím? Selhat a fakturu nevystavit? Nebo ji vystavit a tvářit se, že DIČ je platné? Obojí je špatně. A přesně tohle Billify řeší tak, aby ti výpadek cizí služby nezablokoval fakturaci.

Tři stavy, ne true/false

Naivní VIES wrapper vrátí bool: platí, neplatí. Tím ale zahodíš nejdůležitější informaci. VIES totiž má tři výstupy, ne dva:

  • Valid - DIČ platí, reverse charge je v pořádku.
  • Invalid - DIČ neplatí. Tohle víš.
  • Unavailable - VIES neodpověděl. Tohle nevíš.

Rozdíl mezi Invalid a Unavailable je jádro celého problému. Když je sloučíš do false, znamená to „DIČ neplatí, žádný reverse charge" pokaždé, když má EU prostě výpadek. To je z hlediska důkazního břemene falešně negativní výsledek. Billify proto pracuje s tříhodnotovým verdiktem (ViesStatus) a každý stav řeší jinak.

Co se stane při vystavení faktury?

Ověření běží synchronně přímo při POST /v1/invoices (paralelní Http::pool race s 30s stropem). Podle verdiktu:

  • Valid → faktura se generuje s compliance status Verified.
  • Invalid → tvrdý blok. API vrátí 422 reverse_charge_ineligible:vat_id_invalid a fakturu nevystaví. Žádný override flag neexistuje. (Výjimka je jen ruční vystavení v UI, kde uživatel vědomě přebírá právní odpovědnost za reverse charge podle čl. 196 směrnice 2006/112/ES. Automat to udělat nemůže.)
  • Unavailablefaktura se přesto vystaví, ale s compliance statusem Pending. Tady začíná ta zajímavá část.

Klíčové je, že Unavailable fakturu nezablokuje. Klient dostane svou fakturu, ověření se dořeší později. A protože je Unavailable jen dočasný stav, nikdy se necachuje - cachují se jen definitivní verdikty (Valid/Invalid), a to do konce dne. Příští pokus tak jde vždycky naživo.

Dořešení běží na pozadí

Pending fakturu si vyzvedne plánovaný command, který běží každých 15 minut:

// routes/console.php
Schedule::command('billify:retry-pending-vies')
    ->everyFifteenMinutes()
    ->withoutOverlapping()
    ->runInBackground();

Ten najde reverse-charge faktury vystavené dnes, jejichž poslední VIES záznam je pořád Unavailable, a pro každou spustí jednorázový job:

return Invoice::query()
    ->where('vat_mode', VatMode::ReverseCharge)
    ->whereDate('issued_at', today())
    ->whereRelation('latestViesValidation', 'status', ViesStatus::Unavailable->value);

Job (RetryViesValidationForInvoice) má dvě pojistky:

  1. ShouldBeUnique na ID faktury, takže když předchozí pokus ještě běží, nový dispatch je no-op a nikdy nejedou dva souběžně.
  2. Před každým zápisem kontroluje, že faktura pořád existuje, takže smazání faktury uprostřed běhu !== osiřelý záznam.

Hranice „dnes" je důležitá: jakmile se překlopí půlnoc, faktura z dotazu vypadne, žádné další pokusy se neplánují a její compliance accessor se při čtení přepne na Exhausted. Žádný explicitní cutoff mechanismus, prostě to vyplyne z dotazu.

Výsledek dorazí webhookem

Klient nemusí stav vyčítat polling smyčkou. Když job doběhne k definitivnímu verdiktu, tak se přes event rozešle webhook subscriberům:

$verdict = match ($result->status) {
    ViesStatus::Valid => ViesResolutionVerdict::Valid,
    ViesStatus::Invalid => ViesResolutionVerdict::Invalid,
    ViesStatus::Unavailable => null,
};

if ($verdict !== null) {
    InvoiceViesResolved::dispatch($invoice->id, $verdict);
}

A když retry okno zavře, aniž VIES kdy odpověděl, samostatný příkaz pošle Exhausted event. Běží dvakrát krátce po půlnoci (00:05 a 00:30), aby pokryl i pozdní job, který doběhl až po prvním průchodu. Druhý průchod je idempotentní - přeskočí faktury, kterým už Exhausted webhook odešel:

return Invoice::query()
    ->where('vat_mode', VatMode::ReverseCharge)
    ->whereDate('issued_at', today()->subDay())
    ->whereRelation('latestViesValidation', 'status', ViesStatus::Unavailable->value)
    ->whereDoesntHave('webhookDeliveries', fn ($q) =>
        $q->where('verdict', ViesResolutionVerdict::Exhausted->value));

Z pohledu klienta to znamená jediné: pošle payload, dostane fakturu, a o konečném výsledku ověření se dozví webhookem, ať dorazí za minutu nebo až ráno po nočním výpadku VIES.

Co zbude pro finanční úřad

Tohle všechno se odehrává nad append-only auditní stopou. Tabulka vies_validations drží jeden řádek na každý pokus, ne jen na ten poslední. Když je první pokus Unavailable a třetí Valid, máš v auditu všechny tři. Každý řádek nese raw VIES odpověď (response_payload) jako podklad k souhrnnému hlášení (§ 102 ZDPH) a pro případnou kontrolu z finančního úřadu.

Compliance status faktury přitom není sloupec, ale počítaný accessor (vies_compliance_status) odvozený z posledního záznamu a stavu retry okna:

Stav Význam
NotApplicable faktura není reverse charge, VIES se neaplikuje
Verified VIES potvrdil platné DIČ
Pending VIES byl při vystavení nedostupný, retry pořád běží
Invalid VIES vrátil neplatné DIČ
Acknowledged neplatné DIČ, ale uživatel fakturu vědomě vystavil (jen UI)
Exhausted retry okno zavřelo, VIES nikdy neodpověděl

Protože je to accessor, nemůže zastarat. I když odběratel později své DIČ zruší nebo dodavatel změní svůj VAT status, historická faktura si drží stav z okamžiku vystavení a audit ukazuje, jak jsi se k němu dostal.

Proč na tom záleží

Tady je ale potřeba nepřehánět: ověření ve VIES je formální, informativní krok, ne absolutní důkaz. Finanční úřad při kontrole zkoumá faktický stav plnění, ne jen to, jestli v okamžiku vystavení svítilo ve VIES zelené světlo. Důkazní břemeno u reverse charge totiž není ostré časové okno, ale test péče řádného hospodáře - FÚ se neptá „byl jsi do vteřiny přesný?", ptá se „byl jsi důkladný?". A co ti auditní stopa dává, není neprůstřelná obrana, ale doklad, že jsi jednal v dobré víře: ověřoval jsi, a když ti EU služba neodpověděla, zkoušel jsi dál, každý pokus sis zapsal a fakturu jsi nevystavil naslepo na neověřené DIČ.

Ani Billify ti přitom chybějící data z VIES nevykouzlí - když evropská služba mlčí, platné DIČ z ničeho nevyrobí nikdo. Co ale udělá, je že si nechá i ty neúspěšné pokusy. Append-only tabulka drží každý pokus včetně Unavailable. Nikdy je nemaže a každý řádek v sobě zmrazí surovou odpověď VIES (response_payload) přesně tak, jak v tu chvíli přišla. Takže i „VIES byl v 9:15 i v 9:30 dole" je doložitelný fakt, ne mezera v evidenci. Celá ta stopa je svázaná přímo s fakturou (FK s kaskádou) a žije s ní po celou dobu - vedle zmrazených snapshotů dodavatele, odběratele a banky, které si faktura drží od okamžiku vystavení.

Pointa pro tebe jako klienta je jednoduchá. Výpadek VIES není tvůj problém. Billify fakturu kvůli němu nezablokuje, dořeší ověření na pozadí, výsledek ti pošle webhookem a celou cestu si zapíše do auditní stopy, kterou v případě kontroly doložíš jako svou důkladnost.

Jestli stavíš EU B2B SaaS a nechceš tuhle nestabilní službu řešit sám, Billify to vyřeší za tebe.

Fakturuj přes API, ne klikáním

REST API pro CZ/EU fakturace. Reverse charge, ČNB kurzy a VIES automaticky. Bez MoR poplatků.