From 18e9a3e9b92a1d4ac3e62d52d339baf8710ecda0 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Thu, 8 Oct 2026 21:26:54 -0600 Subject: [PATCH] feat(i18n): fechas e importes del servidor segun el idioma Decision de la tarea 10, pendiente desde que se hizo el PDF de factura: tabla propia y no `babel`. `core/i18n/format.py` da `format_number`, `format_money` y `format_date` con las mismas reglas por defecto que el frontend (`es` -> `es-MX`, `en` -> `en-US`) mas `es-ES` y `en-GB`. Una region sin reglas propias usa la de su idioma, como el catalogo cae de `es-MX` a `es`. - PDF de factura: la fecha y el importe siguen al idioma (en ingles `08/03/2026`). - Correos: los seis importes `f"{currency} {total:,.2f}"` pasan por `format_money`. Las fechas de los correos siguen en ISO, que no depende del idioma, y por eso no se tocan (cambiarlas movia el golden en espanol). - En espanol y en ingles de EE. UU. los importes salen igual que antes; el cambio visible es la fecha del PDF en ingles y las regiones con reglas propias. Tests nuevos: `test_i18n_format.py` (reglas) y uno de correo; el del PDF pasa de "el formato no cambia" a "sigue al idioma". Recuentos documentados 932 -> 957. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 +- backend/app/core/email.py | 13 +-- backend/app/core/i18n/catalogue.py | 4 +- backend/app/core/i18n/format.py | 88 +++++++++++++++++++ backend/app/modules/accounting/pdf.py | 27 +++--- backend/tests/test_email_templates.py | 15 +++- backend/tests/test_i18n_format.py | 82 +++++++++++++++++ backend/tests/test_invoice_pdf_locale.py | 22 +++-- docs/CAPABILITY_MATURITY_SCORECARD.md | 2 +- docs/IMPLEMENTATION_ALIGNMENT.md | 6 +- .../superpowers/plans/2026-08-07-i18n-full.md | 10 ++- 11 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 backend/app/core/i18n/format.py create mode 100644 backend/tests/test_i18n_format.py diff --git a/README.md b/README.md index 527aa08..5b69db9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ El sistema está construido como un **monolito modular** con límites de dominio Discovery → Lead → Opportunity → Quote → Order → Inventory → Portal → Invoice → Payment ``` -**932 tests** (contrato + integración + e2e) en 48 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). +**957 tests** (contrato + integración + e2e) en 49 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). --- @@ -106,7 +106,7 @@ cbos-platform/ │ │ │ ├── accounting/ Facturación, pagos, overdue scanner │ │ │ └── analytics/ Agregación cross-módulo │ │ └── main.py Lifespan, routers, CORS, background tasks -│ ├── tests/ 932 tests (pytest-asyncio) +│ ├── tests/ 957 tests (pytest-asyncio) │ ├── alembic/ Migraciones de base de datos │ └── requirements.txt ├── composable-os/ React 18 + Vite frontend @@ -232,7 +232,7 @@ docker compose exec backend pytest --tb=short -q cd backend && pytest --tb=short -q ``` -**932 tests** en 48 archivos. Las categorías no se solapan: cada archivo cuenta +**957 tests** en 49 archivos. Las categorías no se solapan: cada archivo cuenta una sola vez y las filas suman 41. | Categoría | Archivos | Cobertura | diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 31b2037..735edf2 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -13,6 +13,7 @@ from app.core.config import settings from app.core.i18n import DEFAULT_LOCALE from app.core.i18n.catalogue import translate +from app.core.i18n.format import format_money logger = logging.getLogger(__name__) @@ -115,7 +116,7 @@ def quote_portal_email( t = _scope("quotePortal", locale) greeting = t("greeting", name=contact_name) if contact_name else t("greetingAnonymous") valid_str = str(valid_until) if valid_until else t("noExpiry") - amount = f"{currency} {total:,.2f}" + amount = format_money(total, currency, locale) subject = t("subject", number=quote_number, workspace=workspace_name) @@ -189,7 +190,7 @@ def quote_accepted_email( ) -> tuple[str, str, str]: """Email notification when a quote is accepted by the customer.""" t = _scope("quoteAccepted", locale) - amount = f"{currency} {total:,.2f}" + amount = format_money(total, currency, locale) subject = t("subject", number=quote_number) text = t("text", number=quote_number, amount=amount, order=order_number) title = t("title") @@ -214,7 +215,7 @@ def sales_order_created_email( ) -> tuple[str, str, str]: """Email notification when a new sales order is created.""" t = _scope("salesOrderCreated", locale) - amount = f"{currency} {total:,.2f}" + amount = format_money(total, currency, locale) subject = t("subject", order=order_number) text = t("text", order=order_number, amount=amount) title = t("title") @@ -261,8 +262,8 @@ def invoice_overdue_email( ) -> tuple[str, str, str]: """Email alert when an invoice becomes overdue.""" t = _scope("invoiceOverdue", locale) - amount = f"{currency} {total:,.2f}" - pending = f"{currency} {amount_due:,.2f}" + amount = format_money(total, currency, locale) + pending = format_money(amount_due, currency, locale) subject = t("subject", number=invoice_number) text = t("text", number=invoice_number, amount=amount, pending=pending, dueDate=due_date) title = t("title") @@ -319,7 +320,7 @@ def seller_accept_email( t = _scope("sellerAccept", locale) _client = _html.escape(client_name) _workspace = _html.escape(workspace_name) - amount = f"{currency} {total:,.2f}" + amount = format_money(total, currency, locale) subject = t("subject", client=client_name, number=quote_number) text = t( "text", diff --git a/backend/app/core/i18n/catalogue.py b/backend/app/core/i18n/catalogue.py index a566922..6858d6d 100644 --- a/backend/app/core/i18n/catalogue.py +++ b/backend/app/core/i18n/catalogue.py @@ -30,8 +30,8 @@ `invoice_pdf:typo` en un documento que se le entrega a un cliente. No hay plurales: ningun texto de estos artefactos cuenta cosas. Cuando uno lo -haga, es la hora de decidir entre reglas CLDR (`babel`) y una tabla propia, junto -con el formato de fechas e importes, que tampoco sigue al idioma todavia. +haga, es la hora de ampliar `core/i18n/format.py` (que ya da formato a fechas e +importes con una tabla propia) con reglas de plural, o de pasar a CLDR (`babel`). """ from __future__ import annotations diff --git a/backend/app/core/i18n/format.py b/backend/app/core/i18n/format.py new file mode 100644 index 0000000..712800b --- /dev/null +++ b/backend/app/core/i18n/format.py @@ -0,0 +1,88 @@ +"""Formato de fechas e importes de lo que el servidor dibuja (tarea 10 del plan de i18n). + +El frontend deja esto a `Intl` (`composable-os/src/i18n/format.ts`). El servidor no +lo tiene: un PDF o un correo se arman con texto y no con un runtime del navegador. En +vez de anadir `babel` por dos idiomas, una tabla propia con las mismas reglas por +defecto que el frontend (`REGION_DEFAULTS`: `es` -> `es-MX`, `en` -> `en-US`): + + format_number(1160, "es-MX") -> "1,160.00" + format_number(1160, "es-ES") -> "1160,00" # el espanol de Espana no agrupa 4 cifras + format_date(date(2026, 8, 3), "en") -> "08/03/2026" + format_date(date(2026, 8, 3), "es") -> "03/08/2026" + +Una region que no esta en la tabla (`es-AR`, `en-AU`) usa la regla por defecto de su +idioma, igual que el catalogo cae de `es-MX` a `es`. Una region con reglas propias se +anade a `_RULES`. Si el dia de manana el producto atiende muchas regiones, ese es el +momento de cambiar la tabla por `babel`; el contrato de estas funciones no cambia. + +Una fecha ISO (`"2026-09-01"`) que llega como texto —los eventos la traen asi— se +interpreta y se formatea; un texto que no es una fecha sale tal cual, porque un +documento con un dato raro es mejor que un documento que no se genera. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime + +from app.core.i18n import DEFAULT_LOCALE + + +@dataclass(frozen=True) +class _Rule: + group: str + decimal: str + date_pattern: str + # Cifras enteras minimas para agrupar (CLDR `minimumGroupingDigits`): en espanol + # de Espana `1234,50` no lleva separador y `12.345,50` si. + min_grouping: int = 1 + + +_RULES: dict[str, _Rule] = { + "es-MX": _Rule(group=",", decimal=".", date_pattern="%d/%m/%Y"), + "es-ES": _Rule(group=".", decimal=",", date_pattern="%d/%m/%Y", min_grouping=2), + "en-US": _Rule(group=",", decimal=".", date_pattern="%m/%d/%Y"), + "en-GB": _Rule(group=",", decimal=".", date_pattern="%d/%m/%Y"), +} + +# La region que se asume cuando solo hay idioma. La misma tabla que el frontend. +_LANGUAGE_DEFAULTS: dict[str, str] = {"es": "es-MX", "en": "en-US"} + + +def _rule_for(locale: str | None) -> _Rule: + tag = (locale or DEFAULT_LOCALE).replace("_", "-") + parts = tag.split("-") + language = parts[0].lower() + if len(parts) > 1: + full = f"{language}-{parts[1].upper()}" + if full in _RULES: + return _RULES[full] + default = _LANGUAGE_DEFAULTS.get(language, _LANGUAGE_DEFAULTS[DEFAULT_LOCALE]) + return _RULES[default] + + +def format_number(value: float, locale: str | None = DEFAULT_LOCALE, *, decimals: int = 2) -> str: + """`1160` -> `1,160.00` en `es`/`en`, con los separadores de la region.""" + rule = _rule_for(locale) + text = f"{value:,.{decimals}f}" + integer_digits = len(text.split(".")[0].lstrip("-").replace(",", "")) + if integer_digits < rule.min_grouping + 3: + text = text.replace(",", "") + # Dos pasos: cambiar `,` por `.` y `.` por `,` en uno solo se pisaria. + return text.replace(",", "\0").replace(".", rule.decimal).replace("\0", rule.group) + + +def format_money( + value: float, currency: str, locale: str | None = DEFAULT_LOCALE, *, decimals: int = 2 +) -> str: + """`USD 1,160.00`: el codigo de moneda, un espacio y el importe segun el idioma.""" + return f"{currency} {format_number(value, locale, decimals=decimals)}" + + +def format_date(value: date | datetime | str, locale: str | None = DEFAULT_LOCALE) -> str: + """La fecha en el orden y el separador del idioma (`03/08/2026` o `08/03/2026`).""" + if isinstance(value, str): + try: + value = date.fromisoformat(value[:10]) + except ValueError: + return value + return value.strftime(_rule_for(locale).date_pattern) diff --git a/backend/app/modules/accounting/pdf.py b/backend/app/modules/accounting/pdf.py index 9a48eb0..a30f729 100644 --- a/backend/app/modules/accounting/pdf.py +++ b/backend/app/modules/accounting/pdf.py @@ -15,6 +15,7 @@ from app.core.i18n import DEFAULT_LOCALE from app.core.i18n.catalogue import exists, translate +from app.core.i18n.format import format_date, format_number from app.modules.accounting.fonts import register_unicode_font from app.modules.accounting.models import CompanyProfile, Invoice @@ -48,16 +49,16 @@ # ── Helpers ─────────────────────────────────────────────────────────────────── -def _fmt_currency(amount: float, currency: str = "USD") -> str: +def _fmt_currency(amount: float, currency: str = "USD", locale: str = DEFAULT_LOCALE) -> str: # Only ASCII/latin-1 safe symbols — fpdf2 built-in fonts use latin-1 symbol = {"USD": "$", "MXN": "$"}.get(currency, currency + " ") - return f"{symbol}{amount:,.2f}" + return f"{symbol}{format_number(amount, locale)}" -def _fmt_date(d: date | None) -> str: +def _fmt_date(d: date | None, locale: str = DEFAULT_LOCALE) -> str: if d is None: return "-" - return d.strftime("%d/%m/%Y") + return format_date(d, locale) def _status_label(status: str, locale: str) -> str: @@ -275,7 +276,7 @@ def generate_invoice_pdf( translate("invoice_pdf:meta.dueDate", locale), translate("invoice_pdf:meta.currency", locale), ] - values = [_fmt_date(invoice.issue_date), _fmt_date(invoice.due_date), invoice.currency] + values = [_fmt_date(invoice.issue_date, locale), _fmt_date(invoice.due_date, locale), invoice.currency] for i, (lbl, val) in enumerate(zip(labels, values)): x = 15 + i * col_w @@ -335,9 +336,9 @@ def generate_invoice_pdf( desc = line.description[:55] + "..." if len(line.description) > 55 else line.description pdf.cell(desc_w, 7, desc, align="L") pdf.cell(qty_w, 7, f"{line.quantity:g}", align="C") - pdf.cell(price_w, 7, _fmt_currency(line.unit_price, invoice.currency), align="R") + pdf.cell(price_w, 7, _fmt_currency(line.unit_price, invoice.currency, locale), align="R") pdf.cell(disc_w, 7, f"{line.discount_pct:.0f}%" if line.discount_pct else "-", align="R") - pdf.cell(sub_w, 7, _fmt_currency(line.subtotal, invoice.currency), align="R") + pdf.cell(sub_w, 7, _fmt_currency(line.subtotal, invoice.currency, locale), align="R") pdf.ln(8) # Divider after rows @@ -361,16 +362,16 @@ def _total_row(label: str, value: str, bold: bool = False, color=_DARK): pdf.cell(value_w, 6, value, align="R") pdf.ln(6) - _total_row(translate("invoice_pdf:totals.subtotal", locale), _fmt_currency(invoice.subtotal, invoice.currency)) + _total_row(translate("invoice_pdf:totals.subtotal", locale), _fmt_currency(invoice.subtotal, invoice.currency, locale)) if invoice.discount_amount > 0: _total_row( translate("invoice_pdf:totals.discount", locale), - f"- {_fmt_currency(invoice.discount_amount, invoice.currency)}", + f"- {_fmt_currency(invoice.discount_amount, invoice.currency, locale)}", ) if invoice.tax_rate > 0: _total_row( translate("invoice_pdf:totals.tax", locale, rate=f"{invoice.tax_rate:.0f}"), - _fmt_currency(invoice.tax_amount, invoice.currency), + _fmt_currency(invoice.tax_amount, invoice.currency, locale), ) # Total line with background @@ -381,15 +382,15 @@ def _total_row(label: str, value: str, bold: bool = False, color=_DARK): pdf.set_text_color(255, 255, 255) pdf.set_xy(totals_x, total_row_y) pdf.cell(label_w, 8, translate("invoice_pdf:totals.total", locale), align="L") - pdf.cell(value_w, 8, _fmt_currency(invoice.total, invoice.currency), align="R") + pdf.cell(value_w, 8, _fmt_currency(invoice.total, invoice.currency, locale), align="R") pdf.ln(9) if invoice.amount_paid > 0: - _total_row(translate("invoice_pdf:totals.paid", locale), _fmt_currency(invoice.amount_paid, invoice.currency)) + _total_row(translate("invoice_pdf:totals.paid", locale), _fmt_currency(invoice.amount_paid, invoice.currency, locale)) overdue = invoice.status == "overdue" _total_row( translate("invoice_pdf:totals.balance", locale), - _fmt_currency(invoice.amount_due, invoice.currency), + _fmt_currency(invoice.amount_due, invoice.currency, locale), bold=True, color=(239, 68, 68) if overdue else _DARK, ) diff --git a/backend/tests/test_email_templates.py b/backend/tests/test_email_templates.py index 08c5bb2..f3c420a 100644 --- a/backend/tests/test_email_templates.py +++ b/backend/tests/test_email_templates.py @@ -13,8 +13,8 @@ intactos. Los datos de prueba tambien van en mayusculas, asi que *cualquier* palabra en minusculas que sobreviva en el correo es texto cableado. -Lo que NO cubre: el formato de importes (`USD 1,234.50`) sigue sin seguir al idioma, como -en el PDF (ver el plan, tarea 10). +El importe sigue a la region del idioma (`core/i18n/format.py`); las fechas de los correos +salen en ISO (`2026-09-01`), que no depende del idioma, y por eso no se tocan. """ import json import re @@ -190,3 +190,14 @@ async def test_a_message_missing_in_a_language_falls_back_to_spanish(monkeypatch monkeypatch.setattr(catalogue, "load_catalogues", lambda: {**real, "en": {"email": {}}}) assert render(CASES["quote_accepted"], locale="en") == GOLDEN["quote_accepted"] + + +async def test_the_amount_follows_the_region_of_the_locale(): + def amounts(locale): + _, text, html = templates.quote_accepted_email("Ana", "Q-1", 12345.5, "USD", "SO-1", locale=locale) + return text, html + + for locale, shown in (("es", "USD 12,345.50"), ("en-US", "USD 12,345.50"), ("es-ES", "USD 12.345,50")): + text, html = amounts(locale) + assert shown in text, locale + assert shown in html, locale diff --git a/backend/tests/test_i18n_format.py b/backend/tests/test_i18n_format.py new file mode 100644 index 0000000..6262bd4 --- /dev/null +++ b/backend/tests/test_i18n_format.py @@ -0,0 +1,82 @@ +""" +Fechas e importes del servidor segun el idioma (`core/i18n/format.py`, tarea 10). + +Las reglas por defecto son las del frontend (`es` -> `es-MX`, `en` -> `en-US`), asi +que el mismo importe se ve igual en la interfaz y en el PDF. +""" +from datetime import date, datetime + +import pytest + +from app.core.i18n.format import format_date, format_money, format_number + +AUG_3 = date(2026, 8, 3) + + +@pytest.mark.parametrize( + "locale, expected", + [ + ("es", "1,234,567.50"), + ("es-MX", "1,234,567.50"), + ("en", "1,234,567.50"), + ("en-US", "1,234,567.50"), + ("en_us", "1,234,567.50"), + ("en-GB", "1,234,567.50"), + ("es-ES", "1.234.567,50"), + ("es-AR", "1,234,567.50"), # sin regla propia: la del idioma + ("fr", "1,234,567.50"), # sin catalogo: la del idioma por defecto + (None, "1,234,567.50"), + ], +) +def test_number_separators_follow_the_region(locale, expected): + assert format_number(1234567.5, locale) == expected + + +def test_spanish_of_spain_does_not_group_four_digits(): + assert format_number(1234.5, "es-ES") == "1234,50" + assert format_number(12345.5, "es-ES") == "12.345,50" + assert format_number(-1234.5, "es-ES") == "-1234,50" + assert format_number(1234.5, "es-MX") == "1,234.50" + + +def test_small_and_negative_amounts_keep_their_shape(): + assert format_number(0, "en") == "0.00" + assert format_number(999.999, "en") == "1,000.00" + assert format_number(-1234.5, "en") == "-1,234.50" + assert format_number(7, "es-ES", decimals=0) == "7" + + +def test_money_is_code_space_amount(): + assert format_money(1160, "USD", "en") == "USD 1,160.00" + assert format_money(1160, "EUR", "es-ES") == "EUR 1160,00" + + +@pytest.mark.parametrize( + "locale, expected", + [ + ("es", "03/08/2026"), + ("es-MX", "03/08/2026"), + ("es-ES", "03/08/2026"), + ("en", "08/03/2026"), + ("en-US", "08/03/2026"), + ("en-GB", "03/08/2026"), + ("fr", "03/08/2026"), + (None, "03/08/2026"), + ], +) +def test_date_order_follows_the_language(locale, expected): + assert format_date(AUG_3, locale) == expected + + +def test_a_datetime_formats_as_its_date(): + assert format_date(datetime(2026, 8, 3, 23, 59), "en") == "08/03/2026" + + +def test_an_iso_string_is_read_and_formatted(): + assert format_date("2026-08-03", "en") == "08/03/2026" + assert format_date("2026-08-03T10:00:00+00:00", "es") == "03/08/2026" + + +def test_text_that_is_not_a_date_comes_out_untouched(): + assert format_date("pronto", "en") == "pronto" + assert format_date("", "en") == "" diff --git a/backend/tests/test_invoice_pdf_locale.py b/backend/tests/test_invoice_pdf_locale.py index 2e261b7..294ccbd 100644 --- a/backend/tests/test_invoice_pdf_locale.py +++ b/backend/tests/test_invoice_pdf_locale.py @@ -6,9 +6,8 @@ casos de segundo idioma inyectan un catalogo pseudo-localizado (`EN()`): si una etiqueta del PDF sigue cableada, sale sin el prefijo y el test lo dice. -Lo que NO cubre: el formato de fechas e importes del PDF (`dd/mm/aaaa`, `$1,160.00`) -sigue siendo el de siempre en cualquier idioma. Es una decision pendiente (anadir -Babel o escribir una tabla de formatos), no un olvido; ver el plan, tarea 10. +Las fechas y los importes siguen al idioma (`core/i18n/format.py`): las reglas estan en +`test_i18n_format.py` y el ultimo caso de aqui comprueba que llegan al PDF. """ import re from datetime import date @@ -172,14 +171,21 @@ async def test_invoice_data_is_never_translated(with_pseudo_english): assert f"EN({data})" not in text, data -async def test_dates_and_amounts_keep_their_format_in_every_language(with_pseudo_english): - # Decision pendiente (docstring): hoy el formato no sigue al idioma. +async def test_dates_and_amounts_follow_the_language(with_pseudo_english): inv = _invoice() assert inv.issue_date == date(2026, 8, 3) - for locale in ("es", "en"): + # (locale, fecha, importe): en espanol nada cambia respecto a siempre. + cases = [ + ("es", "03/08/2026", "$1,160.00"), + ("es-MX", "03/08/2026", "$1,160.00"), + ("en", "08/03/2026", "$1,160.00"), + ("en-GB", "03/08/2026", "$1,160.00"), + ("es-ES", "03/08/2026", "$1160,00"), + ] + for locale, day, amount in cases: text = _text(generate_invoice_pdf(inv, locale=locale)) - assert "03/08/2026" in text - assert "$1,160.00" in text + assert day in text, locale + assert amount in text, locale async def test_a_locale_without_catalogue_renders_in_spanish(): diff --git a/docs/CAPABILITY_MATURITY_SCORECARD.md b/docs/CAPABILITY_MATURITY_SCORECARD.md index 98f0e5c..fe7e2e7 100644 --- a/docs/CAPABILITY_MATURITY_SCORECARD.md +++ b/docs/CAPABILITY_MATURITY_SCORECARD.md @@ -147,4 +147,4 @@ A module may advance from Tier 3 → Tier 2 or Tier 2 → Tier 1 when it achieve **12 modules Tier 1 — 12 total modules.** Full wedge funnel: Discovery → Identity → CRM → Sales → Inventory → Portal → Workflows → Accounting → Notifications → Contracts → Projects → HR. -**932 tests across 48 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. +**957 tests across 49 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index aed02c8..07c2b36 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -58,7 +58,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR ## Current Platform Snapshot - Active modules: identity, crm, sales, inventory, portal, discovery, workflows, notifications, accounting, analytics, contracts, projects, hr -- Test suite: 932 automated tests across 48 test files +- Test suite: 957 automated tests across 49 test files - Frontend active surface: API-backed pages aligned to the implemented backend wedge plus current Tier 1 modules - Public runtime surfaces: - `GET /health` @@ -78,12 +78,12 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | Frontend error surfacing | Closed and enforced by the toolchain rather than by review. All 47 `onError` handlers type the error as `Error` and pass it to `translateApiError`, which takes `unknown` and renders Spanish from the backend `code` per ADR 0014 — which also settles the old English-message inconsistency. The original bug was two handlers reading `e.response.data.detail`, an axios shape this codebase does not use, so the expression evaluated to `undefined` and the user saw a generic fallback; it survived because those handlers typed the error as `any`. Two things now stop it recurring: `@typescript-eslint/no-explicit-any` is an error and CI runs `npm run lint`, and `useUnknownInCatchVariables` closes the hole the linter cannot see — with `strict: false` a bare `catch (e)` was implicitly `any`, and reaching for a property on it now fails to compile. The only remaining `any` is scoped to `PortalBuilder.tsx` behind a justified file-level disable | Every mutation surfaces the backend's own message when it has one | Low | Keep it closed: the compiler and the linter enforce it. Lifting the `PortalBuilder.tsx` disable needs an explicit contract for block props first, and turning on full `strict` is a separate, much larger change — `strictNullChecks` is still off | | Runtime health signal | `GET /health` now checks Redis alongside the API and Postgres. It did not before, and that blind spot hid a real production outage: Redis was down on the deployed instance while `/health` reported `healthy`, because no ordinary HTTP route touches it — what fails silently is notifications, the workflow engine and the consumers. Verified both ways locally: stopping Redis turns the endpoint `unhealthy`, restarting it returns `healthy` | Health reflects every dependency whose loss degrades the product | Medium | When a new runtime dependency is added, add its probe in the same change set. A dependency only reached by background work is exactly the one an external probe cannot see | | Frontend alignment | Active routes are much cleaner than before, but docs still lag occasionally | UI backed by owned capabilities and current backend maturity | Medium | Refresh capability specs and route docs alongside feature delivery | -| Test coverage narrative | Codebase has 932 tests across 48 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | +| Test coverage narrative | Codebase has 957 tests across 49 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | | Contracts/Projects/HR production confirmation | Deploy readiness is locally verified, but no production-domain smoke evidence has been recorded in governance | Production dimension marked green only after deployed route smoke passes against the production domain and matching SHA | High | Run production smoke for `/contracts`, `/projects`, `/departments`, and `/employees` after deploy | | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, and locale-aware date and amount formatting in the server-rendered artifacts. English is offered: the backend catalogues (invoice PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, and the quote PDF's dates and amounts. Dates and amounts already follow the language in the invoice PDF and the emails (`app/core/i18n/format.py`, an own table with the frontend's region defaults: `es` is `es-MX`, `en` is `en-US`; email dates stay ISO). English is offered: the backend catalogues (invoice PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | --- diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index 5a33d78..eb76001 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -471,9 +471,15 @@ completa migrada no hay patron que seguir. tarea 2): propone el idioma de la interfaz, lo manda como `locale` de la sesion de portal y el servidor redacta en el el correo al cliente. La pagina del portal sigue el idioma del navegador del cliente, no el de la sesion. + - [x] Decision de `babel` o tabla propia: **tabla propia** (`backend/app/core/i18n/format.py`), + con las reglas por defecto del frontend (`es` -> `es-MX`, `en` -> `en-US`) mas + `es-ES` y `en-GB`. Aplicada al PDF de factura y a los importes de los correos; las + fechas de los correos siguen en ISO (no dependen del idioma). Falta aplicarla al PDF + de cotizacion, que entra con su catalogo. - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: - cadenas concatenadas, orden de palabras, plurales, fechas e importes (decision de - `babel` o tabla propia, pendiente desde la tarea 10). + cadenas concatenadas, orden de palabras, plurales. El barrido estatico del frontend + salio limpio; quedan el PDF de cotizacion, los textos del portal y las etiquetas de + notificaciones, y el recorrido real en el navegador. ---