← Zpět · 22. května 2026 · 8 min čtení

Synchronní VIES v billify: 3 iterace pro fakturační API

Když fakturační API vrací 201 Created s URL na PDF v jediném HTTP cyklu, 80vteřinový retry budget je nepoužitelný už od začátku. Tři iterace, jak synchronní cestu k VIES dostat pod kontrolu.

V předchozím článku o VIES audit trailu jsem skončil teaserem: pro captchaapi.eu si můžu dovolit sériový retry budget 3 × 20 s + sleep mezi pokusy. Ověření tam běží buď v nočním cronu před obnovou předplatného, nebo v checkout wizardu, kde má uživatel fallback „Uložit a vrátit se".

Billify.cz má jiný režim. Klient přes REST API vystaví fakturu synchronně: POST /v1/invoices vrátí 201 Created s číslem faktury, snapshotem dodavatele a URL na PDF. Mezi requestem a odpovědí musím stihnout VIES (jde-li o reverse-charge plnění podle § 9 odst. 1 ZDPH), kurz ČNB, vygenerování čísla faktury a její uložení. Klient na druhé straně typicky běží uvnitř svého vlastního webhooku z platební brány a má vlastní timeout - na 80-sekundový sériový retry tam prostě nemám prostor.

Tenhle článek je o tom, jak jsem to ve třech iteracích vyřešil.

Verze 0: prostě zavolám VIES

Triviální verze synchronního ověření vypadá takhle:

public function issueInvoice(Customer $customer, ...): Invoice
{
    $result = $this->vies->verify($customer->vatId);

    if ($result->verdict === Verdict::Invalid) {
        throw new ReverseChargeIneligible('VAT ID invalid');
    }
    if ($result->verdict === Verdict::Unavailable) {
        throw new ServiceUnavailable('VIES down, try later');
    }

    // ... vystavit fakturu
}

Funguje to - dokud nenastane jeden ze tří scénářů:

1. Tail latency. VIES typicky odpoví za 1–3 vteřiny, ale občas se zasekne na 25+ s. Klientova strana mezitím dávno padla na timeoutu a já jsem se ke 201 ani nedostal.

2. Burst stejnému zákazníkovi. Klient vystavuje 10 faktur za sebou jednomu příjemci (např. monthly batching). Volám VIES 10×, platím 10× cenu race condition s outage v EU.

3. Unavailable jako trvalý 422. Když VIES krátkodobě padne, vrátím klientovi 422 „zkuste to znovu". Klient nemá jak to řešit - jeho cron mezitím končí, jeho webhook se nezopakuje. Pro mě to znamená ztracenou fakturu a manuální práci.

Každý ze tří scénářů má svoji iteraci.

Iterace 1: paralelní race místo sériového retry

První iterace adresuje tail latency. Když si nemůžu dovolit sériové volání (1 + 1 + 1 = 3× 30 s), tak musím jít paralelně (2× 30 s = pořád 30 s, ale dvě nezávislé šance že dostanu odpověď).

Koncept: spustím dva souběžné requesty na VIES, beru první definitivní verdikt (Valid nebo Invalid), transientní selhání (timeout, 5xx) přeskakuji. Když selžou oba, vracím Unavailable - ne Invalid. To by byla falešně negativní reklasifikace plnění.

Laravel to umí přes Http::pool:

$responses = Http::pool(fn (Pool $pool) => array_map(
    fn () => $pool->timeout(30)->acceptJson()->get($viesUrl),
    range(1, 2),
));

foreach ($responses as $response) {
    $verdict = $this->parse($response);

    if ($verdict->isDefinitive()) {
        return $verdict;   // Valid / Invalid - končíme hned
    }
}

return Verdict::unavailable();   // oba pokusy selhaly transientně

Past, na kterou jsem narazil: array_fill(0, 2, $pool->get(...)) vyhodnotí $pool->get(...) jednou a naplní pole dvěma odkazy na tu stejnou promisu. Pool potom pošle jeden request, ne dva. array_map s closure forsuje vlastní volání na každý slot.

Pozorovaná charakteristika VIES je „jeden member-state node 503, druhý OK" mnohem častěji než „celá služba dole pět minut". Pool(2) tak dramaticky snižuje míru Unavailable za cenu dvojnásobku volání. Pro synchronní fakturační API je to dobrá volba - VIES nestojí nic, klientův čas ano.

Co tahle iterace neřeší: 10× volání pro 10 faktur stejnému zákazníkovi je pořád 10× pool(2) = 20 requestů. A Unavailable z obou směrů pořád skončí jako 422 pro klienta.

Iterace 2: cache do konce dne

Druhá iterace řeší burst. Klíčové pozorování: všechny faktury vystavené týž den mají stejné DUZP - tedy stejný moment, ke kterému § 92a ZDPH chce mít platné DIČ. Ověření z 9:00 ráno je důkazně použitelné pro fakturu vystavenou v 17:00 toho samého dne.

Z toho plyne cache strategie:

$key = "vies:{$supplierVat}:{$customerVat}";

if ($cached = Cache::get($key)) {
    return $cached;
}

$result = $this->verify(...);   // pool(2) z iterace 1

if ($result->isDefinitive()) {
    Cache::put($key, $result, now()->endOfDay());
}

return $result;

Tři detaily, na kterých záleží:

Klíč je pár (dodavatel, zákazník), ne jen zákazník. Stejný zákazník může vůči různým dodavatelům spadat do jiného režimu (zánik / obnova plátcovství, fakturace přes provázané firmy). Cachovat per-zákazník by mohlo pustit verdict z jiného právního kontextu.

TTL je endOfDay(), ne 24 hodin. Klasická TTL by mohla pustit nedělní Valid verdict do pondělních faktur - a pondělní DUZP už chce důkaz, který je k pondělí blízký. Půlnoc je byznysová hranice, ne arbitrární okno.

Cachuju jen Valid a Invalid. Unavailable se do cache nikdy nedostane. Jinak by druhý pokus za pět minut vrátil zacachovaný Unavailable místo aby šel naživo a možná uspěl - cache by efektivně zhoršila výsledek.

Co tahle iterace neřeší: pořád, když i pool(2) selže, klient dostane 422. A pro nového zákazníka, kterého ještě nemám v cache, je první volání plně exponované VIES.

Iterace 3: vrátit Pending místo 422

Třetí iterace mění mentální model. Defenzivní reakce na Unavailable je „vrátit 422 a říct klientovi: zkus to později". Provozně je to ale špatně - VIES může být dole hodiny, klient to nemá jak řešit.

Místo toho: Unavailable propustí fakturu dál se stavem Pending. Faktura se vystaví, dostane číslo, vrátí se 201 s URL na PDF. V databázi má attached audit row s tím, co VIES odpověděl (a co neodpověděl). Unavailable není koncový stav - scheduled command ho dořeší v pozadí.

Doplnění pomocí scheduled command, který běží každých 15 minut:

// schedule
$schedule->command('billify:retry-pending-vies')
    ->everyFifteenMinutes()
    ->withoutOverlapping();

// command (zjednodušeně)
Invoice::query()
    ->where('vat_mode', VatMode::ReverseCharge)
    ->whereDate('issued_at', today())   // jen dnes
    ->whereRelation('latestVies', 'verdict', Verdict::Unavailable)
    ->chunkById(200, fn ($invoices) =>
        $invoices->each(fn ($inv) => RetryViesJob::dispatch($inv->id))
    );

Retry job je jednorázový (tries = 1, 60 s timeout, unique lock na ID faktury, aby se dva běhy commandu nepřebily). Zapíše další audit row, faktura má latestVies aktualizovaný, stav compliance se přepočítá z poslední řádky.

Cutoff je implicitní: dotaz filtruje whereDate('issued_at', today()). Jakmile nastane nový den, faktura z dotazu vypadne, žádný další retry se nedispatchne, a accessor stavu compliance přečte poslední Unavailable jako Exhausted. Žádný explicitní state machine, žádná cancel logika - den prostě skončí.

Klient se výsledek dozví webhookem. Synchronní 201 nesla status Pending; v ten okamžik klient ví jen to, že faktura existuje a že ověření doběhne v pozadí. Když retry job dorazí k definitivnímu verdiktu, vystřelí event:

// uvnitř retry jobu
if ($result->isDefinitive()) {
    InvoiceViesResolved::dispatch($invoice->id, $result->verdict);
}

Queued listener si na druhém konci najde klientův registrovaný webhook na event invoice.vies.resolved (pokud nějaký má) a pošle mu HMAC-podepsaný POST s novým stavem. Klient si tak může lokálně přepsat cache faktury, poslat zákazníkovi e-mail s definitivní fakturou, nebo cokoli dalšího, co normálně zařizoval kolem Verified stavu při synchronním issue.

Důležitý detail: webhook se spustí jen při async změně (PendingVerified / Invalid / Exhausted). Synchronní Verified z první cesty, kdy ho klient dostal rovnou v 201 odpovědi, se webhookem neoznamuje - byla by to duplicitní informace.

Šest stavů, které z toho vyplývají

Když si rozkreslím možné průchody třemi iteracemi, vyjde mi šestičlenný lifecycle compliance:

Stav Kdy nastává
NotApplicable Faktura není reverse-charge, VIES se neřeší
Verified VIES vrátil Valid v okamžiku vystavení nebo při retry
Pending VIES vrátil Unavailable, čeká se na další retry tick
Invalid VIES vrátil Invalid (přes API odmítnuto 422 už na vstupu)
Acknowledged UI cesta: uživatel kliknul „Vystavit přesto" přes Invalid verdict
Exhausted Pending VIES do půlnoci nevrátil odpověď, retry window se zavřelo

Klient přes API vidí stejný stav jako uživatel v UI. Jediný rozdíl je Acknowledged, ten stav umí vystavit jenom UI tlačítko. REST API tu cestu schválně nemá, jinak by kontrakt nebyl deterministický.

Co to ukázalo

1. Synchronní zápis na flaky service není řešení pro sériový retry. Když má klient na druhém konci svůj vlastní timeout, sérii proti sobě nepostavíš. Buď parallel race, nebo přesuneš retry do pozadí (Pending + cron). Sériový retry má smysl tam, kde si to můžeš dovolit (cron, queue worker, background job) - ne v synchronním HTTP cyklu.

2. Pending je promise, ne odpověď. Falešný Invalid na daňových datech bych zaplatil nakonec já jako penále, takže „nevím, zkouším dál" je v API kontraktu legitimní stav, a ne chyba. Cenou je ale uzavřený loop na obou stranách: dovnitř (cron + retry job, který stav v DB dořeší) a ven (webhook, kterým o výsledku aktivně řeknu klientovi). Bez té druhé strany jsem klienta donutil pollovat - což je horší UX než kdybych mu rovnou vrátil 422.

3. endOfDay() jako TTL kde má den byznysovou váhu. Klasické TTL (24 h, 1 h) je arbitrární a posunuté. endOfDay() koresponduje s tím, jak ZDPH vidí DUZP - den je atomická jednotka kontroly. TTL navázaná na byznysový den ti nedovolí pustit nedělní Valid jako odpověď na pondělní request.

4. Implicitní cutoff přes dotaz, ne přes state machine. „Půlnoc překlopí faktury na Exhausted" jsem zpočátku chtěl řešit explicitním přechodem v noci. Vyšlo z toho ale elegantnější řešení: dotaz vidí jen faktury vystavené dnes, accessor odvodí stav z toho, co poslední validation říká. Žádný cron na přechod, žádný flag k synchronizaci - den uplynul, dotaz to viděl, accessor to viděl, hotovo.

Příště navážu článkem o kurzech ČNB - jak řešit „kurz k DUZP", když ČNB občas nezveřejní fixing a synchronní fakturační flow potřebuje nějakou odpověď hned.

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

No results found