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 1735687800 → 2025-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 timezonetimestamp(=timestamp without time zone) — naivní, žádná konverze, uloží přesně to, co dostane
Z toho plynou dva architektonické styly:
- Defenzivní (UTC v DB):
timestamptz+app.timezone = Europe/Prague+ DB session timezone =UTC. Postgres mezi tím překládá automaticky. - 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
psqlz jiného countries ukáže UTC, což je předvídatelné
Nevýhody:
NOW(),CURRENT_DATE,DATE_TRUNCv SQL vrací UTC hodnoty — pro business queries ("faktury vystavené dnes v Praze") musíš všude psátAT TIME ZONE 'Europe/Prague'- Při debugování v
psqlvidíš 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) timestamptz→timestampcast (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_DATEfunguje intuitivně bezAT TIME ZONE- V
psqlvidíš 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, aleNOW()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ý.