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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
13 changes: 7 additions & 6 deletions backend/app/core/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions backend/app/core/i18n/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 88 additions & 0 deletions backend/app/core/i18n/format.py
Original file line number Diff line number Diff line change
@@ -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]
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect accepted regional locales when selecting format rules

For any accepted regional locale not explicitly listed in _RULES, this falls back to the language default even though normalize_locale preserves arbitrary two-letter regions and the frontend passes the complete tag to Intl. Consequently users with locales such as es-AR, en-AU, or en-IN receive server-generated invoices and emails with Mexican or US formatting while the UI uses their actual regional conventions (for example, en-AU dates become 08/03/2026 here instead of 03/08/2026, and es-AR amounts use commas instead of periods for grouping). Either support every locale the API accepts via a locale-aware formatter or restrict locale validation to the regions represented by this table.

Useful? React with 👍 / 👎.



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)
27 changes: 14 additions & 13 deletions backend/app/modules/accounting/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
)
Expand Down
15 changes: 13 additions & 2 deletions backend/tests/test_email_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
82 changes: 82 additions & 0 deletions backend/tests/test_i18n_format.py
Original file line number Diff line number Diff line change
@@ -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") == ""
Loading
Loading