From b96b1f08dfda480add2e126e2c21d2b8a8589998 Mon Sep 17 00:00:00 2001 From: Jonathan <523261+jcfrei@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:06:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(utils):=20now()=20returns=20timezone-aware?= =?UTF-8?q?=20UTC=20=E2=80=94=20timestamps=20read=20as=20one=20instant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit now() returned a naive isoformat() (no zone). A client parsing it (JS `new Date(...)`) reads a zone-less datetime as browser-local, so a timestamp stamped on a UTC server rendered shifted by the viewer's UTC offset — a note set server-side showed ~2h behind in Zürich, while a record whose timestamp the frontend set via toISOString() (with Z) showed correctly. The two producers disagreed on the encoding of the same instant. now() now returns datetime.now(timezone.utc).isoformat() (…+00:00), so every stored creation/modified/occurred_at is an unambiguous instant. nowdate() is unchanged (date-only fields stay zone-free). No core code parses these datetime strings back for comparison (only date-only fields via date.fromisoformat), and auth already uses aware UTC for JWT expiry — so nothing mixes naive/aware. Existing rows keep their naive value; only new writes carry the zone. tests/test_utils_now.py guards the format (aware UTC datetime, date-only date); wired into CI as a backend-agnostic step. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 3 +++ CHANGELOG.md | 15 ++++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- lambda_erp/utils.py | 13 ++++++++--- pyproject.toml | 2 +- tests/test_utils_now.py | 48 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 tests/test_utils_now.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1e1f06..1d938fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - name: Install (with the postgres extra) run: pip install -e ".[postgres]" + - name: Utils — now() timezone-aware UTC (backend-agnostic) + run: python -m tests.test_utils_now + - name: Validation suite — SQLite (in-memory) run: python -m tests.test_erp_validation diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a8600..298b23a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ semver-governed public surface — a breaking change to a seam is a major bump. ## [Unreleased] +## [0.6.7] - 2026-07-29 + +### Fixed +- **Timestamps are now timezone-aware UTC (`now()`).** `now()` returned a naive + ISO string (no zone), so a client parsing it (`new Date(...)`) read it as + *browser-local* and rendered a UTC-server timestamp shifted by the viewer's + offset — e.g. a note stamped server-side showed ~2h behind in Zürich, while a + record whose timestamp the frontend set via `toISOString()` (with `Z`) showed + correctly. `now()` now returns `datetime.now(timezone.utc).isoformat()` + (`…+00:00`), making every stored `creation`/`modified`/`occurred_at` an + unambiguous instant that round-trips correctly. `nowdate()` is unchanged + (date-only fields stay zone-free). Existing rows keep their stored (naive) + value — only newly written timestamps carry the zone. `tests/test_utils_now.py` + guards the format. + ## [0.6.6] - 2026-07-29 ### Added diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 043bb18..dc6d0d4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.6", + "version": "0.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lambda-development/erp-core", - "version": "0.6.6", + "version": "0.6.7", "license": "Apache-2.0", "dependencies": { "@fontsource/inter": "^5.2.8" diff --git a/frontend/package.json b/frontend/package.json index a8a3f7c..350cc13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.6", + "version": "0.6.7", "description": "Frontend core of Lambda ERP — app shell, document/master pages, chat UI, reports, and extension registries.", "license": "Apache-2.0", "repository": { diff --git a/lambda_erp/utils.py b/lambda_erp/utils.py index 6f1dcaa..d30c74e 100644 --- a/lambda_erp/utils.py +++ b/lambda_erp/utils.py @@ -6,7 +6,7 @@ """ import math -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from decimal import Decimal, ROUND_HALF_UP def flt(value, precision=None): @@ -54,8 +54,15 @@ def nowdate(): return date.today().isoformat() def now(): - """Return current datetime as string.""" - return datetime.now().isoformat() + """Return the current instant as a timezone-aware UTC ISO string + (e.g. "2026-07-29T09:57:00.123456+00:00"). + + The offset is deliberate: a naive isoformat() (no zone) is ambiguous, and a + client parsing it (JS `new Date(...)`) reads it as *browser-local*, so a + timestamp stamped on a UTC server renders shifted by the viewer's UTC + offset. Stamping the zone makes every stored timestamp (creation, modified, + occurred_at, …) an unambiguous instant that round-trips correctly.""" + return datetime.now(timezone.utc).isoformat() def add_days(dt, days): """Add days to a date.""" diff --git a/pyproject.toml b/pyproject.toml index 0437f87..61ed186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lambda-erp" -version = "0.6.6" +version = "0.6.7" description = "Core ERP logic - accounting, sales, purchasing, inventory" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_utils_now.py b/tests/test_utils_now.py new file mode 100644 index 0000000..8a7027d --- /dev/null +++ b/tests/test_utils_now.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Tests for lambda_erp.utils.now() — timestamps must be unambiguous instants. + +now() stamps every document's creation/modified (and plugin timestamps like the +CRM's occurred_at). It returns a timezone-aware UTC ISO string so a client +parsing it (JS `new Date(...)`) reads the correct instant instead of treating a +naive value as browser-local and rendering it shifted by the viewer's offset. + +Run: python -m tests.test_utils_now +""" +import sys +from datetime import datetime, timezone + + +def check_now(): + from lambda_erp.utils import now, nowdate + + s = now() + # Round-trips to an aware datetime (has a tzinfo/offset), and that offset is + # UTC — the whole point is an unambiguous instant, not a naive local string. + dt = datetime.fromisoformat(s) + assert dt.tzinfo is not None, f"now() must be timezone-aware, got naive: {s!r}" + assert dt.utcoffset() == timezone.utc.utcoffset(None), f"now() must be UTC, got {s!r}" + # The string itself carries the zone designator (what JS keys off of). + assert s.endswith("+00:00") or s.endswith("Z"), f"now() missing UTC designator: {s!r}" + + # nowdate() is a pure calendar date (no time, no zone) and stays that way — + # date-only fields (posting_date, due_date) must not grow a timezone. + d = nowdate() + assert "T" not in d and "+" not in d, f"nowdate() must be date-only, got {d!r}" + from datetime import date as _date + _date.fromisoformat(d) # parses as a plain date + + print(" [utils.now] timezone-aware UTC timestamp + date-only nowdate OK") + + +def main(): + print("utils.now() checks") + check_now() + print("All utils.now() checks passed.") + + +if __name__ == "__main__": + try: + main() + except AssertionError as e: + print(f"\nFAILED: {e}") + sys.exit(1)