T2026-08-047 feat(fin): catálogos SAT, conceptos de facturación y datos fiscales del emisor #5

Open
jcedilloAS wants to merge 10 commits from feature/AS-catalogos-sat-facturacion into feature/crm-cumplimiento-pdf
3 changed files with 402 additions and 1 deletions
Showing only changes of commit cb2acb11fc - Show all commits

View File

@@ -37,9 +37,13 @@ import api.v1.modules.crm.quotes.models # noqa: E402,F401
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
import api.v1.modules.ops.shipments.models # noqa: E402,F401
import api.v1.modules.fin.catalogs.models # noqa: E402,F401
import api.v1.modules.fin.concepts.models # noqa: E402,F401
import api.v1.modules.fin.issuer.models # noqa: E402,F401
import api.v1.modules.fin.invoices.models # noqa: E402,F401
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None}
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
@@ -75,6 +79,10 @@ def db():
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, future=True)
session = session_factory()
# Los catálogos del SAT los siembra la migración en PostgreSQL; aquí se replica
# con la misma función para que conceptos y emisor tengan claves que referenciar.
sync_catalogs(session.connection())
session.commit()
try:
yield session
finally:

View File

@@ -0,0 +1,331 @@
"""Pruebas de los catálogos del SAT, el catálogo de conceptos y los datos fiscales
del emisor (módulo fin).
Cubren: lectura de los 8 catálogos y su filtrado, que no acepten escritura, el CRUD de
conceptos con la relación 1:1 contra c_ClaveProdServ, el aislamiento multi-tenant, el
upsert del emisor y el amarre de las partidas de factura al catálogo de conceptos.
Los RFC de las pruebas son dummies (XAXX010101000): nunca datos reales.
"""
from decimal import Decimal
import pytest
import sqlalchemy as sa
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import ValidationError
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.fin.catalogs.models import ProductService, TaxRegime
from api.v1.modules.fin.catalogs.routes import router as catalogs_router
from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs
from api.v1.modules.fin.concepts import service as concepts_service
from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate
from api.v1.modules.fin.invoices import service as invoices_service
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate
from api.v1.modules.fin.issuer import service as issuer_service
from api.v1.modules.fin.issuer.dto import IssuerSettingsInput
from api.v1.modules.fin.issuer.models import IssuerSettings
from core.database import get_core_db
from core.security import get_current_user
T, C = 1, 1
OTHER_TENANT, OTHER_COMPANY = 2, 2
RFC_DUMMY = "XAXX010101000"
@pytest.fixture()
def client(db):
"""App mínima con solo el router de catálogos: evita levantar auth y permisos."""
app = FastAPI()
app.include_router(catalogs_router, prefix="/fin")
app.dependency_overrides[get_core_db] = lambda: db
app.dependency_overrides[get_current_user] = lambda: {"sub": "tester", "tenant_id": T}
return TestClient(app)
def _product_service(db, code: str = "78101600") -> ProductService:
return db.query(ProductService).filter(ProductService.code == code).one()
def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") -> ConceptCreate:
return ConceptCreate(
code=code,
description="Flete marítimo internacional",
product_service_id=_product_service(db, ps_code).id,
unit_price=Decimal("1500.00"),
)
# ---------- Catálogos del SAT: lectura ----------
CATALOG_EXPECTATIONS = [
("tax-regimes", 19, "601"),
("taxes", 3, "002"),
("payment-forms", 22, "03"),
("units-of-measure", 21, "H87"),
("products-services", 11, "78101500"),
("voucher-types", 5, "I"),
("payment-methods", 2, "PUE"),
("tax-objects", 4, "02"),
]
@pytest.mark.parametrize("path,expected_count,sample_code", CATALOG_EXPECTATIONS)
def test_catalog_endpoints_return_seeded_rows(client, path, expected_count, sample_code):
res = client.get(f"/fin/catalogs/{path}")
assert res.status_code == 200
rows = res.json()
assert len(rows) == expected_count
assert sample_code in [r["code"] for r in rows]
def test_catalog_search_filters_by_code_or_description(client):
by_code = client.get("/fin/catalogs/payment-forms", params={"search": "03"}).json()
assert [r["code"] for r in by_code] == ["03"]
by_description = client.get("/fin/catalogs/payment-forms", params={"search": "transferencia"}).json()
assert [r["code"] for r in by_description] == ["03"]
prodserv = client.get("/fin/catalogs/products-services", params={"search": "marítimo"}).json()
assert [r["code"] for r in prodserv] == ["78101600"]
def test_tax_regimes_person_type_excludes_individual_only(client):
moral = client.get("/fin/catalogs/tax-regimes", params={"person_type": "moral"}).json()
codes = [r["code"] for r in moral]
assert "601" in codes # General de Ley Personas Morales
assert "605" not in codes # Sueldos y Salarios: solo persona física
assert all(r["applies_to_legal_entity"] for r in moral)
fisica = client.get("/fin/catalogs/tax-regimes", params={"person_type": "fisica"}).json()
fisica_codes = [r["code"] for r in fisica]
assert "605" in fisica_codes and "601" not in fisica_codes
def test_products_services_limit_caps_results(client):
assert len(client.get("/fin/catalogs/products-services", params={"limit": 3}).json()) == 3
assert client.get("/fin/catalogs/products-services", params={"limit": 500}).status_code == 422
def test_catalogs_are_read_only(client):
"""Los catálogos del SAT no exponen métodos de escritura."""
for method, path in [
("post", "/fin/catalogs/payment-forms"),
("put", "/fin/catalogs/tax-regimes"),
("patch", "/fin/catalogs/units-of-measure"),
("delete", "/fin/catalogs/products-services"),
]:
res = client.request(method.upper(), path, json={"code": "XX", "description": "Inventado"})
assert res.status_code == 405, f"{method.upper()} {path} no debería aceptarse"
def _catalog_counts(db) -> dict[str, int]:
return {
table.name: db.execute(sa.select(sa.func.count()).select_from(table)).scalar()
for table, _ in CATALOGS
}
def test_sync_catalogs_is_idempotent(db):
"""Volver a correrla no duplica ni borra filas."""
before = _catalog_counts(db)
inserted = sync_catalogs(db.connection()) # el fixture ya sembró los catálogos
db.commit()
assert sum(inserted.values()) == 0
assert _catalog_counts(db) == before
# ---------- Conceptos ----------
def test_concept_crud(db):
created = concepts_service.create_concept(db, _concept_payload(db), T, C, "tester")
assert created.code == "FLETE-MAR" and created.currency == "MXN" and created.is_active
fetched = concepts_service.get_concept(db, created.id, T, C)
assert fetched.product_service.code == "78101600" # catálogo resuelto sin N+1
updated = concepts_service.update_concept(
db, created.id, ConceptUpdate(description="Flete marítimo FCL", is_active=False), T, C, "tester"
)
assert updated.description == "Flete marítimo FCL" and updated.is_active is False
assert concepts_service.get_concepts(db, T, C, active_only=False) == [updated]
assert concepts_service.get_concepts(db, T, C, active_only=True) == []
concepts_service.delete_concept(db, created.id, T, C)
assert concepts_service.get_concepts(db, T, C) == []
with pytest.raises(HTTPException) as exc:
concepts_service.get_concept(db, created.id, T, C)
assert exc.value.status_code == 404
def test_duplicate_product_service_in_same_company_conflicts(db):
concepts_service.create_concept(db, _concept_payload(db), T, C)
with pytest.raises(HTTPException) as exc:
concepts_service.create_concept(db, _concept_payload(db, code="OTRO-CODIGO"), T, C)
assert exc.value.status_code == 409
assert "producto/servicio" in exc.value.detail
def test_duplicate_concept_code_in_same_company_conflicts(db):
concepts_service.create_concept(db, _concept_payload(db), T, C)
with pytest.raises(HTTPException) as exc:
concepts_service.create_concept(db, _concept_payload(db, ps_code="78101500"), T, C)
assert exc.value.status_code == 409
assert "clave 'FLETE-MAR'" in exc.value.detail
def test_same_product_service_allowed_in_another_company(db):
concepts_service.create_concept(db, _concept_payload(db), T, C)
other = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY)
assert other.company_id == OTHER_COMPANY
assert other.product_service_id == _product_service(db).id
def test_soft_deleted_concept_frees_its_product_service(db):
first = concepts_service.create_concept(db, _concept_payload(db), T, C)
concepts_service.delete_concept(db, first.id, T, C)
reused = concepts_service.create_concept(db, _concept_payload(db), T, C)
assert reused.id != first.id
assert reused.product_service_id == first.product_service_id
def test_concept_is_isolated_by_tenant(db):
other_tenant_concept = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C)
assert concepts_service.get_concepts(db, T, C) == []
with pytest.raises(HTTPException) as exc:
concepts_service.get_concept(db, other_tenant_concept.id, T, C)
assert exc.value.status_code == 404
with pytest.raises(HTTPException) as exc:
concepts_service.update_concept(
db, other_tenant_concept.id, ConceptUpdate(description="Ajeno"), T, C
)
assert exc.value.status_code == 404
def test_concept_rejects_unknown_sat_key(db):
payload = _concept_payload(db)
payload.product_service_id = 999999
with pytest.raises(HTTPException) as exc:
concepts_service.create_concept(db, payload, T, C)
assert exc.value.status_code == 422
# ---------- Datos fiscales del emisor ----------
def _issuer_payload(db, legal_name: str = "Empresa Demo SA de CV") -> IssuerSettingsInput:
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
return IssuerSettingsInput(
legal_name=legal_name, rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="64000"
)
def test_issuer_settings_upsert_keeps_one_row_per_company(db):
created = issuer_service.save_issuer_settings(db, _issuer_payload(db), T, C, "tester")
assert created.rfc == RFC_DUMMY
updated = issuer_service.save_issuer_settings(
db, _issuer_payload(db, legal_name="Empresa Demo Renombrada SA de CV"), T, C, "tester"
)
assert updated.id == created.id
assert updated.legal_name == "Empresa Demo Renombrada SA de CV"
rows = db.query(IssuerSettings).filter(
IssuerSettings.tenant_id == T, IssuerSettings.company_id == C, IssuerSettings.deleted_at.is_(None)
).all()
assert len(rows) == 1
def test_issuer_settings_missing_returns_404(db):
with pytest.raises(HTTPException) as exc:
issuer_service.get_issuer_settings(db, T, C)
assert exc.value.status_code == 404
def test_issuer_rfc_is_validated_and_normalized(db):
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
with pytest.raises(ValidationError):
IssuerSettingsInput(legal_name="Demo", rfc="RFC-INVALIDO", tax_regime_id=regime.id)
with pytest.raises(ValidationError):
IssuerSettingsInput(legal_name="Demo", rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="123")
normalized = IssuerSettingsInput(
legal_name="Demo", rfc=" xaxx010101000 ", tax_regime_id=regime.id
)
assert normalized.rfc == RFC_DUMMY
def test_issuer_rejects_unknown_tax_regime(db):
payload = _issuer_payload(db)
payload.tax_regime_id = 999999
with pytest.raises(HTTPException) as exc:
issuer_service.save_issuer_settings(db, payload, T, C)
assert exc.value.status_code == 422
# ---------- Amarre con las facturas ----------
def test_invoice_item_inherits_concept_description(db):
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-1"), T, C)
item = invoices_service.create_item(
db,
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, quantity=1, unit_amount=1500),
T,
C,
)
assert item.concept == concept.description # copiada del catálogo para el PDF
assert item.concept_id == concept.id
# Si el cliente sí manda el texto, se respeta tal cual.
explicit = invoices_service.create_item(
db,
InvoiceItemCreate(
invoice_id=invoice.id, concept_id=concept.id, concept="Flete a la medida", unit_amount=100
),
T,
C,
)
assert explicit.concept == "Flete a la medida"
def test_invoice_item_without_concept_or_catalog_is_rejected(db):
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-2"), T, C)
with pytest.raises(HTTPException) as exc:
invoices_service.create_item(db, InvoiceItemCreate(invoice_id=invoice.id, unit_amount=10), T, C)
assert exc.value.status_code == 422
def test_invoice_item_rejects_concept_from_another_company(db):
concept = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY)
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-3"), T, C)
with pytest.raises(HTTPException) as exc:
invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=10), T, C
)
assert exc.value.status_code == 422
def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch):
"""Las facturas previas, sin claves del SAT, siguen listándose y generando PDF."""
stored = {}
monkeypatch.setattr(
"core.storage_s3.put_object_bytes",
lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}),
)
account = accounts_service.create_account(db, AccountCreate(name="Cliente heredado"), T, C)
invoice = invoices_service.create_invoice(
db, InvoiceCreate(reference="F-LEGACY", account_id=account.id, tax_rate=Decimal("16")), T, C
)
invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional", unit_amount=1000), T, C
)
assert invoice.voucher_type_id is None and invoice.payment_form_id is None
listed = invoices_service.get_invoices(db, T, C)
assert invoice.id in [i.id for i in listed]
sent = invoices_service.send_invoice(db, invoice.id, T, C)
assert sent.status == "enviada" and stored["len"] > 0

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const get = vi.fn();
// El cliente de catálogos solo usa `api.get`; se sustituye para contar peticiones.
vi.mock('$lib/api', () => ({ api: { get } }));
const { satCatalogsAPI, clearCatalogCache } = await import('./catalogs');
const COMPANY_ID = 1;
const PAYMENT_FORMS = [
{ id: 1, code: '01', description: 'Efectivo', is_active: true },
{ id: 2, code: '03', description: 'Transferencia electrónica de fondos', is_active: true }
];
describe('satCatalogsAPI — cacheo en memoria', () => {
beforeEach(() => {
clearCatalogCache();
get.mockReset();
get.mockResolvedValue({ data: PAYMENT_FORMS, status: 200 });
});
it('consulta el backend la primera vez y reusa el cache después', async () => {
const first = await satCatalogsAPI.paymentForms(COMPANY_ID);
const second = await satCatalogsAPI.paymentForms(COMPANY_ID);
expect(first).toEqual(PAYMENT_FORMS);
expect(second).toBe(first); // misma referencia: vino del cache
expect(get).toHaveBeenCalledTimes(1);
});
it('cachea por separado cada combinación de parámetros', async () => {
await satCatalogsAPI.paymentForms(COMPANY_ID);
await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' });
await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' });
expect(get).toHaveBeenCalledTimes(2);
});
it('no comparte cache entre compañías', async () => {
await satCatalogsAPI.paymentForms(COMPANY_ID);
await satCatalogsAPI.paymentForms(2);
expect(get).toHaveBeenCalledTimes(2);
});
it('clearCatalogCache obliga a volver a consultar', async () => {
await satCatalogsAPI.paymentForms(COMPANY_ID);
clearCatalogCache();
await satCatalogsAPI.paymentForms(COMPANY_ID);
expect(get).toHaveBeenCalledTimes(2);
});
it('propaga el error del backend y no lo cachea', async () => {
get.mockResolvedValueOnce({ error: 'Falla del servidor', status: 500 });
await expect(satCatalogsAPI.taxRegimes(COMPANY_ID)).rejects.toThrow('Falla del servidor');
await satCatalogsAPI.taxRegimes(COMPANY_ID);
expect(get).toHaveBeenCalledTimes(2);
});
});