From 640ea5748684404b44f4a1bd03fcbbe90a90b7b4 Mon Sep 17 00:00:00 2001 From: EricPaque <60603143+Radisio@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:30:15 +0200 Subject: [PATCH] Migrate number to varchar and add country --- api/billing/mappers.py | 5 +++-- ports/crm_core_sqlalchemy.py | 7 ++++--- tests/billing/test_crm_core_read.py | 4 ++-- tests/billing/test_mappers.py | 7 ++++--- tests/factories/crm_billing_factory.py | 2 +- tests/sql/crm_test_schema.sql | 3 ++- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/api/billing/mappers.py b/api/billing/mappers.py index a006a00..a93bfb5 100644 --- a/api/billing/mappers.py +++ b/api/billing/mappers.py @@ -93,8 +93,9 @@ def invoice_to_out( def _format_address(obj: CommunityIdentity | ParticipantContact | None) -> str | None: if obj is None: return None - # Coerce each part to str: address components (e.g. a house number) may arrive - # as ints from the CRM, and a TypeError here would dead-letter the whole issue. + # Coerce each part to str. The CRM house number became text on 2026-08-30, but + # this formatter is fed from callers this module does not control, and a + # TypeError here would dead-letter the whole issue. line1 = " ".join(str(part) for part in (obj.street, obj.number) if part) line2 = " ".join(str(part) for part in (obj.postcode, obj.city) if part) joined = ", ".join(str(part) for part in (line1, line2, obj.supplement) if part) diff --git a/ports/crm_core_sqlalchemy.py b/ports/crm_core_sqlalchemy.py index 397556b..bca4de5 100644 --- a/ports/crm_core_sqlalchemy.py +++ b/ports/crm_core_sqlalchemy.py @@ -218,8 +218,9 @@ async def get_community_identity(self, *, id_community: int) -> CommunityIdentit if row is None: return None data = dict(row) - # CRM `address.number` is an integer column; the DTO (and the docgen - # address formatter) treat it as a string. Coerce to honour `str | None`. + # CRM `address.number` is text since 2026-08-30, so this is normally a + # no-op — kept because it is what lets this adapter read a CRM on either + # side of that migration, and the DTO owes `str | None` regardless. if data.get("number") is not None: data["number"] = str(data["number"]) return CommunityIdentity(**data) @@ -264,7 +265,7 @@ async def participant_contacts( vat_number=row["company_vat"], social_rate=bool(row["social_rate"]) if row["social_rate"] is not None else False, street=row["street"], - # CRM `address.number` is an integer column; DTO expects `str | None`. + # Text since 2026-08-30; kept for the same reason as above. number=str(row["number"]) if row["number"] is not None else None, postcode=row["postcode"], city=row["city"], diff --git a/tests/billing/test_crm_core_read.py b/tests/billing/test_crm_core_read.py index 88f57f9..98f5839 100644 --- a/tests/billing/test_crm_core_read.py +++ b/tests/billing/test_crm_core_read.py @@ -493,7 +493,7 @@ async def test_get_community_identity(db_session): db_session, iban="BE68539007547034", legal_name="ACME ASBL", account_holder_name=None ) addr = await f.create_address( - db_session, id_community=cid, street="Rue de la Loi", number=16, city="Bruxelles" + db_session, id_community=cid, street="Rue de la Loi", number="16", city="Bruxelles" ) await db_session.execute( text("UPDATE community SET headquarters_address_id = :a WHERE id = :c"), @@ -524,7 +524,7 @@ async def test_community_identity_missing_iban_is_incomplete(db_session): async def test_participant_contacts_individual_and_company(db_session): cid = await f.create_community(db_session) billing_addr = await f.create_address( - db_session, id_community=cid, street="Chaussée de Liège", number=5, city="Namur" + db_session, id_community=cid, street="Chaussée de Liège", number="5", city="Namur" ) m_ind = await f.create_member( db_session, diff --git a/tests/billing/test_mappers.py b/tests/billing/test_mappers.py index 654d09c..ffc6f43 100644 --- a/tests/billing/test_mappers.py +++ b/tests/billing/test_mappers.py @@ -27,9 +27,10 @@ def test_format_address_normal() -> None: def test_format_address_tolerates_int_number() -> None: - # Defensive guard: the CRM `address.number` column is an integer, so an int can - # reach the formatter. It must not raise a TypeError — that used to dead-letter - # the whole invoice-issue message and block PDF generation entirely. + # Defensive guard. `address.number` became a VARCHAR(32) on 2026-08-30, so an + # int no longer arrives from the CRM by that route — but the formatter is also + # fed from callers this module does not control, and a TypeError here used to + # dead-letter the whole invoice-issue message and block PDF generation. assert _format_address(_identity(number=16)) == "Rue de la Loi 16, 1000 Bruxelles" diff --git a/tests/factories/crm_billing_factory.py b/tests/factories/crm_billing_factory.py index 7357bd8..93bcf01 100644 --- a/tests/factories/crm_billing_factory.py +++ b/tests/factories/crm_billing_factory.py @@ -74,7 +74,7 @@ async def create_address( *, id_community: int, street: str = "Rue de la Loi", - number: int = 16, # integer column in the real CRM (see crm_test_schema.sql) + number: str = "16", # text column in the real CRM: 12A is a real house number postcode: str = "1000", city: str = "Bruxelles", supplement: str | None = None, diff --git a/tests/sql/crm_test_schema.sql b/tests/sql/crm_test_schema.sql index 10b7604..40cd5b7 100644 --- a/tests/sql/crm_test_schema.sql +++ b/tests/sql/crm_test_schema.sql @@ -37,10 +37,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