Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ bundles through `document-generation`. Read `README.md` for the domain.
- Cross-DB refs (`id_sharing_operation`, `id_member`, `ean`) are plain columns, never FKs.
- Errors: `ErrorException(errors.admin.X, status_code=...)`; every key needs all
four locales (`tests/test_locales.py` enforces it). Generation block is 2360-2365.
- CRM `address.number` is an **INTEGER**. Coerce at the port boundary.
- CRM `address.number` is a **VARCHAR(32)** since 2026-08-30 (`12A` is a real
Belgian house number). The port still coerces via `_as_optional_str`, which is
now a no-op — keep it, so the port survives either column type.

## Gotchas
- **`api/administrative_document/routes.py` must NOT `from __future__ import
Expand Down
5 changes: 3 additions & 2 deletions ports/crm_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
class PostalAddress:
"""A CRM address, normalised to strings.

``number`` is an INTEGER column in the CRM; it is converted at the adapter
boundary so downstream string formatting never has to care.
``number`` is a VARCHAR(32) in the CRM as of 2026-08-30 — a Belgian house
number is ``12A``, not 12. It is still normalised at the adapter boundary, so
this type is correct against either column type.
"""

street: str | None = None
Expand Down
12 changes: 7 additions & 5 deletions ports/crm_core_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,10 @@
def _as_optional_str(value: object) -> str | None:
"""Normalise a CRM column to a string.

``address.number`` is an INTEGER in the CRM, so a naive join into an address
line raises TypeError. Convert once, here.
``address.number`` was an INTEGER in the CRM until 2026-08-30, when it became
a VARCHAR(32); a naive join into an address line raised TypeError. The
conversion is now usually a no-op, and is kept deliberately: it is what lets
this adapter run against a CRM on either side of that migration.
"""
if value is None:
return None
Expand All @@ -141,9 +143,9 @@ def _address(row: dict[str, Any], prefix: str) -> PostalAddress | None:
values = {field: row.get(column) for field, column in columns.items()}
if not any(value is not None for value in values.values()):
return None
# address.number is an INTEGER column in the CRM — coerce at the boundary so
# no downstream formatter has to care. Getting this wrong silently broke
# billing's whole issue pipeline once.
# Coerce at the boundary so no downstream formatter has to care. Getting this
# wrong silently broke billing's whole issue pipeline once — which is why the
# coercion stays even though address.number is text since 2026-08-30.
return PostalAddress(**{field: _as_optional_str(value) for field, value in values.items()})


Expand Down
2 changes: 1 addition & 1 deletion tests/api/administrative_document/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def register_bundle(db_session, *, doc_type="annex6_notification", uri=_BU


async def seed_participants(db_session, community, sharing_operation) -> None:
home = await f.create_address(db_session, street="Rue Basse", number=3)
home = await f.create_address(db_session, street="Rue Basse", number="3")
alice = await f.create_member(
db_session,
id_community=community.id,
Expand Down
2 changes: 1 addition & 1 deletion tests/factories/crm_participant_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async def create_address(
session: AsyncSession,
*,
street: str = "Rue Haute",
number: int = 12,
number: str = "12",
postcode: str = "5000",
city: str = "Namur",
supplement: str | None = None,
Expand Down
10 changes: 6 additions & 4 deletions tests/sql/crm_test_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
-- against a single Postgres instance, so we mirror the minimum CRM DDL the
-- suite needs here.
--
-- Keep column types identical to production. In particular `address.number` is
-- an INTEGER in the real CRM — mirroring it as VARCHAR here would hide the
-- str/int bug class at the port boundary.
-- Keep column types identical to production. `address.number` is a VARCHAR(32)
-- in the real CRM as of 2026-08-30 (a Belgian house number is `12A`, not 12);
-- the port still coerces with `_as_optional_str`, which is now a no-op rather
-- than a conversion, and is kept so the port survives either column type.

-- ---- community -------------------------------------------------------------
-- Mirrors core/database/models.py::Community plus the legal/regulatory columns
Expand Down Expand Up @@ -51,10 +52,11 @@ CREATE INDEX IF NOT EXISTS idx_community_subscription_id_community
CREATE TABLE IF NOT EXISTS address (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
street VARCHAR(255),
number INTEGER, -- matches the real CRM: house number is an integer column
number VARCHAR(32), -- matches the real CRM: text, because 12A is a real house number
postcode VARCHAR(16),
supplement VARCHAR(255),
city VARCHAR(255),
country CHAR(2) NOT NULL DEFAULT 'BE',
id_community INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
Expand Down
10 changes: 6 additions & 4 deletions tests/test_crm_participants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


async def _alice_with_two_meters(db_session, community, sharing_operation) -> int:
home = await f.create_address(db_session, street="Rue Basse", number=3)
home = await f.create_address(db_session, street="Rue Basse", number="3")
id_member = await f.create_member(
db_session,
id_community=community.id,
Expand All @@ -28,7 +28,7 @@ async def _alice_with_two_meters(db_session, community, sharing_operation) -> in
email="alice@example.be",
id_home_address=home,
)
site = await f.create_address(db_session, street="Rue du Site", number=7, city="Jambes")
site = await f.create_address(db_session, street="Rue du Site", number="7", city="Jambes")
for ean, address in (("541448000000000001", site), ("541448000000000002", None)):
await f.create_meter(
db_session,
Expand Down Expand Up @@ -71,8 +71,10 @@ async def test_a_member_is_returned_once_with_all_their_meters(
async def test_the_house_number_arrives_as_a_string(
self, db_session, community, sharing_operation
):
"""address.number is INTEGER in the CRM. This exact coercion bug once
broke billing's whole issue pipeline."""
"""The port owes a string whatever the column is. `address.number` was an
INTEGER until 2026-08-30 and is a VARCHAR(32) now; this exact coercion bug
once broke billing's whole issue pipeline, so the guarantee is asserted
rather than assumed."""
await _alice_with_two_meters(db_session, community, sharing_operation)

result = await SqlAlchemyCrmCoreRead(db_session).get_operation_participants(
Expand Down
Loading