Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
13 changes: 10 additions & 3 deletions lambda_erp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
48 changes: 48 additions & 0 deletions tests/test_utils_now.py
Original file line number Diff line number Diff line change
@@ -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)