MOVGRANTO

date.today() w Django: faktura z datą poprzedniego miesiąca

date.today() czyta strefę systemu, nie TIME_ZONE z Django. Zamieniłem 104 wywołania na timezone.localdate() i dodałem test, który pilnuje, żeby nie wróciły.

Opublikowano: 5 min czytania

Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Faktura wystawiona o 01:00 w nocy: date.today() daje 30 kwietnia, timezone.localdate() daje 1 maja.
Spis treści

W BusiKM TIME_ZONE ustawiony jest na Europe/Warsaw, a mimo to część dat w dokumentach liczyła się w innej strefie. Winne było date.today(), które czyta strefę systemu operacyjnego i w ogóle nie patrzy na ustawienia Django. 7 października zamieniłem 104 takie wywołania w 32 plikach na timezone.localdate() i dodałem test, który nie pozwoli im wrócić.

Błąd, którego nie widać na własnym komputerze

Django z USE_TZ = True trzyma czas w UTC, a TIME_ZONE mówi, w jakiej strefie pokazywać i liczyć daty lokalne. timezone.localdate() korzysta z tego ustawienia. date.today() pochodzi ze standardowej biblioteki Pythona i bierze datę z zegara systemu, więc o TIME_ZONE nic nie wie.

Na moim komputerze w Warszawie obie funkcje zwracają to samo, więc błąd jest niewidoczny przy pisaniu kodu i w ręcznych testach. W kontenerze, który ma strefę UTC, daty różnią się przez kilka godzin w nocy: latem od północy do 2:00 czasu polskiego, zimą od północy do 1:00. W tym czasie w Polsce jest już następny dzień, a dla systemu wciąż poprzedni.

Oś czasu jednej nocy z 30 kwietnia na 1 maja. Między 0 a 2 czasu polskiego w Polsce jest już 1 maja, a w kontenerze z UTC wciąż 30 kwietnia, więc date.today() i timezone.localdate() zwracają różne daty

Kilka godzin w nocy brzmi niegroźnie, dopóki nie spojrzy się na to, co te daty znaczą. BusiKM wystawia faktury, liczy statusy dokumentów pojazdów i kierowców, decyduje, który miesiąc jest zamknięty do eksportu księgowego. W każdym z tych miejsc data to dzień kalendarzowy w Polsce, bo na takim operują przepisy.

Faktura w złym miesiącu

Najlepiej widać to przy fakturze za zlecenie. Data sprzedaży to element faktury z art. 106e ustawy o VAT, a termin płatności liczy się od niej w dniach kalendarzowych. Stary kod brał daty z dwóch źródeł i oba były złe:

today = date.today()  # system time zone
sale_dt = self.completed_at.date() if self.completed_at else today  # UTC date
due_dt = today + timedelta(days=int(self.payment_terms_days or 14))

date.today() czyta strefę systemu, a completed_at.date() bierze datę z wartości zapisanej w UTC. Zlecenie zamknięte 1 maja o 01:00 czasu polskiego dostawało datę sprzedaży 30 kwietnia, czyli trafiało do innego miesiąca rozliczeniowego. Poprawka liczy obie daty w czasie polskim:

# Sale date and payment due date are calendar days in Polish time,
# because that is what the VAT act works with.
today = timezone.localdate()
sale_dt = timezone.localdate(self.completed_at) if self.completed_at else today
due_dt = today + timedelta(days=int(self.payment_terms_days or 14))

Drugi wzorzec, datetime.date() na wartości z bazy, łatwo przeoczyć, bo nie zawiera słowa today. timezone.localdate(value) przelicza wartość do strefy z ustawień, zanim weźmie z niej datę.

Ten sam błąd siedział w statusie dokumentów firmy (ważny, wygasa w ciągu 30 dni, wygasł), w panelu zgodności, w generatorach PDF, w dacie wystawienia przy imporcie z KSeF, gdy odpowiedź jej nie zawierała, i w sprawdzaniu, czy miesiąc jest już zamknięty i można go wyeksportować do systemu księgowego. Najwięcej wywołań, 37, było w skrypcie danych demo, który nie dotyka dokumentów klienta. Zamieniłem je razem z resztą, żeby w kodzie nie zostało żadne date.today(), na które ktoś mógłby się kiedyś powołać.

Jak przetestować błąd, który pojawia się w nocy

Test, który sprawdza datę o 12:00 w Warszawie, przejdzie zarówno ze starym, jak i z nowym kodem. Żeby test w ogóle rozróżniał obie funkcje, musi trafić w okno, w którym daty się różnią. Można zamrozić zegar na 00:30 czasu polskiego, ale wtedy test zależy od tego, w jakiej strefie działa maszyna, na której się uruchamia.

Zamiast tego test przestawia strefę aplikacji na Pacific/Kiritimati, czyli UTC+14, najdalej wysuniętą strefę świata, i zamraża zegar na 12:00 UTC. Data lokalna to wtedy już następny dzień, niezależnie od strefy maszyny, na której działa CI:

PROBE_TZ = "Pacific/Kiritimati"  # UTC+14
FROZEN_UTC_NOON = "2026-04-26 12:00:00+00:00"
 
@freeze_time(FROZEN_UTC_NOON)
def test_local_date_is_a_day_ahead_of_utc(probe_timezone):
    # The probe must actually separate the two dates, otherwise it tests nothing.
    assert timezone.now().date() == date(2026, 4, 26)
    assert timezone.localdate() == date(2026, 4, 27)

Pierwszy test w pliku sprawdza samą sondę: jeśli kiedyś przestanie rozdzielać obie daty, kolejne testy faktur i okresów rozliczeniowych nie testowałyby niczego. Następne sprawdzają już konkretne miejsca, na przykład że data sprzedaży na fakturze równa się timezone.localdate(), a nie dacie z UTC.

Strażnik, który nie wpuści date.today() z powrotem

Sto wywołań nie pojawiło się przez jeden błąd. To nawyk: date.today() jest pierwszym, co przychodzi do głowy, także modelom AI, z którymi piszę większość kodu. Poprawienie wszystkich miejsc nie wystarczy, jeśli następne pojawi się za tydzień.

Dlatego w tym samym pliku jest test, który przechodzi po kodzie aplikacji i szuka gołego date.today() poza testami i migracjami:

def test_application_code_has_no_bare_date_today():
    apps_dir = Path(__file__).resolve().parents[2]
    bare_call = re.compile(r"(?<![\w.])date\.today\(\)")
    offenders = []
    for path in apps_dir.rglob("*.py"):
        if "tests" in path.parts or "migrations" in path.parts:
            continue
        for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
            if not line.lstrip().startswith("#") and bare_call.search(line):
                offenders.append(f"{path.relative_to(apps_dir.parent)}:{number}")
    assert offenders == [], "Use timezone.localdate(): " + ", ".join(offenders)

To prosty grep zapisany jako test, a nie reguła lintera. Wybrałem test, bo uruchamia się w tym samym miejscu co reszta testów, komunikat od razu mówi, czego użyć, a lista wyjątków (testy, migracje) jest widoczna obok reguły. Jego ograniczenie jest jasne: nie złapie datetime.now().date() ani completed_at.date(), czyli drugiego wzorca z faktury. Te miejsca pilnują testy z sondą UTC+14, ale tylko tam, gdzie je napisałem.

Co z tego zostaje

Dokumenty, które BusiKM generuje, są dokumentami księgowymi, więc data na nich to nie szczegół wyświetlania. Ewidencja przebiegu musi być przechowywana pięć lat, faktura z datą sprzedaży w złym miesiącu to korekta. Błąd strefy czasowej w takim systemie jest błędem merytorycznym, nawet jeśli dotyczy dwóch godzin w nocy.

Najbardziej przydatna okazała się sonda UTC+14. Zamienia błąd, który pojawia się tylko w nocy i tylko w kontenerze, w taki, który widać w każdym przebiegu testów na każdej maszynie. Jeśli też liczysz w Django daty dokumentów i chcesz porozmawiać o tym, jak to sprawdzać, napisz do mnie.

Łukasz Głowacz

O autorze

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer w MOVGRANTO. Buduje systemy od architektury po produkcję: backend, web, mobile, płatności i CI/CD. Autor BusiKM, SaaS do zarządzania flotą busów.

LinkedInGitHub

BusiKM

Zarządzanie flotą busów 2,5-3,5 t: zlecenia, mapa floty, czas pracy kierowców i koszty w jednym miejscu. 14 dni za darmo.

Sprawdź BusiKM ↗

Budujesz podobny system?

Dołączamy do zespołów jako inżynierowie full-stack albo budujemy produkt od zera. Porozmawiajmy o Twoim projekcie.

Umów rozmowę →

Newsletter

Co tydzień jedna prawdziwa decyzja architektoniczna z budowy produkcyjnego SaaS.