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

DUZP, Stripe a UTC: jak ukládat finanční timestampy

Dva validní přístupy k ukládání finančních timestampů v ČR SaaS — a proč jsem v billify.cz zvolil ten pragmatický.

Když máš český SaaS s mezinárodními platbami, narazíš na rozpor, který v dokumentaci žádného frameworku není explicitně popsaný: finanční datum (DUZP) musí být vždy v Europe/Prague, ale technický timestamp z platební brány ti přijde v UTC. Smícháš-li to ve špatný okamžik, dostaneš se do situace, kdy platba ve 23:30 SEČ má DUZP 31. prosince, ale ve fakturačním systému se zaúčtuje 1. ledna příštího roku — a ty jsi právě zvoral DPH přiznání.

Přesně tohle jsem řešil v komunikaci mezi captchaapi.eu (PoW CAPTCHA API hostovaná v EU) a billify.cz (fakturační SaaS, který jí vystavuje faktury). V tomhle článku popíšu dva přístupy — defenzivní učebnicový a pragmatický, který reálně používám — a vysvětlím, proč oba fungují.

Proč to není triviální

DUZP — datum uskutečnění zdanitelného plnění — je pojem ze zákona č. 235/2004 Sb. o DPH, ke kterému se váže povinnost přiznat daň. Pro plátce DPH a identifikované osoby je DUZP povinný údaj na faktuře a určuje, do kterého zdaňovacího období transakce spadá. Klíčová věc: DUZP je kalendářní datum v Česku, ne timestamp v UTC. Stát se nezajímá, že 2025-12-31 22:30:00 UTC je technicky předchozí kalendářní den; pro něj je to platba z 31. 12. 2025 lokálního času.

A teď druhá strana: Stripe (a Lemon Squeezy, a v podstatě každá moderní platební brána) ti pošle webhook s created jako Unix timestamp v UTC. Pokud platba proběhne 31. 12. 2025 ve 23:30 SEČ, dostaneš timestamp 17356878002025-12-31 22:30:00 UTC. Stačí jeden bug v konverzi a posíláš fakturu s DUZP do špatného roku.

Dva validní přístupy

V Postgresu existují dva typy pro ukládání data a času:

  • timestamptz (= timestamp with time zone) — interně vždy UTC, při čtení/zápisu konvertuje podle session timezone
  • timestamp (= timestamp without time zone) — naivní, žádná konverze, uloží přesně to, co dostane

Z toho plynou dva architektonické styly:

  1. Defenzivní (UTC v DB): timestamptz + app.timezone = Europe/Prague + DB session timezone = UTC. Postgres mezi tím překládá automaticky.
  2. Pragmatický (matched timezone): timestamp (bez TZ) + app.timezone = Europe/Prague + DB session timezone = Europe/Prague. Žádná konverze, naivní wall-clock storage.

Oba jsou validní. Volba záleží na kontextu — pojďme oba popsat.

Společný základ: DUZP jako date, ne timestamp

Tohle platí pro oba přístupy a je nejdůležitější rozhodnutí v celém schématu. DUZP je kalendářní den. Když ho uložíš jako timestamp s časem 00:00:00, koleduješ si o problém: jaká timezone? Jakmile někdo ten timestamp převede přes setTimezone(), dostane jiný den.

V mém schématu pro billify.cz to vypadá takhle (zkráceno):

create table public.invoices (
    id                  bigserial primary key,
    issued_at           date,           -- datum vystavení
    taxable_supply_at   date,           -- DUZP
    due_at              date,           -- splatnost
    paid_at             timestamp(0),   -- okamžik platby
    sent_at             timestamp(0),   -- okamžik odeslání
    -- ...
);

Všechny účetní datumy jsou date — žádná timezone, žádný čas, jen kalendářní den. Všechny operativní timestampy jsou timestamp(0) (bez time zone, sekundová přesnost). O tom za chvíli.

Přístup 1: Defenzivní (UTC v DB + timestamptz)

Tohle je učebnicová best practice, kterou najdeš v každé Postgres příručce.

// config/app.php
'timezone' => 'Europe/Prague',
// config/database.php
'pgsql' => [
    // ...
    'timezone' => 'UTC',  // session TZ při connectu
],

V migracích pak timestampTz() místo timestamp():

$table->timestampTz('paid_at');
$table->date('taxable_supply_at');     // DUZP zůstává date
$table->timestampsTz();

Jak to funguje: Eloquent serializuje Carbon do stringu Y-m-d H:i:s. Postgres ten string interpretuje v session timezone (UTC), uloží jako UTC. Při čtení převede zpátky podle session timezone (UTC) — Eloquent dostane Y-m-d H:i:s v UTC, parsuje ho v app.timezone (Europe/Prague), vznikne Carbon v Europe/Prague časové zóně.

Výhody:

  • Defenzivní default — kdyby někdo někdy importoval data z jiné timezone, je to jasné
  • Multi-region friendly — repliky v různých regionech vidí stejné UTC hodnoty
  • Reporting tools (Metabase, Grafana) mají defaultně UTC a vidí to samé jako DB
  • psql z jiného countries ukáže UTC, což je předvídatelné

Nevýhody:

  • NOW(), CURRENT_DATE, DATE_TRUNC v SQL vrací UTC hodnoty — pro business queries ("faktury vystavené dnes v Praze") musíš všude psát AT TIME ZONE 'Europe/Prague'
  • Při debugování v psql vidíš UTC, v aplikaci Prahu — musíš mentálně překládat
  • Pro single-region SaaS je to overkill

Přístup 2: Pragmatický (matched timezone, můj setup)

Tohle reálně používám v billify.cz. Oba configy jsou nastavené na Europe/Prague a databáze používá naivní timestamp bez time zone:

// config/app.php
'timezone' => 'Europe/Prague',
// config/database.php
'pgsql' => [
    // ...
    // Pin the PostgreSQL session timezone to Europe/Prague to
    // match app.timezone. Eloquent writes timestamps without an
    // explicit offset (`Y-m-d H:i:s`); PG interprets that as
    // session-local, so app TZ and DB session TZ must agree or
    // TIMESTAMPTZ round-trips drift by 1–2 hours. Also makes
    // SQL functions (NOW(), CURRENT_DATE, DATE_TRUNC) return
    // Czech calendar values, which is what business queries
    // ("invoices issued today", monthly quotas) need.
    'timezone' => 'Europe/Prague',
],

V migracích timestamp() (ne timestampTz()):

$table->timestamp('paid_at', 0)->nullable();
$table->date('taxable_supply_at');
$table->timestamps(0);

Jak to funguje: Eloquent serializuje Carbon (v Praze) do stringu Y-m-d H:i:s (wall clock v Praze). Postgres ho uloží do timestamp without time zone přesně jak přišel — žádná konverze. Při čtení dostane Eloquent zpátky stejný string, parsuje ho v app.timezone (Praha), vznikne Carbon v Praze. Round-trip bez jediné timezone konverze.

Session timezone Europe/Prague v DB connectu hraje roli jen pro:

  • SQL funkce (NOW(), CURRENT_DATE, DATE_TRUNC)
  • timestamptztimestamp cast (kdybys ho někdy potřeboval)

Pro samotné timestamp without time zone storage je session TZ irelevantní — Postgres ho uloží jak přišel.

Výhody:

  • SELECT NOW()::date FROM invoices WHERE created_at::date = CURRENT_DATE funguje intuitivně bez AT TIME ZONE
  • V psql vidíš stejné hodnoty jako v aplikaci — žádné mentální překládání
  • Reporting queries jsou kratší a čitelnější
  • Žádná možnost "drift o 1–2 hodiny" kvůli rozsynchronizovaným timezone — protože žádná konverze neprobíhá

Nevýhody:

  • Když si k DB připojí někdo z jiné timezone bez SET TIME ZONE 'Europe/Prague', dostane stejné stringy, ale NOW() mu vrátí jeho lokální čas — můžou vzniknout zmatky
  • Multi-region by vyžadoval dodatečné rozhodnutí (typicky migrace na timestamptz)
  • Při importu dat z jiné timezone musíš ručně vědět, že je to wall clock v Praze a ne UTC

Klíčový bod: jediné místo s timezone konverzí

V obou přístupech je jeden bod, kde do systému vstupuje UTC timestamp ze Stripe webhooku, a v tom bodě se musí explicitně konvertovat na Prague time.

V mém setupu probíhá konverze už na straně captchaapi.eu, ne v billify.cz. Captcha API si od Stripe webhooku spočítá DUZP samo a posílá ho do billify API už jako hotový string:

// captchaapi.eu — handler Stripe webhooku
use Carbon\CarbonImmutable;

public function handlePaymentSucceeded(array $event): void
{
    $paymentIntent = $event['data']['object'];

    // DUZP = dnešní kalendářní datum v Praze
    $today = CarbonImmutable::now('Europe/Prague')->toDateString();

    // Pošleme do billify API
    $this->billifyClient->createInvoice([
        'customer_id' => $this->resolveCustomer($paymentIntent),
        'taxable_supply_at' => $today,  // už hotový 'Y-m-d' string
        // ... další položky
    ]);
}

Billify API pak ten string jen uloží do taxable_supply_at jako date. Žádná timezone arithmetic na straně billify, čistá responsibility separation. Jediné místo v celém systému, kde se řeší timezone, je tenhle jeden řádek v captchaapi.eu.

Důvod, proč používám now('Europe/Prague') místo derivace z paymentIntent['created']: faktura se generuje v okamžiku úspěšné platby (synchronně v handleru webhooku), který obvykle dorazí do několika vteřin. Pro 99,9 % případů je now() v Praze totéž co Stripe.created v Praze. Edge case je platba kolem půlnoci, kde by webhook mohl dorazit s mírným zpožděním a překlopit se přes půlnoc — pro absolutní přesnost bys použil:

$paidAt = CarbonImmutable::createFromTimestampUTC($paymentIntent['created']);
$today = $paidAt->setTimezone('Europe/Prague')->toDateString();

Pro mě je rozdíl zanedbatelný (Stripe webhook latency je v jednotkách sekund a faktura nemá fixní čas dodání služby), ale pokud ty fakturuješ něco s časově citlivým plněním, použij druhou variantu.

Co nedělat (platí pro oba přístupy)

1. Neukládat DUZP jako timestamp. DUZP je date. Tečka. Když ho uložíš jako timestamp, dostaneš se do diskuzí o tom, "v jaké timezone je 00:00:00", a koleduješ si o off-by-one při serializaci.

2. Nespoléhat na implicitní app.timezone při účetních výpočtech. Pro účetní data vždy uveď timezone explicitně: CarbonImmutable::now('Europe/Prague'), ne CarbonImmutable::now(). Stejně tak ->setTimezone('Europe/Prague'), ne spoléhání na default. Default se může změnit; účetní logika ne.

3. Nemíchat oba přístupy v jedné DB (personal preference < consistency) Pokud jdeš pragmatickou cestou (matched timezone + naive timestamp), nepřidávej někam jeden timestamptz sloupec, protože to "vypadá robustnější". Konzistence v rámci jednoho schématu má větší hodnotu než lokální optimalizace.

4. Nezapomenout na background workery a CLI příkazy. Pokud máš Horizon worker nebo php artisan schedule:run, ujisti se, že používají stejný DB connection config jako webový request. Jinak ti session timezone v DB může lišit a kvarteální reporty ti začnou ukazovat data z jiných období.

Který přístup zvolit?

Kontext Doporučení
ČR-only SaaS, single-region Pragmatický (matched timezone, naive timestamp)
Multi-region nebo plánovaná expanze Defenzivní (timestamptz, DB v UTC)
Tým bez deep Postgres znalostí Defenzivní (méně překvapení, learning curve nesedí na timezone hacks)
Reporting přes externí BI nástroje Defenzivní (BI tooling defaultuje na UTC)
Nízká frekvence dat z jiných timezone Pragmatický (jednodušší queries, čitelnější psql)

Pro billify.cz jsem zvolil pragmatický. Je to ČR-only fakturační SaaS, jediný TZ-aware bod je Stripe webhook v captchaapi.eu, a WHERE created_at::date = CURRENT_DATE je hezky čitelné. Pokud bych v budoucnu billify rozšířil i mimo ČR, přepnu na timestamptz migrací — Postgres umí konvertovat in-place pomocí ALTER COLUMN ... TYPE timestamptz USING created_at AT TIME ZONE 'Europe/Prague'.

Shrnutí

┌─────────────────┐    ┌──────────────────────────┐    ┌────────────────────┐
  Stripe (UTC)        captchaapi.eu (Praha)        billify.cz API    
  Unix timestamp       ::now('Europe/Prague')        taxable_supply_at 
                       ->toDateString()              jako 'Y-m-d' date 
└─────────────────┘    └──────────────────────────┘    └────────────────────┘

Tři vrstvy, jasné role, jeden bod konverze mezi UTC a Prahou. Stripe ti dá UTC, captchaapi.eu vyrobí DUZP jako prostý string v Praze, billify ho uloží jako date. Žádná timezone arithmetic v účetní logice, žádné "v jaké timezone je tahle hodnota" pochybnosti při debugování — a finanční úřad je spokojený.

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

No results found