diff --git a/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py b/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py new file mode 100644 index 0000000..b46d319 --- /dev/null +++ b/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py @@ -0,0 +1,88 @@ +"""Catálogo c_UsoCFDI y claves fiscales del receptor en crm.accounts. + +Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega +``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los +catálogos, conservando las columnas de texto libre que ya existían. + +Re-encadenada al integrar main: esta rama y la del CRM habían salido las dos de +d5e6f7a8b9c0, y con dos cabezas ``alembic upgrade head`` falla. La historia queda lineal, +con las migraciones de facturación detrás de las del CRM. + +Revision ID: f7a8b9c0d1e2 +Revises: g1h2i3j4k5l6 +Create Date: 2026-08-07 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from api.v1.modules.fin.catalogs.seed_data import sync_catalogs + +revision: str = "f7a8b9c0d1e2" +down_revision: Union[str, None] = "g1h2i3j4k5l6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ---------- sat.cfdi_uses ---------- + op.create_table( + "cfdi_uses", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=4), nullable=False), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + schema="sat", + ) + op.create_index("ix_sat_cfdi_uses_id", "cfdi_uses", ["id"], schema="sat") + op.create_index("ix_sat_cfdi_uses_code", "cfdi_uses", ["code"], unique=True, schema="sat") + + # sync_catalogs es idempotente: siembra c_UsoCFDI y deja intactos los catálogos + # que ya sembró la migración anterior. + sync_catalogs(op.get_bind()) + + # ---------- crm.accounts: claves fiscales del receptor ---------- + # Nullables: las cuentas existentes solo tienen el texto libre. + op.add_column("accounts", sa.Column("tax_regime_id", sa.Integer(), nullable=True), schema="crm") + op.add_column("accounts", sa.Column("cfdi_use_id", sa.Integer(), nullable=True), schema="crm") + op.create_foreign_key( + "fk_crm_accounts_tax_regime_id", "accounts", "tax_regimes", + ["tax_regime_id"], ["id"], source_schema="crm", referent_schema="sat", + ) + op.create_foreign_key( + "fk_crm_accounts_cfdi_use_id", "accounts", "cfdi_uses", + ["cfdi_use_id"], ["id"], source_schema="crm", referent_schema="sat", + ) + + # Backfill conservador: solo resuelve lo inequívoco. Se compara el texto libre + # contra la clave del catálogo (p. ej. "601", "G03") y contra la descripción + # exacta, sin distinguir mayúsculas ni espacios sobrantes. Lo que no case así se + # queda en NULL para que lo revise el usuario: adivinar el régimen de un receptor + # a partir de texto libre provoca CFDI rechazados. + for column, catalog in [("tax_regime", "tax_regimes"), ("cfdi_use", "cfdi_uses")]: + op.execute( + f""" + UPDATE crm.accounts AS a + SET {column}_id = c.id + FROM sat.{catalog} AS c + WHERE a.{column}_id IS NULL + AND a.{column} IS NOT NULL + AND ( + upper(btrim(a.{column})) = upper(c.code) + OR upper(btrim(a.{column})) = upper(c.description) + ) + """ + ) + + +def downgrade() -> None: + op.drop_constraint("fk_crm_accounts_cfdi_use_id", "accounts", schema="crm", type_="foreignkey") + op.drop_constraint("fk_crm_accounts_tax_regime_id", "accounts", schema="crm", type_="foreignkey") + op.drop_column("accounts", "cfdi_use_id", schema="crm") + op.drop_column("accounts", "tax_regime_id", schema="crm") + op.drop_table("cfdi_uses", schema="sat") diff --git a/backend/alembic/versions/g1h2i3j4k5l6_fin_sat_catalogs_concepts_issuer.py b/backend/alembic/versions/g1h2i3j4k5l6_fin_sat_catalogs_concepts_issuer.py new file mode 100644 index 0000000..29fdcce --- /dev/null +++ b/backend/alembic/versions/g1h2i3j4k5l6_fin_sat_catalogs_concepts_issuer.py @@ -0,0 +1,274 @@ +"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor +y amarre de facturas y partidas a los catálogos. + +Renumerada al integrar main: nació como e6f7a8b9c0d1, el mismo identificador que la +migración de catálogos del CRM, porque ambas ramas salieron de d5e6f7a8b9c0 sin verse. Se +renumera ésta y no la del CRM porque aquélla ya está en main y hay otra migración que la +referencia por id. + +Revision ID: g1h2i3j4k5l6 +Revises: c5d6e7f8a9b0 +Create Date: 2026-08-07 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from api.v1.modules.fin.catalogs.seed_data import sync_catalogs + +revision: str = "g1h2i3j4k5l6" +down_revision: Union[str, None] = "c5d6e7f8a9b0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Índices únicos parciales: la baja lógica (deleted_at) libera la clave. +_ALIVE = "deleted_at IS NULL" + +# Catálogos del SAT: (tabla, longitud de code, columnas propias del catálogo). +_SAT_CATALOGS: list[tuple[str, int, list[sa.Column]]] = [ + ("tax_regimes", 3, [ + sa.Column("applies_to_individual", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("applies_to_legal_entity", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ]), + ("taxes", 3, [ + sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_transferred", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_local", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ]), + ("payment_forms", 2, []), + ("units_of_measure", 20, [ + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("symbol", sa.String(length=20), nullable=True), + ]), + ("products_services", 8, []), + ("voucher_types", 1, []), + ("payment_methods", 3, []), + ("tax_objects", 2, []), +] + +# units_of_measure guarda el nombre corto aparte, así que su description es opcional. +_NULLABLE_DESCRIPTION = {"units_of_measure"} + + +def _timestamp_columns(with_soft_delete: bool) -> list[sa.Column]: + columns = [ + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + ] + if with_soft_delete: + columns.append(sa.Column("deleted_at", sa.DateTime(), nullable=True)) + return columns + + +def upgrade() -> None: + # ---------- Schema y catálogos globales del SAT ---------- + op.execute("CREATE SCHEMA IF NOT EXISTS sat") + + for table, code_length, extra_columns in _SAT_CATALOGS: + op.create_table( + table, + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=code_length), nullable=False), + sa.Column( + "description", + sa.String(length=500), + nullable=table in _NULLABLE_DESCRIPTION, + ), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + *extra_columns, + *_timestamp_columns(with_soft_delete=False), + sa.PrimaryKeyConstraint("id"), + schema="sat", + ) + op.create_index(f"ix_sat_{table}_id", table, ["id"], schema="sat") + # La clave oficial del SAT es única dentro de su catálogo. + op.create_index(f"ix_sat_{table}_code", table, ["code"], unique=True, schema="sat") + + # Semillas de los catálogos (idempotente: puede volver a correrse sin duplicar). + sync_catalogs(op.get_bind()) + + # ---------- fin.concepts ---------- + op.create_table( + "concepts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=40), nullable=False), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("product_service_id", sa.Integer(), nullable=False), + sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), + sa.Column("tax_object_id", sa.Integer(), nullable=True), + sa.Column("unit_price", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + *_timestamp_columns(with_soft_delete=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_concepts_tenant_id"), + sa.ForeignKeyConstraint( + ["product_service_id"], ["sat.products_services.id"], name="fk_fin_concepts_product_service_id" + ), + sa.ForeignKeyConstraint( + ["unit_of_measure_id"], ["sat.units_of_measure.id"], name="fk_fin_concepts_unit_of_measure_id" + ), + sa.ForeignKeyConstraint( + ["tax_object_id"], ["sat.tax_objects.id"], name="fk_fin_concepts_tax_object_id" + ), + schema="fin", + ) + op.create_index("ix_fin_concepts_id", "concepts", ["id"], schema="fin") + op.create_index("ix_fin_concepts_tenant_id", "concepts", ["tenant_id"], schema="fin") + op.create_index("ix_fin_concepts_company_id", "concepts", ["company_id"], schema="fin") + op.create_index("ix_fin_concepts_product_service_id", "concepts", ["product_service_id"], schema="fin") + # La clave interna del concepto es única por empresa. + op.create_index( + "uq_fin_concepts_code", "concepts", ["tenant_id", "company_id", "code"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + # Relación 1:1 con c_ClaveProdServ: una clave del SAT no puede repetirse entre + # los conceptos vigentes de la misma empresa. + op.create_index( + "uq_fin_concepts_product_service", "concepts", ["tenant_id", "company_id", "product_service_id"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + + # ---------- fin.issuer_settings ---------- + op.create_table( + "issuer_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("legal_name", sa.String(length=255), nullable=False), + sa.Column("rfc", sa.String(length=13), nullable=False), + sa.Column("tax_regime_id", sa.Integer(), nullable=False), + sa.Column("zip_code", sa.String(length=5), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + *_timestamp_columns(with_soft_delete=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_issuer_settings_tenant_id"), + sa.ForeignKeyConstraint( + ["tax_regime_id"], ["sat.tax_regimes.id"], name="fk_fin_issuer_settings_tax_regime_id" + ), + schema="fin", + ) + op.create_index("ix_fin_issuer_settings_id", "issuer_settings", ["id"], schema="fin") + op.create_index("ix_fin_issuer_settings_tenant_id", "issuer_settings", ["tenant_id"], schema="fin") + op.create_index("ix_fin_issuer_settings_company_id", "issuer_settings", ["company_id"], schema="fin") + op.create_index("ix_fin_issuer_settings_tax_regime_id", "issuer_settings", ["tax_regime_id"], schema="fin") + # Una sola configuración fiscal vigente por empresa. + op.create_index( + "uq_fin_issuer_settings_company", "issuer_settings", ["tenant_id", "company_id"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + + # ---------- fin.invoice_item_taxes ---------- + op.create_table( + "invoice_item_taxes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("invoice_item_id", sa.Integer(), nullable=False), + sa.Column("tax_id", sa.Integer(), nullable=False), + sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("rate", sa.Numeric(precision=8, scale=6), nullable=True), + sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")), + *_timestamp_columns(with_soft_delete=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_invoice_item_taxes_tenant_id"), + sa.ForeignKeyConstraint( + ["invoice_item_id"], ["fin.invoice_items.id"], name="fk_fin_invoice_item_taxes_invoice_item_id" + ), + sa.ForeignKeyConstraint(["tax_id"], ["sat.taxes.id"], name="fk_fin_invoice_item_taxes_tax_id"), + schema="fin", + ) + op.create_index("ix_fin_invoice_item_taxes_id", "invoice_item_taxes", ["id"], schema="fin") + op.create_index("ix_fin_invoice_item_taxes_tenant_id", "invoice_item_taxes", ["tenant_id"], schema="fin") + op.create_index("ix_fin_invoice_item_taxes_company_id", "invoice_item_taxes", ["company_id"], schema="fin") + op.create_index( + "ix_fin_invoice_item_taxes_invoice_item_id", "invoice_item_taxes", ["invoice_item_id"], schema="fin" + ) + # Un mismo impuesto no puede declararse dos veces con el mismo rol en la partida. + op.create_index( + "uq_fin_invoice_item_taxes", "invoice_item_taxes", ["invoice_item_id", "tax_id", "is_withholding"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + + # ---------- fin.invoices: claves fiscales del comprobante ---------- + # Todas nullable: las facturas ya emitidas no tienen estos datos. + op.add_column("invoices", sa.Column("voucher_type_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoices", sa.Column("payment_form_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoices", sa.Column("payment_method_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoices", sa.Column("expedition_zip_code", sa.String(length=5), nullable=True), schema="fin") + op.create_foreign_key( + "fk_fin_invoices_voucher_type_id", "invoices", "voucher_types", + ["voucher_type_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoices_payment_form_id", "invoices", "payment_forms", + ["payment_form_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoices_payment_method_id", "invoices", "payment_methods", + ["payment_method_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + + # ---------- fin.invoice_items: claves fiscales de la partida ---------- + # La columna de texto libre `concept` se conserva intacta y obligatoria: la usa el + # PDF actual de la factura. + op.add_column("invoice_items", sa.Column("concept_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoice_items", sa.Column("product_service_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoice_items", sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoice_items", sa.Column("tax_object_id", sa.Integer(), nullable=True), schema="fin") + op.create_index("ix_fin_invoice_items_concept_id", "invoice_items", ["concept_id"], schema="fin") + op.create_foreign_key( + "fk_fin_invoice_items_concept_id", "invoice_items", "concepts", + ["concept_id"], ["id"], source_schema="fin", referent_schema="fin", + ) + op.create_foreign_key( + "fk_fin_invoice_items_product_service_id", "invoice_items", "products_services", + ["product_service_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoice_items_unit_of_measure_id", "invoice_items", "units_of_measure", + ["unit_of_measure_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoice_items_tax_object_id", "invoice_items", "tax_objects", + ["tax_object_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + + +def downgrade() -> None: + # fin.invoice_items + for constraint in ( + "fk_fin_invoice_items_tax_object_id", + "fk_fin_invoice_items_unit_of_measure_id", + "fk_fin_invoice_items_product_service_id", + "fk_fin_invoice_items_concept_id", + ): + op.drop_constraint(constraint, "invoice_items", schema="fin", type_="foreignkey") + op.drop_index("ix_fin_invoice_items_concept_id", table_name="invoice_items", schema="fin") + for column in ("tax_object_id", "unit_of_measure_id", "product_service_id", "concept_id"): + op.drop_column("invoice_items", column, schema="fin") + + # fin.invoices + for constraint in ( + "fk_fin_invoices_payment_method_id", + "fk_fin_invoices_payment_form_id", + "fk_fin_invoices_voucher_type_id", + ): + op.drop_constraint(constraint, "invoices", schema="fin", type_="foreignkey") + for column in ("expedition_zip_code", "payment_method_id", "payment_form_id", "voucher_type_id"): + op.drop_column("invoices", column, schema="fin") + + # Tablas nuevas (los índices caen con la tabla). + op.drop_table("invoice_item_taxes", schema="fin") + op.drop_table("issuer_settings", schema="fin") + op.drop_table("concepts", schema="fin") + + # Catálogos del SAT: se va el schema completo. + op.execute("DROP SCHEMA IF EXISTS sat CASCADE") diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index 4708fbe..55a4bf6 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -28,6 +28,9 @@ class AccountBase(BaseModel): # Fiscal tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) + # Claves contra los catálogos del SAT; sustituyen al texto libre de arriba al timbrar. + tax_regime_id: int | None = Field(None, description="c_RegimenFiscal del receptor") + cfdi_use_id: int | None = Field(None, description="c_UsoCFDI del receptor") payment_method: str | None = Field(None, max_length=60) payment_form: str | None = Field(None, max_length=60) currency: str | None = Field(None, max_length=3) @@ -71,6 +74,8 @@ class AccountUpdate(BaseModel): commercial_observations: str | None = None tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) + tax_regime_id: int | None = None + cfdi_use_id: int | None = None payment_method: str | None = Field(None, max_length=60) payment_form: str | None = Field(None, max_length=60) currency: str | None = Field(None, max_length=3) diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index 4526b7d..e498c62 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -1,9 +1,10 @@ from decimal import Decimal -from sqlalchemy import Integer, Numeric, String, Text, text +from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime # noqa: F401 (resuelve las FK) from core.database import Base @@ -51,8 +52,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin): commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales # ----- Información fiscal ----- + # Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario + # para no perder lo ya registrado, pero lo que vale al timbrar son las FK de abajo. tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI + # Claves del receptor contra los catálogos del SAT (c_RegimenFiscal y c_UsoCFDI). + tax_regime_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.tax_regimes.id"), nullable=True + ) + cfdi_use_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.cfdi_uses.id"), nullable=True + ) payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda diff --git a/backend/api/v1/modules/crm/accounts/service.py b/backend/api/v1/modules/crm/accounts/service.py index 617edc6..2284a17 100644 --- a/backend/api/v1/modules/crm/accounts/service.py +++ b/backend/api/v1/modules/crm/accounts/service.py @@ -3,10 +3,24 @@ from datetime import datetime, timezone from fastapi import HTTPException, status from sqlalchemy.orm import Session +from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime + from .dto import AccountCreate, AccountUpdate from .models import Account +def _validate_sat_refs(db: Session, data: dict) -> None: + """Verifica las claves del SAT del receptor antes de guardar la cuenta.""" + for field, model, msg in [ + ("tax_regime_id", TaxRegime, "El régimen fiscal indicado no existe en el catálogo del SAT"), + ("cfdi_use_id", CfdiUse, "El uso de CFDI indicado no existe en el catálogo del SAT"), + ]: + value = data.get(field) + if field in data and value is not None: + if db.query(model.id).filter(model.id == value).first() is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg) + + def get_accounts( db: Session, tenant_id: int, @@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) - def create_account( db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Account: + data = payload.model_dump() + _validate_sat_refs(db, data) account = Account( - **payload.model_dump(), + **data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, @@ -75,7 +91,9 @@ def update_account( user_id: str | None = None, ) -> Account: account = get_account(db, account_id, tenant_id, company_id) - for field, value in payload.model_dump(exclude_unset=True).items(): + data = payload.model_dump(exclude_unset=True) + _validate_sat_refs(db, data) + for field, value in data.items(): setattr(account, field, value) account.updated_by = user_id db.commit() diff --git a/backend/api/v1/modules/fin/catalogs/__init__.py b/backend/api/v1/modules/fin/catalogs/__init__.py new file mode 100644 index 0000000..0778b59 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/__init__.py @@ -0,0 +1 @@ +"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura.""" diff --git a/backend/api/v1/modules/fin/catalogs/dto.py b/backend/api/v1/modules/fin/catalogs/dto.py new file mode 100644 index 0000000..4fc2848 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/dto.py @@ -0,0 +1,61 @@ +"""Esquemas de respuesta de los catálogos del SAT (solo lectura).""" + +from pydantic import BaseModel, ConfigDict + + +class SatCatalogItem(BaseModel): + """Forma común de todo catálogo del SAT: clave + descripción.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + description: str + is_active: bool + + +class TaxRegimeResponse(SatCatalogItem): + """``c_RegimenFiscal``: incluye a qué tipo de persona aplica el régimen.""" + + applies_to_individual: bool # persona física + applies_to_legal_entity: bool # persona moral + + +class TaxResponse(SatCatalogItem): + """``c_Impuesto``: indica si el impuesto puede retenerse o trasladarse.""" + + is_withholding: bool + is_transferred: bool + is_local: bool + + +class UnitOfMeasureResponse(SatCatalogItem): + """``c_ClaveUnidad``: nombre corto, símbolo y nota larga del catálogo.""" + + description: str | None = None + name: str + symbol: str | None = None + + +class PaymentFormResponse(SatCatalogItem): + """``c_FormaPago``.""" + + +class ProductServiceResponse(SatCatalogItem): + """``c_ClaveProdServ``.""" + + +class VoucherTypeResponse(SatCatalogItem): + """``c_TipoDeComprobante``.""" + + +class PaymentMethodResponse(SatCatalogItem): + """``c_MetodoPago``.""" + + +class TaxObjectResponse(SatCatalogItem): + """``c_ObjetoImp``.""" + + +class CfdiUseResponse(SatCatalogItem): + """``c_UsoCFDI``.""" diff --git a/backend/api/v1/modules/fin/catalogs/models.py b/backend/api/v1/modules/fin/catalogs/models.py new file mode 100644 index 0000000..4e31c9a --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/models.py @@ -0,0 +1,143 @@ +"""Modelos de los catálogos oficiales del SAT — schema ``sat``. + +Son catálogos **globales**: los publica el SAT, valen igual para cualquier tenant y +compañía, por eso no heredan ``TenantScopedMixin``. Tampoco se borran: cuando el SAT +retira una clave, el registro se marca ``is_active = false`` para que las facturas +históricas que la usan sigan resolviendo su descripción (de ahí que se use +``BaseTimestampMixin``, sin ``deleted_at``). + +La API los expone únicamente en modo lectura; el alta y la actualización pasan por +``seed_data.sync_catalogs()``. +""" + +from sqlalchemy import Boolean, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import BaseTimestampMixin +from core.database import Base + + +class SatCatalogMixin(BaseTimestampMixin): + """Campos comunes a todo catálogo del SAT. + + ``code`` (la clave oficial) se declara en cada modelo porque su longitud + cambia de catálogo en catálogo. + """ + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + description: Mapped[str] = mapped_column(String(500), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + + +class TaxRegime(Base, SatCatalogMixin): + """``c_RegimenFiscal`` — régimen fiscal del emisor y del receptor del CFDI. + + Las banderas indican a qué tipo de persona aplica el régimen: una persona física + no puede declararse en el 601 (General de Ley Personas Morales) y viceversa. + """ + + __tablename__ = "tax_regimes" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True) + applies_to_individual: Mapped[bool] = mapped_column( # persona física + Boolean, nullable=False, server_default=text("false") + ) + applies_to_legal_entity: Mapped[bool] = mapped_column( # persona moral + Boolean, nullable=False, server_default=text("false") + ) + + +class Tax(Base, SatCatalogMixin): + """``c_Impuesto`` — impuestos federales que pueden trasladarse o retenerse.""" + + __tablename__ = "taxes" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True) + is_withholding: Mapped[bool] = mapped_column( # puede retenerse + Boolean, nullable=False, server_default=text("false") + ) + is_transferred: Mapped[bool] = mapped_column( # puede trasladarse + Boolean, nullable=False, server_default=text("false") + ) + # Los impuestos locales (ISH y similares) viajan en el complemento "Impuestos + # Locales" con claves ajenas a c_Impuesto; la bandera queda disponible para + # cuando el negocio defina ese catálogo. + is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + + +class PaymentForm(Base, SatCatalogMixin): + """``c_FormaPago`` — con qué se pagó (efectivo, transferencia, tarjeta…).""" + + __tablename__ = "payment_forms" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True) + + +class UnitOfMeasure(Base, SatCatalogMixin): + """``c_ClaveUnidad`` — unidad de medida de la partida. + + Único catálogo que separa nombre corto y definición: ``name`` es lo que se + muestra al capturar y ``description`` la nota larga del SAT, que puede venir + vacía. + """ + + __tablename__ = "units_of_measure" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + symbol: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Se redeclara para permitir NULL: aquí la descripción es la nota del catálogo. + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + + +class ProductService(Base, SatCatalogMixin): + """``c_ClaveProdServ`` — clave de producto o servicio de la partida.""" + + __tablename__ = "products_services" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(8), nullable=False, unique=True, index=True) + + +class VoucherType(Base, SatCatalogMixin): + """``c_TipoDeComprobante`` — I ingreso, E egreso, T traslado, N nómina, P pago.""" + + __tablename__ = "voucher_types" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(1), nullable=False, unique=True, index=True) + + +class PaymentMethod(Base, SatCatalogMixin): + """``c_MetodoPago`` — PUE (una sola exhibición) o PPD (parcialidades/diferido).""" + + __tablename__ = "payment_methods" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True) + + +class TaxObject(Base, SatCatalogMixin): + """``c_ObjetoImp`` — si la partida es o no objeto de impuesto.""" + + __tablename__ = "tax_objects" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True) + + +class CfdiUse(Base, SatCatalogMixin): + """``c_UsoCFDI`` — uso que el receptor le dará al comprobante. + + Lo declara el receptor, no el emisor, y el SAT lo valida contra su régimen + fiscal: por eso vive en la ficha del cliente (``crm.accounts.cfdi_use_id``). + """ + + __tablename__ = "cfdi_uses" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(4), nullable=False, unique=True, index=True) diff --git a/backend/api/v1/modules/fin/catalogs/routes.py b/backend/api/v1/modules/fin/catalogs/routes.py new file mode 100644 index 0000000..5a385b6 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/routes.py @@ -0,0 +1,138 @@ +"""Endpoints de los catálogos del SAT — **solo lectura**. + +No se exponen POST/PUT/PATCH/DELETE a propósito: son catálogos fijos publicados por +el SAT y se mantienen con ``seed_data.sync_catalogs()``, no por API. + +Nota: aunque los catálogos son globales, el router del módulo exige ``fin.access``, +permiso que se resuelve sobre una compañía; por eso las peticiones siguen llevando +``company_id`` en la query string. +""" + +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import ( + CfdiUseResponse, + PaymentFormResponse, + PaymentMethodResponse, + ProductServiceResponse, + TaxObjectResponse, + TaxRegimeResponse, + TaxResponse, + UnitOfMeasureResponse, + VoucherTypeResponse, +) + +router = APIRouter() + +_SEARCH = Query(None, description="Búsqueda por clave o descripción") +_ACTIVE_ONLY = Query(True, description="Solo claves vigentes") + + +@router.get("/catalogs/tax-regimes", response_model=list[TaxRegimeResponse]) +def list_tax_regimes( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + person_type: Literal["fisica", "moral"] | None = Query( + None, description="Acota al régimen de persona física o moral" + ), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_RegimenFiscal`` — régimen fiscal del emisor/receptor del CFDI.""" + return service.get_tax_regimes(db, search, active_only, person_type) + + +@router.get("/catalogs/taxes", response_model=list[TaxResponse]) +def list_taxes( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_Impuesto`` — impuestos federales trasladados y retenidos.""" + return service.get_taxes(db, search, active_only) + + +@router.get("/catalogs/payment-forms", response_model=list[PaymentFormResponse]) +def list_payment_forms( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_FormaPago`` — medio con el que se liquidó el comprobante.""" + return service.get_payment_forms(db, search, active_only) + + +@router.get("/catalogs/units-of-measure", response_model=list[UnitOfMeasureResponse]) +def list_units_of_measure( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_ClaveUnidad`` — unidad de medida de la partida.""" + return service.get_units_of_measure(db, search, active_only) + + +@router.get("/catalogs/products-services", response_model=list[ProductServiceResponse]) +def list_products_services( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + limit: int = Query(50, ge=1, le=200, description="Máximo de claves devueltas"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_ClaveProdServ`` — clave de producto/servicio; pensado para autocompletado.""" + return service.get_products_services(db, search, active_only, limit) + + +@router.get("/catalogs/voucher-types", response_model=list[VoucherTypeResponse]) +def list_voucher_types( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_TipoDeComprobante`` — ingreso, egreso, traslado, nómina o pago.""" + return service.get_voucher_types(db, search, active_only) + + +@router.get("/catalogs/payment-methods", response_model=list[PaymentMethodResponse]) +def list_payment_methods( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_MetodoPago`` — PUE o PPD.""" + return service.get_payment_methods(db, search, active_only) + + +@router.get("/catalogs/tax-objects", response_model=list[TaxObjectResponse]) +def list_tax_objects( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_ObjetoImp`` — si la partida es objeto de impuesto.""" + return service.get_tax_objects(db, search, active_only) + + +@router.get("/catalogs/cfdi-uses", response_model=list[CfdiUseResponse]) +def list_cfdi_uses( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_UsoCFDI`` — uso que el receptor le dará al comprobante.""" + return service.get_cfdi_uses(db, search, active_only) diff --git a/backend/api/v1/modules/fin/catalogs/seed_data.py b/backend/api/v1/modules/fin/catalogs/seed_data.py new file mode 100644 index 0000000..71beb47 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/seed_data.py @@ -0,0 +1,336 @@ +"""Datos semilla de los catálogos del SAT y su sincronización idempotente. + +Los catálogos viven aquí y no dentro de una migración concreta a propósito: cuando el +SAT corrige una descripción o publica una clave nueva, basta editar estas listas y +volver a correr :func:`sync_catalogs`, sin escribir una migración de esquema. + +Las tablas se describen con ``sa.Table`` ligeros sobre un ``MetaData`` propio (no con +los modelos ORM) para que la migración pueda importar este módulo sin acoplarse a la +definición ORM, que sigue evolucionando. +""" + +import sqlalchemy as sa + +_metadata = sa.MetaData() + + +def _catalog_table(name: str, *extra_columns: sa.Column) -> sa.Table: + """Tabla mínima de catálogo: las columnas que toca el upsert, nada más.""" + return sa.Table( + name, + _metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("code", sa.String, nullable=False), + sa.Column("description", sa.String), + sa.Column("is_active", sa.Boolean), + *extra_columns, + schema="sat", + ) + + +tax_regimes_table = _catalog_table( + "tax_regimes", + sa.Column("applies_to_individual", sa.Boolean), + sa.Column("applies_to_legal_entity", sa.Boolean), +) +taxes_table = _catalog_table( + "taxes", + sa.Column("is_withholding", sa.Boolean), + sa.Column("is_transferred", sa.Boolean), + sa.Column("is_local", sa.Boolean), +) +payment_forms_table = _catalog_table("payment_forms") +units_of_measure_table = _catalog_table( + "units_of_measure", + sa.Column("name", sa.String), + sa.Column("symbol", sa.String), +) +products_services_table = _catalog_table("products_services") +voucher_types_table = _catalog_table("voucher_types") +payment_methods_table = _catalog_table("payment_methods") +tax_objects_table = _catalog_table("tax_objects") +cfdi_uses_table = _catalog_table("cfdi_uses") + + +# --------------------------------------------------------------------------- +# c_RegimenFiscal (CFDI 4.0) +# --------------------------------------------------------------------------- + +def _regime(code: str, description: str, individual: bool, legal_entity: bool) -> dict: + return { + "code": code, + "description": description, + "applies_to_individual": individual, + "applies_to_legal_entity": legal_entity, + "is_active": True, + } + + +TAX_REGIMES: list[dict] = [ + _regime("601", "General de Ley Personas Morales", False, True), + _regime("603", "Personas Morales con Fines no Lucrativos", False, True), + _regime("605", "Sueldos y Salarios e Ingresos Asimilados a Salarios", True, False), + _regime("606", "Arrendamiento", True, False), + _regime("607", "Régimen de Enajenación o Adquisición de Bienes", True, False), + _regime("608", "Demás ingresos", True, False), + _regime("610", "Residentes en el Extranjero sin Establecimiento Permanente en México", True, True), + _regime("611", "Ingresos por Dividendos (socios y accionistas)", True, False), + _regime("612", "Personas Físicas con Actividades Empresariales y Profesionales", True, False), + _regime("614", "Ingresos por intereses", True, False), + _regime("615", "Régimen de los ingresos por obtención de premios", True, False), + _regime("616", "Sin obligaciones fiscales", True, False), + _regime("620", "Sociedades Cooperativas de Producción que optan por diferir sus ingresos", False, True), + _regime("621", "Incorporación Fiscal", True, False), + _regime("622", "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras", False, True), + _regime("623", "Opcional para Grupos de Sociedades", False, True), + _regime("624", "Coordinados", False, True), + _regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False), + _regime("626", "Régimen Simplificado de Confianza", True, True), +] + + +# --------------------------------------------------------------------------- +# c_Impuesto +# --------------------------------------------------------------------------- +# is_local queda en false para los tres: los impuestos locales (ISH y similares) +# se declaran en el complemento "Impuestos Locales" con claves que no pertenecen +# a c_Impuesto. No se siembran registros locales inventados. + +TAXES: list[dict] = [ + {"code": "001", "description": "ISR", "is_withholding": True, "is_transferred": False, "is_local": False, "is_active": True}, + {"code": "002", "description": "IVA", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True}, + {"code": "003", "description": "IEPS", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True}, +] + + +# --------------------------------------------------------------------------- +# c_FormaPago +# --------------------------------------------------------------------------- + +PAYMENT_FORMS: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("01", "Efectivo"), + ("02", "Cheque nominativo"), + ("03", "Transferencia electrónica de fondos"), + ("04", "Tarjeta de crédito"), + ("05", "Monedero electrónico"), + ("06", "Dinero electrónico"), + ("08", "Vales de despensa"), + ("12", "Dación en pago"), + ("13", "Pago por subrogación"), + ("14", "Pago por consignación"), + ("15", "Condonación"), + ("17", "Compensación"), + ("23", "Novación"), + ("24", "Confusión"), + ("25", "Remisión de deuda"), + ("26", "Prescripción o caducidad"), + ("27", "A satisfacción del acreedor"), + ("28", "Tarjeta de débito"), + ("29", "Tarjeta de servicios"), + ("30", "Aplicación de anticipos"), + ("31", "Intermediario pagos"), + ("99", "Por definir"), + ] +] + + +# --------------------------------------------------------------------------- +# c_TipoDeComprobante +# --------------------------------------------------------------------------- + +VOUCHER_TYPES: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("I", "Ingreso"), + ("E", "Egreso"), + ("T", "Traslado"), + ("N", "Nómina"), + ("P", "Pago"), + ] +] + + +# --------------------------------------------------------------------------- +# c_MetodoPago +# --------------------------------------------------------------------------- + +PAYMENT_METHODS: list[dict] = [ + {"code": "PUE", "description": "Pago en una sola exhibición", "is_active": True}, + {"code": "PPD", "description": "Pago en parcialidades o diferido", "is_active": True}, +] + + +# --------------------------------------------------------------------------- +# c_ObjetoImp +# --------------------------------------------------------------------------- +# Versiones posteriores del catálogo incorporan las claves 05–07; no se siembran +# hasta que el área Fiscal confirme la versión vigente (ver PENDIENTE DECISIÓN). + +TAX_OBJECTS: list[dict] = [ + {"code": "01", "description": "No objeto de impuesto", "is_active": True}, + {"code": "02", "description": "Sí objeto de impuesto", "is_active": True}, + {"code": "03", "description": "Sí objeto del impuesto y no obligado al desglose", "is_active": True}, + {"code": "04", "description": "Sí objeto del impuesto y no causa impuesto", "is_active": True}, +] + + +# --------------------------------------------------------------------------- +# c_ClaveUnidad — subset operativo +# --------------------------------------------------------------------------- +# description queda en NULL: es la nota larga del catálogo, que aquí no aporta. + +UNITS_OF_MEASURE: list[dict] = [ + {"code": code, "name": name, "symbol": symbol, "description": None, "is_active": True} + for code, name, symbol in [ + ("H87", "Pieza", "pz"), + ("E48", "Unidad de servicio", None), + ("ACT", "Actividad", None), + ("C62", "Uno", None), + ("KGM", "Kilogramo", "kg"), + ("TNE", "Tonelada métrica", "t"), + ("GRM", "Gramo", "g"), + ("LTR", "Litro", "l"), + ("MTR", "Metro", "m"), + ("MTK", "Metro cuadrado", "m²"), + ("MTQ", "Metro cúbico", "m³"), + ("KMT", "Kilómetro", "km"), + ("CMT", "Centímetro", "cm"), + ("DAY", "Día", "d"), + ("HUR", "Hora", "h"), + ("MON", "Mes", None), + ("XBX", "Caja", None), + ("XPK", "Paquete", None), + ("XPX", "Paleta / tarima", None), + ("XLT", "Lote", None), + ("E51", "Trabajo", None), + ] +] + + +# --------------------------------------------------------------------------- +# c_ClaveProdServ — subset de logística +# --------------------------------------------------------------------------- +# Subset inicial de c_ClaveProdServ para agente de carga — pendiente validación con +# área Fiscal antes de producción. El catálogo completo son ~52,000 claves; aquí solo +# se siembran las del giro. Si falta una clave para un caso de uso, se documenta como +# PENDIENTE DECISIÓN: no se deduce ni se inventa. + +PRODUCTS_SERVICES: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("78101500", "Transporte de carga por carretera"), + ("78101600", "Transporte de carga marítimo"), + ("78101700", "Transporte de carga por ferrocarril"), + ("78101800", "Transporte de carga aérea"), + ("78102200", "Servicios postales de paqueteo y courrier"), + ("78121600", "Embalaje"), + ("78131600", "Almacenaje"), + ("78141500", "Servicios de planificación logística"), + ("78141600", "Servicios de expedición de fletes"), + ("84131500", "Seguros de vida, salud y accidentes / seguros de carga"), + ("80101500", "Servicios de consultoría de negocios y administración corporativa"), + ] +] + + +# --------------------------------------------------------------------------- +# c_UsoCFDI +# --------------------------------------------------------------------------- +# Catálogo del uso que el receptor da al comprobante. Se siembran clave y +# descripción; **no** se cargan las banderas de persona física/moral ni la +# compatibilidad por régimen fiscal, porque esa matriz cambia entre versiones del +# catálogo y equivocarla provoca rechazos al timbrar. +# +# Pendiente validación con área Fiscal antes de producción, igual que el subset de +# c_ClaveProdServ. + +CFDI_USES: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("G01", "Adquisición de mercancías"), + ("G02", "Devoluciones, descuentos o bonificaciones"), + ("G03", "Gastos en general"), + ("I01", "Construcciones"), + ("I02", "Mobiliario y equipo de oficina por inversiones"), + ("I03", "Equipo de transporte"), + ("I04", "Equipo de cómputo y accesorios"), + ("I05", "Dados, troqueles, moldes, matrices y herramental"), + ("I06", "Comunicaciones telefónicas"), + ("I07", "Comunicaciones satelitales"), + ("I08", "Otra maquinaria y equipo"), + ("D01", "Honorarios médicos, dentales y gastos hospitalarios"), + ("D02", "Gastos médicos por incapacidad o discapacidad"), + ("D03", "Gastos funerales"), + ("D04", "Donativos"), + ("D05", "Intereses reales efectivamente pagados por créditos hipotecarios (casa habitación)"), + ("D06", "Aportaciones voluntarias al SAR"), + ("D07", "Primas por seguros de gastos médicos"), + ("D08", "Gastos de transportación escolar obligatoria"), + ("D09", "Depósitos en cuentas para el ahorro, primas que tengan como base planes de pensiones"), + ("D10", "Pagos por servicios educativos (colegiaturas)"), + ("S01", "Sin efectos fiscales"), + ("CP01", "Pagos"), + ("CN01", "Nómina"), + ] +] + + +# Orden estable de sincronización: (tabla, filas). +CATALOGS: list[tuple[sa.Table, list[dict]]] = [ + (tax_regimes_table, TAX_REGIMES), + (taxes_table, TAXES), + (payment_forms_table, PAYMENT_FORMS), + (units_of_measure_table, UNITS_OF_MEASURE), + (products_services_table, PRODUCTS_SERVICES), + (voucher_types_table, VOUCHER_TYPES), + (payment_methods_table, PAYMENT_METHODS), + (tax_objects_table, TAX_OBJECTS), + (cfdi_uses_table, CFDI_USES), +] + + +def sync_catalogs(connection) -> dict[str, int]: + """Sincroniza los catálogos del SAT contra la base, de forma idempotente. + + Inserta las claves que faltan y actualiza descripción y banderas de las que ya + existen. **Nunca borra**: una clave retirada por el SAT se desactiva a mano para + no romper los CFDI históricos que la referencian. + + Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de + la migración. + + Los catálogos cuya tabla todavía no existe se omiten: al correr el historial de + migraciones desde cero, una migración antigua invoca esta misma función cuando los + catálogos agregados después aún no se han creado. Cada uno se siembra en la + migración que lo crea. + + Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la + conexión de una sesión en pruebas. + """ + inspector = sa.inspect(connection) + # La inspección no aplica el schema_translate_map (las pruebas mapean sat -> None + # sobre SQLite), así que se resuelve el schema efectivo a mano. + schema_map = connection.get_execution_options().get("schema_translate_map") or {} + + inserted: dict[str, int] = {} + for table, rows in CATALOGS: + effective_schema = schema_map.get(table.schema, table.schema) + if not inspector.has_table(table.name, schema=effective_schema): + continue + key = f"sat.{table.name}" + inserted[key] = 0 + for row in rows: + existing = connection.execute( + sa.select(table.c.id).where(table.c.code == row["code"]) + ).scalar() + values = {k: v for k, v in row.items() if k != "code"} + if existing is None: + connection.execute(table.insert().values(code=row["code"], **values)) + inserted[key] += 1 + else: + connection.execute( + table.update().where(table.c.id == existing).values(**values) + ) + return inserted diff --git a/backend/api/v1/modules/fin/catalogs/service.py b/backend/api/v1/modules/fin/catalogs/service.py new file mode 100644 index 0000000..7ea97b4 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/service.py @@ -0,0 +1,106 @@ +"""Consultas de los catálogos del SAT. + +Son globales (sin tenant_id / company_id) y de solo lectura: aquí no hay altas, +cambios ni bajas, únicamente búsqueda para llenar los selectores de captura. +""" + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from .models import ( + CfdiUse, + PaymentForm, + PaymentMethod, + ProductService, + Tax, + TaxObject, + TaxRegime, + UnitOfMeasure, + VoucherType, +) + +# Catálogos que además del código y la descripción buscan por nombre corto. +_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)} + + +def search_catalog( + db: Session, + model, + search: str | None = None, + active_only: bool = True, + limit: int | None = None, +) -> list: + """Devuelve las claves de un catálogo, filtradas por texto libre. + + ``search`` compara contra la clave o la descripción sin distinguir mayúsculas. + """ + q = db.query(model) + if active_only: + q = q.filter(model.is_active.is_(True)) + if search: + term = f"%{search.strip()}%" + fields = [model.code, model.description] + for extra in _SEARCHABLE_EXTRA_FIELDS.get(model, ()): + fields.append(getattr(model, extra)) + q = q.filter(or_(*[f.ilike(term) for f in fields])) + q = q.order_by(model.code.asc()) + if limit is not None: + q = q.limit(limit) + return q.all() + + +def get_tax_regimes( + db: Session, + search: str | None = None, + active_only: bool = True, + person_type: str | None = None, +) -> list[TaxRegime]: + """``c_RegimenFiscal``, opcionalmente acotado al tipo de persona. + + ``person_type='fisica'`` deja solo los regímenes que puede usar una persona + física; ``'moral'``, los de persona moral. + """ + q = db.query(TaxRegime) + if active_only: + q = q.filter(TaxRegime.is_active.is_(True)) + if search: + term = f"%{search.strip()}%" + q = q.filter(or_(TaxRegime.code.ilike(term), TaxRegime.description.ilike(term))) + if person_type == "fisica": + q = q.filter(TaxRegime.applies_to_individual.is_(True)) + elif person_type == "moral": + q = q.filter(TaxRegime.applies_to_legal_entity.is_(True)) + return q.order_by(TaxRegime.code.asc()).all() + + +def get_taxes(db: Session, search=None, active_only=True) -> list[Tax]: + return search_catalog(db, Tax, search, active_only) + + +def get_payment_forms(db: Session, search=None, active_only=True) -> list[PaymentForm]: + return search_catalog(db, PaymentForm, search, active_only) + + +def get_units_of_measure(db: Session, search=None, active_only=True) -> list[UnitOfMeasure]: + return search_catalog(db, UnitOfMeasure, search, active_only) + + +def get_products_services(db: Session, search=None, active_only=True, limit=50) -> list[ProductService]: + """``c_ClaveProdServ``. Va paginado porque alimenta un autocompletado.""" + return search_catalog(db, ProductService, search, active_only, limit=limit) + + +def get_voucher_types(db: Session, search=None, active_only=True) -> list[VoucherType]: + return search_catalog(db, VoucherType, search, active_only) + + +def get_payment_methods(db: Session, search=None, active_only=True) -> list[PaymentMethod]: + return search_catalog(db, PaymentMethod, search, active_only) + + +def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]: + return search_catalog(db, TaxObject, search, active_only) + + +def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]: + return search_catalog(db, CfdiUse, search, active_only) diff --git a/backend/api/v1/modules/fin/concepts/__init__.py b/backend/api/v1/modules/fin/concepts/__init__.py new file mode 100644 index 0000000..6fd4a40 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/__init__.py @@ -0,0 +1 @@ +"""Catálogo de conceptos de facturación por empresa.""" diff --git a/backend/api/v1/modules/fin/concepts/dto.py b/backend/api/v1/modules/fin/concepts/dto.py new file mode 100644 index 0000000..49f9a62 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/dto.py @@ -0,0 +1,55 @@ +"""Esquemas del catálogo de conceptos de facturación.""" + +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from ..catalogs.dto import ProductServiceResponse, TaxObjectResponse, UnitOfMeasureResponse + + +class ConceptBase(BaseModel): + code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto") + description: str = Field(..., min_length=1, max_length=500) + product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)") + unit_of_measure_id: int | None = None + tax_object_id: int | None = None + unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str = Field("MXN", min_length=3, max_length=3) + is_active: bool = True + notes: str | None = None + + +class ConceptCreate(ConceptBase): + pass + + +class ConceptUpdate(BaseModel): + """Actualización parcial: solo se tocan los campos enviados.""" + + code: str | None = Field(None, min_length=1, max_length=40) + description: str | None = Field(None, min_length=1, max_length=500) + product_service_id: int | None = None + unit_of_measure_id: int | None = None + tax_object_id: int | None = None + unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str | None = Field(None, min_length=3, max_length=3) + is_active: bool | None = None + notes: str | None = None + + +class ConceptResponse(ConceptBase): + """Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + product_service: ProductServiceResponse | None = None + unit_of_measure: UnitOfMeasureResponse | None = None + tax_object: TaxObjectResponse | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/fin/concepts/models.py b/backend/api/v1/modules/fin/concepts/models.py new file mode 100644 index 0000000..96838b5 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/models.py @@ -0,0 +1,67 @@ +"""Catálogo de conceptos de facturación — ``fin.concepts``. + +A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada +concepto que la empresa factura (flete internacional, despacho, almacenaje…) se +registra una vez y queda amarrado a la clave de producto/servicio del SAT que le +corresponde. + +La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos +compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál +descripción corresponde a la clave, así que la unicidad se garantiza por índice y se +valida además en el service para devolver un 409 con mensaje entendible. +""" + +from sqlalchemy import Boolean, ForeignKey, Index, Integer, Numeric, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + +from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones) + +# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja +# lógica libera su clave y su código para uno nuevo. +_ALIVE = text("deleted_at IS NULL") + + +class Concept(Base, TenantScopedMixin, TimestampMixin): + """Concepto facturable de una empresa, ligado a una clave ProdServ del SAT.""" + + __tablename__ = "concepts" + __table_args__ = ( + Index( + "uq_fin_concepts_code", + "tenant_id", "company_id", "code", + unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE, + ), + Index( + "uq_fin_concepts_product_service", + "tenant_id", "company_id", "product_service_id", + unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE, + ), + {"schema": "fin"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto + description: Mapped[str] = mapped_column(String(500), nullable=False) + product_service_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True + ) + unit_of_measure_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.units_of_measure.id"), nullable=True + ) + tax_object_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.tax_objects.id"), nullable=True + ) + unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) + currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # Cargadas con selectinload para que el listado no dispare N+1 consultas. + product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin") + unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin") + tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin") diff --git a/backend/api/v1/modules/fin/concepts/routes.py b/backend/api/v1/modules/fin/concepts/routes.py new file mode 100644 index 0000000..cce3e45 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/routes.py @@ -0,0 +1,96 @@ +"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa).""" + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from api.v1.modules.core.permissions.dependencies import PermissionChecker +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import ConceptCreate, ConceptResponse, ConceptUpdate + +router = APIRouter() + + +def _uid(current_user: dict) -> str | None: + return current_user.get("sub") or current_user.get("id") + + +@router.get( + "/concepts", + response_model=list[ConceptResponse], + dependencies=[Depends(PermissionChecker(["fin.concept.view"]))], +) +def list_concepts( + company_id: int = Query(..., description="Company ID"), + search: str | None = Query(None, description="Búsqueda por clave o descripción"), + active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"), + product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.get_concepts( + db, current_user["tenant_id"], company_id, search, active_only, product_service_id + ) + + +@router.get( + "/concepts/{concept_id}", + response_model=ConceptResponse, + dependencies=[Depends(PermissionChecker(["fin.concept.view"]))], +) +def get_concept( + concept_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.get_concept(db, concept_id, current_user["tenant_id"], company_id) + + +@router.post( + "/concepts", + response_model=ConceptResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(PermissionChecker(["fin.concept.create"]))], +) +def create_concept( + payload: ConceptCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user)) + + +@router.patch( + "/concepts/{concept_id}", + response_model=ConceptResponse, + dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))], +) +def update_concept( + concept_id: int, + payload: ConceptUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.update_concept( + db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user) + ) + + +@router.delete( + "/concepts/{concept_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))], +) +def delete_concept( + concept_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Baja lógica del concepto (``deleted_at``).""" + service.delete_concept(db, concept_id, current_user["tenant_id"], company_id) diff --git a/backend/api/v1/modules/fin/concepts/service.py b/backend/api/v1/modules/fin/concepts/service.py new file mode 100644 index 0000000..c3aeb09 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/service.py @@ -0,0 +1,146 @@ +"""Lógica del catálogo de conceptos de facturación. + +Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``: +el catálogo es privado de cada empresa dentro de cada tenant. +""" + +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure +from .dto import ConceptCreate, ConceptUpdate +from .models import Concept + + +def _check_sat_refs(db: Session, data: dict) -> None: + """Verifica que las claves del SAT referidas existan antes de guardar.""" + for field, model, msg in [ + ("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"), + ("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"), + ("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"), + ]: + value = data.get(field) + if field in data and value is not None: + if db.query(model.id).filter(model.id == value).first() is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg) + + +def _check_unique( + db: Session, + tenant_id: int, + company_id: int, + code: str | None, + product_service_id: int | None, + exclude_id: int | None = None, +) -> None: + """Aplica en el service las mismas reglas que los índices únicos parciales. + + Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se + traduce a un 409 con mensaje en español. + """ + base = db.query(Concept).filter( + Concept.tenant_id == tenant_id, + Concept.company_id == company_id, + Concept.deleted_at.is_(None), + ) + if exclude_id is not None: + base = base.filter(Concept.id != exclude_id) + + if code is not None and base.filter(Concept.code == code).first() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Ya existe un concepto con la clave '{code}' en esta empresa", + ) + # Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa. + if product_service_id is not None and base.filter( + Concept.product_service_id == product_service_id + ).first() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa", + ) + + +def get_concepts( + db: Session, + tenant_id: int, + company_id: int, + search: str | None = None, + active_only: bool | None = None, + product_service_id: int | None = None, +) -> list[Concept]: + q = db.query(Concept).filter( + Concept.tenant_id == tenant_id, + Concept.company_id == company_id, + Concept.deleted_at.is_(None), + ) + if active_only is not None: + q = q.filter(Concept.is_active.is_(active_only)) + if product_service_id is not None: + q = q.filter(Concept.product_service_id == product_service_id) + if search: + term = f"%{search.strip()}%" + q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term))) + return q.order_by(Concept.code.asc()).all() + + +def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept: + obj = db.query(Concept).filter( + Concept.id == concept_id, + Concept.tenant_id == tenant_id, + Concept.company_id == company_id, + Concept.deleted_at.is_(None), + ).first() + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado") + return obj + + +def create_concept( + db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None +) -> Concept: + data = payload.model_dump() + _check_sat_refs(db, data) + _check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"]) + obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_concept( + db: Session, + concept_id: int, + payload: ConceptUpdate, + tenant_id: int, + company_id: int, + user_id: str | None = None, +) -> Concept: + obj = get_concept(db, concept_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + _check_sat_refs(db, data) + _check_unique( + db, + tenant_id, + company_id, + data.get("code"), + data.get("product_service_id"), + exclude_id=obj.id, + ) + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj + + +def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None: + """Baja lógica: libera la clave ProdServ y el código para un concepto nuevo.""" + obj = get_concept(db, concept_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index c529684..121613f 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -10,7 +10,16 @@ class InvoiceClientReviewInput(BaseModel): notes: str | None = None -class InvoiceItemBase(BaseModel): +class InvoiceItemSatFields(BaseModel): + """Claves fiscales de la partida. Opcionales: las facturas previas no las tienen.""" + + concept_id: int | None = None + product_service_id: int | None = None + unit_of_measure_id: int | None = None + tax_object_id: int | None = None + + +class InvoiceItemBase(InvoiceItemSatFields): concept: str = Field(..., max_length=60) description: str | None = Field(None, max_length=255) quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2) @@ -19,9 +28,11 @@ class InvoiceItemBase(BaseModel): class InvoiceItemCreate(InvoiceItemBase): invoice_id: int + # Opcional solo si viene concept_id: el service copia la descripción del concepto. + concept: str | None = Field(None, max_length=60) -class InvoiceItemUpdate(BaseModel): +class InvoiceItemUpdate(InvoiceItemSatFields): concept: str | None = Field(None, max_length=60) description: str | None = Field(None, max_length=255) quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2) @@ -76,6 +87,11 @@ class InvoiceBase(BaseModel): bank_info: str | None = None notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) + # ----- Claves fiscales del CFDI (opcionales mientras no se timbre) ----- + voucher_type_id: int | None = None + payment_form_id: int | None = None + payment_method_id: int | None = None + expedition_zip_code: str | None = Field(None, max_length=5) class InvoiceCreate(InvoiceBase): @@ -94,6 +110,10 @@ class InvoiceUpdate(BaseModel): bank_info: str | None = None notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) + voucher_type_id: int | None = None + payment_form_id: int | None = None + payment_method_id: int | None = None + expedition_zip_code: str | None = Field(None, max_length=5) class InvoiceResponse(InvoiceBase): diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py index dd53a6f..147aa6b 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -1,11 +1,22 @@ from datetime import date, datetime -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base +from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK) + PaymentForm, + PaymentMethod, + ProductService, + Tax, + TaxObject, + UnitOfMeasure, + VoucherType, +) +from ..concepts.models import Concept # noqa: F401 + class Invoice(Base, TenantScopedMixin, TimestampMixin): """Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente.""" @@ -51,6 +62,18 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin): owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + # ----- Datos fiscales del CFDI (catálogos SAT) ----- + # Nullables: las facturas emitidas antes de existir los catálogos no los tienen. + voucher_type_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.voucher_types.id"), nullable=True + ) + payment_form_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.payment_forms.id"), nullable=True + ) + payment_method_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.payment_methods.id"), nullable=True + ) + expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True) class InvoiceItem(Base, TenantScopedMixin, TimestampMixin): @@ -63,10 +86,54 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin): invoice_id: Mapped[int] = mapped_column( Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True ) + # Texto libre histórico: lo consume el PDF actual y se conserva obligatorio. concept: Mapped[str] = mapped_column(String(60), nullable=False) description: Mapped[str | None] = mapped_column(String(255), nullable=True) quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1")) unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) + # ----- Datos fiscales de la partida (catálogos SAT) ----- + concept_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True + ) + product_service_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.products_services.id"), nullable=True + ) + unit_of_measure_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.units_of_measure.id"), nullable=True + ) + tax_object_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.tax_objects.id"), nullable=True + ) + + +class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin): + """Impuesto trasladado o retenido de una partida de la factura. + + Es captura de detalle fiscal para el futuro CFDI: **no** interviene en el cálculo + de subtotal/IVA/total de la factura, que sigue saliendo de ``invoices.tax_rate``. + """ + + __tablename__ = "invoice_item_taxes" + __table_args__ = ( + Index( + "uq_fin_invoice_item_taxes", + "invoice_item_id", "tax_id", "is_withholding", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + sqlite_where=text("deleted_at IS NULL"), + ), + {"schema": "fin"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + invoice_item_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True + ) + tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False) + # false = trasladado (se cobra al cliente); true = retenido + is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000 + amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) class Payment(Base, TenantScopedMixin, TimestampMixin): diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 82722c8..912f7f0 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -11,6 +11,7 @@ from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote, QuoteItem from api.v1.modules.ops.shipments.models import Shipment +from ..concepts.models import Concept from .dto import ( InvoiceClientReviewInput, InvoiceCreate, @@ -344,9 +345,50 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem: return obj +# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían. +_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id") + + +def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: + """Completa la partida a partir del concepto del catálogo. + + Hereda dos cosas cuando el cliente no las manda: + + - ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así + que ahí va la descripción del concepto (recortada al largo de la columna). + - Las claves fiscales (``product_service_id``, ``unit_of_measure_id``, + ``tax_object_id``): sin ellas la partida capturada por catálogo quedaría + incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo, + para poder facturar una partida con una unidad distinta a la del concepto. + """ + concept_id = data.get("concept_id") + if concept_id is not None: + catalog_concept = db.query(Concept).filter( + Concept.id == concept_id, Concept.tenant_id == tenant_id, + Concept.company_id == company_id, Concept.deleted_at.is_(None), + ).first() + if not catalog_concept: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="El concepto del catálogo no existe en esta empresa", + ) + if not data.get("concept"): + data["concept"] = catalog_concept.description[:60] + for field in _CONCEPT_INHERITED_FIELDS: + if data.get(field) is None: + data[field] = getattr(catalog_concept, field) + if not data.get("concept"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="La partida requiere un concepto o una referencia al catálogo de conceptos", + ) + + def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem: invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id) - item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) + data = payload.model_dump() + _resolve_item_concept(db, data, tenant_id, company_id) + item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id) db.add(item) db.flush() _recompute(db, invoice) @@ -357,7 +399,12 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem: item = _get_item(db, item_id, tenant_id, company_id) - for f, v in payload.model_dump(exclude_unset=True).items(): + data = payload.model_dump(exclude_unset=True) + # Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar + # descripción y claves fiscales del concepto nuevo. + if data.get("concept_id") is not None: + _resolve_item_concept(db, data, tenant_id, company_id) + for f, v in data.items(): setattr(item, f, v) db.flush() _recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id)) diff --git a/backend/api/v1/modules/fin/issuer/__init__.py b/backend/api/v1/modules/fin/issuer/__init__.py new file mode 100644 index 0000000..83dc841 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/__init__.py @@ -0,0 +1 @@ +"""Datos fiscales del emisor por empresa.""" diff --git a/backend/api/v1/modules/fin/issuer/dto.py b/backend/api/v1/modules/fin/issuer/dto.py new file mode 100644 index 0000000..9f8df25 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/dto.py @@ -0,0 +1,60 @@ +"""Esquemas de los datos fiscales del emisor.""" + +import re +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ..catalogs.dto import TaxRegimeResponse + +# RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. +RFC_PATTERN = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$") +ZIP_PATTERN = re.compile(r"^\d{5}$") + + +class IssuerSettingsInput(BaseModel): + """Alta o actualización de los datos fiscales del emisor.""" + + legal_name: str = Field(..., min_length=1, max_length=255, description="Razón social") + rfc: str = Field(..., max_length=13, description="RFC del emisor") + tax_regime_id: int = Field(..., description="Régimen fiscal (c_RegimenFiscal)") + zip_code: str | None = Field(None, max_length=5, description="CP del lugar de expedición") + + # mode="before": la normalización corre antes que el max_length del campo, para que + # un RFC con espacios de sobra no se rechace por longitud antes de limpiarlo. + @field_validator("rfc", mode="before") + @classmethod + def _validate_rfc(cls, value: str) -> str: + """Normaliza a mayúsculas sin espacios y valida el formato oficial del RFC.""" + if not isinstance(value, str): + raise ValueError("El RFC debe ser texto") + normalized = value.replace(" ", "").replace("-", "").upper() + if not RFC_PATTERN.match(normalized): + raise ValueError("El RFC no tiene un formato válido (ej. XAXX010101000)") + return normalized + + @field_validator("zip_code") + @classmethod + def _validate_zip(cls, value: str | None) -> str | None: + if value is None or value == "": + return None + normalized = value.strip() + if not ZIP_PATTERN.match(normalized): + raise ValueError("El código postal debe tener 5 dígitos") + return normalized + + +class IssuerSettingsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + legal_name: str + rfc: str + tax_regime_id: int + tax_regime: TaxRegimeResponse | None = None + zip_code: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/fin/issuer/models.py b/backend/api/v1/modules/fin/issuer/models.py new file mode 100644 index 0000000..85eb025 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/models.py @@ -0,0 +1,42 @@ +"""Datos fiscales del emisor — ``fin.issuer_settings``. + +Es la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen +fiscal y código postal del lugar de expedición. Hay **una sola configuración vigente +por empresa**, garantizada con un índice único parcial. +""" + +from sqlalchemy import ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + +from ..catalogs.models import TaxRegime # noqa: F401 (resuelve la relación) + +_ALIVE = text("deleted_at IS NULL") + + +class IssuerSettings(Base, TenantScopedMixin, TimestampMixin): + """Configuración fiscal del emisor de la empresa.""" + + __tablename__ = "issuer_settings" + __table_args__ = ( + Index( + "uq_fin_issuer_settings_company", + "tenant_id", "company_id", + unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE, + ), + {"schema": "fin"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + legal_name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social + rfc: Mapped[str] = mapped_column(String(13), nullable=False) + tax_regime_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sat.tax_regimes.id"), nullable=False, index=True + ) + # CP del lugar de expedición del comprobante + zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + + tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin") diff --git a/backend/api/v1/modules/fin/issuer/routes.py b/backend/api/v1/modules/fin/issuer/routes.py new file mode 100644 index 0000000..3f60490 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/routes.py @@ -0,0 +1,48 @@ +"""Endpoints de los datos fiscales del emisor (una configuración por empresa).""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from api.v1.modules.core.permissions.dependencies import PermissionChecker +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import IssuerSettingsInput, IssuerSettingsResponse + +router = APIRouter() + + +@router.get( + "/settings/issuer", + response_model=IssuerSettingsResponse, + dependencies=[Depends(PermissionChecker(["fin.settings.view"]))], +) +def get_issuer_settings( + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Devuelve 404 mientras la empresa no haya capturado sus datos fiscales.""" + return service.get_issuer_settings(db, current_user["tenant_id"], company_id) + + +@router.put( + "/settings/issuer", + response_model=IssuerSettingsResponse, + dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))], +) +def save_issuer_settings( + payload: IssuerSettingsInput, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Alta o actualización (upsert) de los datos fiscales del emisor.""" + return service.save_issuer_settings( + db, + payload, + current_user["tenant_id"], + company_id, + current_user.get("sub") or current_user.get("id"), + ) diff --git a/backend/api/v1/modules/fin/issuer/service.py b/backend/api/v1/modules/fin/issuer/service.py new file mode 100644 index 0000000..bc170d9 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/service.py @@ -0,0 +1,58 @@ +"""Lógica de los datos fiscales del emisor. + +Una empresa tiene, a lo más, una configuración vigente: el guardado es un upsert, no +un alta que pueda duplicar filas. +""" + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from ..catalogs.models import TaxRegime +from .dto import IssuerSettingsInput +from .models import IssuerSettings + + +def _find(db: Session, tenant_id: int, company_id: int) -> IssuerSettings | None: + return db.query(IssuerSettings).filter( + IssuerSettings.tenant_id == tenant_id, + IssuerSettings.company_id == company_id, + IssuerSettings.deleted_at.is_(None), + ).first() + + +def get_issuer_settings(db: Session, tenant_id: int, company_id: int) -> IssuerSettings: + obj = _find(db, tenant_id, company_id) + if not obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="La empresa aún no tiene datos fiscales del emisor configurados", + ) + return obj + + +def save_issuer_settings( + db: Session, + payload: IssuerSettingsInput, + tenant_id: int, + company_id: int, + user_id: str | None = None, +) -> IssuerSettings: + """Crea la configuración la primera vez y la actualiza en adelante.""" + if db.query(TaxRegime.id).filter(TaxRegime.id == payload.tax_regime_id).first() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="El régimen fiscal indicado no existe en el catálogo del SAT", + ) + + obj = _find(db, tenant_id, company_id) + data = payload.model_dump() + if obj is None: + obj = IssuerSettings(**data, tenant_id=tenant_id, company_id=company_id, updated_by=user_id) + db.add(obj) + else: + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj diff --git a/backend/api/v1/modules/fin/permissions.py b/backend/api/v1/modules/fin/permissions.py index 068fb98..366da64 100644 --- a/backend/api/v1/modules/fin/permissions.py +++ b/backend/api/v1/modules/fin/permissions.py @@ -3,7 +3,7 @@ from api.v1.modules.core.permissions.registry import registry MODULE = "fin" -_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")] +_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")] _ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")] @@ -12,6 +12,11 @@ def register_permissions() -> None: for entity, label in _ENTITIES: for action, verb in _ACTIONS: registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action) + # Datos fiscales del emisor: es configuración de la empresa, no una entidad con CRUD, + # así que solo tiene ver/editar. Los catálogos del SAT no llevan permiso propio: + # son globales y de solo lectura, basta con fin.access. + registry.register(code=f"{MODULE}.settings.view", description="Ver datos fiscales del emisor", module=MODULE, action="view") + registry.register(code=f"{MODULE}.settings.edit", description="Editar datos fiscales del emisor", module=MODULE, action="edit") register_permissions() diff --git a/backend/api/v1/modules/fin/router.py b/backend/api/v1/modules/fin/router.py index 9cc824c..ee82017 100644 --- a/backend/api/v1/modules/fin/router.py +++ b/backend/api/v1/modules/fin/router.py @@ -5,8 +5,14 @@ from fastapi import APIRouter, Depends from api.v1.modules.core.permissions.dependencies import PermissionChecker from . import permissions # noqa: F401 (side-effect: registra permisos) +from .catalogs.routes import router as catalogs_router +from .concepts.routes import router as concepts_router from .invoices.routes import router as invoices_router +from .issuer.routes import router as issuer_router # Enforcement por área/carril (R-T-07): se exige fin.access para el módulo. router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))]) +router.include_router(catalogs_router) +router.include_router(concepts_router) +router.include_router(issuer_router) router.include_router(invoices_router) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cf070ca..b428706 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -40,9 +40,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. @@ -78,6 +82,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: diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py new file mode 100644 index 0000000..b0a432a --- /dev/null +++ b/backend/tests/test_fin_sat_catalogs.py @@ -0,0 +1,444 @@ +"""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, AccountUpdate +from api.v1.modules.fin.catalogs.models import CfdiUse, ProductService, TaxObject, TaxRegime, UnitOfMeasure +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, + InvoiceItemResponse, + InvoiceItemUpdate, +) +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_of_measure_id=db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "E48").one().id, + tax_object_id=db.query(TaxObject).filter(TaxObject.code == "02").one().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"), + ("cfdi-uses", 24, "G03"), +] + + +@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 + + # La respuesta expone las claves fiscales: el frontend etiqueta la partida con ellas. + payload = InvoiceItemResponse.model_validate(item).model_dump() + assert payload["concept_id"] == concept.id + assert payload["concept"] == concept.description + assert {"product_service_id", "unit_of_measure_id", "tax_object_id"} <= payload.keys() + + # 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_invoice_item_inherits_sat_keys_from_concept(db): + """La partida hereda las claves fiscales del concepto para quedar completa (CFDI).""" + concept = concepts_service.create_concept(db, _concept_payload(db), T, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-4"), T, C) + + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=1500), T, C + ) + assert item.product_service_id == concept.product_service_id + assert item.unit_of_measure_id == concept.unit_of_measure_id + assert item.tax_object_id == concept.tax_object_id + + +def test_invoice_item_sat_keys_sent_by_client_win_over_concept(db): + """Lo que el cliente envía manda: permite facturar con otra unidad de medida.""" + concept = concepts_service.create_concept(db, _concept_payload(db), T, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-5"), T, C) + other_unit = db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "KGM").one() + + item = invoices_service.create_item( + db, + InvoiceItemCreate( + invoice_id=invoice.id, concept_id=concept.id, unit_of_measure_id=other_unit.id, unit_amount=10 + ), + T, + C, + ) + assert item.unit_of_measure_id == other_unit.id + assert item.product_service_id == concept.product_service_id # el resto sí se hereda + + +def test_changing_item_concept_reinherits_keys(db): + """Cambiar el concepto de una partida revalida y vuelve a heredar del nuevo.""" + first = concepts_service.create_concept(db, _concept_payload(db), T, C) + second = concepts_service.create_concept( + db, _concept_payload(db, code="DESPACHO", ps_code="78141600"), T, C + ) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-6"), T, C) + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=first.id, unit_amount=100), T, C + ) + + updated = invoices_service.update_item( + db, item.id, InvoiceItemUpdate(concept_id=second.id), T, C + ) + assert updated.concept_id == second.id + assert updated.product_service_id == second.product_service_id + assert updated.concept == second.description + + +def test_updating_item_rejects_concept_from_another_tenant(db): + """El PATCH valida la referencia igual que el alta: no cruza tenants.""" + mine = concepts_service.create_concept(db, _concept_payload(db), T, C) + alien = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-7"), T, C) + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=mine.id, unit_amount=100), T, C + ) + + with pytest.raises(HTTPException) as exc: + invoices_service.update_item(db, item.id, InvoiceItemUpdate(concept_id=alien.id), T, C) + assert exc.value.status_code == 422 + + +# ---------- Claves fiscales del receptor (crm.accounts) ---------- + +def test_account_accepts_sat_fiscal_keys(db): + regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one() + cfdi_use = db.query(CfdiUse).filter(CfdiUse.code == "G03").one() + + account = accounts_service.create_account( + db, + AccountCreate(name="Cliente fiscal", tax_regime_id=regime.id, cfdi_use_id=cfdi_use.id), + T, + C, + ) + assert account.tax_regime_id == regime.id and account.cfdi_use_id == cfdi_use.id + + +def test_account_rejects_unknown_sat_fiscal_keys(db): + with pytest.raises(HTTPException) as exc: + accounts_service.create_account(db, AccountCreate(name="Cliente malo", cfdi_use_id=999999), T, C) + assert exc.value.status_code == 422 + + account = accounts_service.create_account(db, AccountCreate(name="Cliente ok"), T, C) + with pytest.raises(HTTPException) as exc: + accounts_service.update_account(db, account.id, AccountUpdate(tax_regime_id=999999), T, C) + assert exc.value.status_code == 422 + + +def test_account_free_text_fiscal_fields_are_preserved(db): + """El texto libre previo se conserva: las FK lo complementan, no lo sustituyen.""" + account = accounts_service.create_account( + db, AccountCreate(name="Cliente heredado", tax_regime="601", cfdi_use="G03"), T, C + ) + assert account.tax_regime == "601" and account.cfdi_use == "G03" + assert account.tax_regime_id is None and account.cfdi_use_id is None + + +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 diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index f20f47a..cdfd9bf 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -31,8 +31,11 @@ export interface Account { phone: string | null; website: string | null; commercial_observations: string | null; + /** Texto libre histórico; lo que vale al timbrar son las claves del SAT de abajo. */ tax_regime: string | null; cfdi_use: string | null; + tax_regime_id: number | null; + cfdi_use_id: number | null; payment_method: string | null; payment_form: string | null; currency: string | null; diff --git a/frontend/src/lib/api/fin/catalogs.test.ts b/frontend/src/lib/api/fin/catalogs.test.ts new file mode 100644 index 0000000..29ab94f --- /dev/null +++ b/frontend/src/lib/api/fin/catalogs.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/api/fin/catalogs.ts b/frontend/src/lib/api/fin/catalogs.ts new file mode 100644 index 0000000..66a52a6 --- /dev/null +++ b/frontend/src/lib/api/fin/catalogs.ts @@ -0,0 +1,96 @@ +/** + * Cliente API — Catálogos del SAT (solo lectura). + * + * Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la + * sesión, así que se guardan en un `Map` del módulo para no repetir la petición en + * cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone. + */ +import { api } from '$lib/api'; + +export interface SatCatalogItem { + id: number; + code: string; + description: string; + is_active: boolean; +} + +export interface SatTaxRegime extends SatCatalogItem { + applies_to_individual: boolean; // persona física + applies_to_legal_entity: boolean; // persona moral +} + +export interface SatTax extends SatCatalogItem { + is_withholding: boolean; + is_transferred: boolean; + is_local: boolean; +} + +/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */ +export interface SatUnitOfMeasure extends Omit { + description: string | null; + name: string; + symbol: string | null; +} + +export type PersonType = 'fisica' | 'moral'; + +type CatalogParams = Record; + +/** Cache en memoria del módulo, con la query string completa como llave. */ +const cache = new Map(); + +function buildQuery(companyId: number, params?: CatalogParams): string { + const qs = new URLSearchParams({ company_id: String(companyId) }); + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined && value !== '') qs.set(key, String(value)); + } + qs.sort(); // llave de cache estable sin importar el orden de los parámetros + return qs.toString(); +} + +async function fetchCatalog( + path: string, + companyId: number, + params?: CatalogParams +): Promise { + const query = buildQuery(companyId, params); + const key = `${path}?${query}`; + const cached = cache.get(key); + if (cached) return cached as T[]; + + const res = await api.get(`/v1/fin/catalogs/${path}?${query}`); + if (res.error) throw new Error(res.error); + const rows = res.data ?? []; + cache.set(key, rows); + return rows; +} + +/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */ +export function clearCatalogCache(): void { + cache.clear(); +} + +export const satCatalogsAPI = { + taxRegimes: ( + companyId: number, + params?: { search?: string; person_type?: PersonType; active_only?: boolean } + ) => fetchCatalog('tax-regimes', companyId, params), + taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('taxes', companyId, params), + paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('payment-forms', companyId, params), + unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('units-of-measure', companyId, params), + productsServices: ( + companyId: number, + params?: { search?: string; limit?: number; active_only?: boolean } + ) => fetchCatalog('products-services', companyId, params), + voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('voucher-types', companyId, params), + paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('payment-methods', companyId, params), + taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('tax-objects', companyId, params), + cfdiUses: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('cfdi-uses', companyId, params) +}; diff --git a/frontend/src/lib/api/fin/concepts.ts b/frontend/src/lib/api/fin/concepts.ts new file mode 100644 index 0000000..975aba2 --- /dev/null +++ b/frontend/src/lib/api/fin/concepts.ts @@ -0,0 +1,81 @@ +/** + * Cliente API — Catálogo de conceptos de facturación. + * + * Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la + * empresa; el backend responde 409 si la clave ya está tomada. + */ +import { api } from '$lib/api'; +import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs'; + +export interface Concept { + id: number; + code: string; + description: string; + product_service_id: number; + unit_of_measure_id: number | null; + tax_object_id: number | null; + unit_price: number | null; + currency: string; + is_active: boolean; + notes: string | null; + product_service: SatCatalogItem | null; + unit_of_measure: SatUnitOfMeasure | null; + tax_object: SatCatalogItem | null; + tenant_id: number; + company_id: number; + created_by: string | null; + updated_by: string | null; + created_at: string; + updated_at: string; +} + +export interface ConceptInput { + code: string; + description: string; + product_service_id: number; + unit_of_measure_id?: number | null; + tax_object_id?: number | null; + unit_price?: number | null; + currency?: string; + is_active?: boolean; + notes?: string | null; +} + +export const conceptsAPI = { + async list( + companyId: number, + params?: { search?: string; active_only?: boolean; product_service_id?: number } + ): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.search) qs.set('search', params.search); + if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only)); + if (params?.product_service_id !== undefined) + qs.set('product_service_id', String(params.product_service_id)); + const res = await api.get(`/v1/fin/concepts?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/v1/fin/concepts/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async create(data: ConceptInput, companyId: number): Promise { + const res = await api.post(`/v1/fin/concepts?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/v1/fin/concepts/${id}?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async remove(id: number, companyId: number): Promise { + const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index e04e708..0d2bbde 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -3,6 +3,10 @@ */ import { api } from '$lib/api'; +export * from './catalogs'; +export * from './concepts'; +export * from './issuer'; + export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada'; export interface Invoice { @@ -33,6 +37,11 @@ export interface Invoice { owner_user_id: string | null; created_by: string | null; updated_by: string | null; + // Claves fiscales del CFDI (catálogos SAT); nulas mientras no se capturen. + voucher_type_id: number | null; + payment_form_id: number | null; + payment_method_id: number | null; + expedition_zip_code: string | null; tenant_id: number; company_id: number; created_at: string; @@ -43,17 +52,26 @@ export type InvoiceInput = Partial> & { invoice_id: number; - concept: string; }; export interface Payment { diff --git a/frontend/src/lib/api/fin/issuer.ts b/frontend/src/lib/api/fin/issuer.ts new file mode 100644 index 0000000..69cfc9c --- /dev/null +++ b/frontend/src/lib/api/fin/issuer.ts @@ -0,0 +1,51 @@ +/** + * Cliente API — Datos fiscales del emisor (una configuración por empresa). + */ +import { api } from '$lib/api'; +import type { SatTaxRegime } from './catalogs'; + +/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */ +export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/; + +export interface IssuerSettings { + id: number; + tenant_id: number; + company_id: number; + legal_name: string; + rfc: string; + tax_regime_id: number; + tax_regime: SatTaxRegime | null; + zip_code: string | null; + updated_by: string | null; + created_at: string; + updated_at: string; +} + +export interface IssuerSettingsInput { + legal_name: string; + rfc: string; + tax_regime_id: number; + zip_code?: string | null; +} + +export const issuerAPI = { + /** + * Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el + * backend responde 404 y la pantalla debe abrirse en modo alta, no en error. + */ + async get(companyId: number): Promise { + const res = await api.get(`/v1/fin/settings/issuer?company_id=${companyId}`); + if (res.status === 404) return null; + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async save(data: IssuerSettingsInput, companyId: number): Promise { + const res = await api.put( + `/v1/fin/settings/issuer?company_id=${companyId}`, + data + ); + if (res.error) throw new Error(res.error); + return res.data!; + } +}; diff --git a/frontend/src/lib/components/crm/AccountFields.svelte b/frontend/src/lib/components/crm/AccountFields.svelte index 4fb7730..cbe20f4 100644 --- a/frontend/src/lib/components/crm/AccountFields.svelte +++ b/frontend/src/lib/components/crm/AccountFields.svelte @@ -1,11 +1,44 @@ + +
+ + + + + +
+ Clave de producto/servicio del SAT * +

+ Una clave del SAT solo puede estar asignada a un concepto de la empresa. +

+ {#if productService} +
+ + {productService.code} + — {productService.description} + + +
+ {:else} + + {#if searchingProductService} +

Buscando…

+ {:else if productServiceOptions.length > 0} +
    + {#each productServiceOptions as option (option.id)} +
  • + +
  • + {/each} +
+ {:else if productServiceQuery.trim().length >= 2} +

Sin coincidencias en el catálogo.

+ {/if} + {/if} + {#if productServiceError} +

{productServiceError}

+ {/if} +
+ + + + + + + + +
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 59f69d9..e4068ea 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -73,6 +73,7 @@ export function getNavMain(): NavMainItem[] { icon: Receipt, items: [ { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, + { title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' }, ], }, { @@ -97,6 +98,7 @@ export function getNavMain(): NavMainItem[] { items: [ { title: 'General', url: '/dashboard/settings/general' }, { title: 'Formato de cotización', url: '/dashboard/settings/cotizacion' }, + { title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' }, ], }, ]; diff --git a/frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte index 1621ae2..4ba0e66 100644 --- a/frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte @@ -108,7 +108,7 @@ {#if activeTab.kind === 'info'} - +
diff --git a/frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte index 27dcd6b..637a9db 100644 --- a/frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte @@ -65,7 +65,7 @@ {/each} - +
diff --git a/frontend/src/routes/dashboard/fin/conceptos/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte new file mode 100644 index 0000000..11c1304 --- /dev/null +++ b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte @@ -0,0 +1,181 @@ + + + + Conceptos de facturación + + +
+
+
+

+ + Conceptos de facturación +

+

+ Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la + empresa. +

+
+ +
+ + + +
+
+ + companyId && load(companyId)} + /> +
+ +
+
+ + {#if loading} +

Cargando…

+ {:else if items.length === 0} +

Sin conceptos registrados.

+ {:else} +
+ + + + Clave + Descripción + Clave ProdServ + Unidad + Objeto de impuesto + Precio unitario + Estado + Acciones + + + + {#each items as concept (concept.id)} + + + + {concept.code} + + + {concept.description} + + {concept.product_service?.code ?? '—'} + {#if concept.product_service} + + {concept.product_service.description} + + {/if} + + {concept.unit_of_measure?.name ?? '—'} + {concept.tax_object?.code ?? '—'} + + {money(concept.unit_price)} + {concept.currency} + + + + {concept.is_active ? 'Activo' : 'Inactivo'} + + + + + + + + {/each} + + +
+ {/if} +
+
+
diff --git a/frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte new file mode 100644 index 0000000..a2ad257 --- /dev/null +++ b/frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte @@ -0,0 +1,155 @@ + + + + {concept ? `Concepto ${concept.code}` : 'Concepto de facturación'} + + +
+ + + {#if loading && !concept} +

Cargando…

+ {:else if concept} +
+
+

+ + {concept.code} +

+

+ {concept.description} + {#if concept.product_service} + · {concept.product_service.code} + {/if} + · {concept.is_active ? 'Activo' : 'Inactivo'} +

+
+ +
+ + + +
+ + +
+ +
+ +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte new file mode 100644 index 0000000..a6f0e12 --- /dev/null +++ b/frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte @@ -0,0 +1,99 @@ + + + + Nuevo concepto de facturación + + +
+ + +
+

+ Nuevo concepto +

+

+ Cada concepto se liga a una clave de producto/servicio del SAT. +

+
+ + + +
+ + +
+ + +
+ +
+
+
diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte index 6d4bdbc..8e085ad 100644 --- a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte @@ -6,8 +6,8 @@ import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; import { - invoicesAPI, invoiceItemsAPI, paymentsAPI, - type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput + invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI, + type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput } from '$lib/api/fin'; import { accountsAPI, type Account } from '$lib/api/crm'; import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format'; @@ -20,6 +20,9 @@ let items = $state([]); let payments = $state([]); let accounts = $state([]); + /** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar + * partidas que apunten a un concepto ya inactivo. */ + let concepts = $state([]); let form = $state({}); let tab = $state('conceptos'); let loading = $state(false); @@ -29,6 +32,10 @@ let addingPay = $state(false); let newItem = $state({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }); let newPay = $state({ invoice_id: 0, amount: 0, method: 'transferencia' }); + /** Opción elegida en el selector de concepto: `cat:` del catálogo o `txt:` genérica. */ + let conceptChoice = $state('txt:flete_internacional'); + + const activeConcepts = $derived(concepts.filter((c) => c.is_active)); $effect(() => { const cid = companyId; @@ -40,8 +47,9 @@ async function load(cid: number, id: number) { loading = true; try { - [invoice, items, payments, accounts] = await Promise.all([ - invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid) + [invoice, items, payments, accounts, concepts] = await Promise.all([ + invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), + accountsAPI.list(cid), conceptsAPI.list(cid) ]); form = { ...invoice }; } catch (e) { @@ -127,7 +135,35 @@ } } - function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; } + function startItem() { + newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; + // Si la empresa ya tiene catálogo, se arranca con su primer concepto. + conceptChoice = activeConcepts.length ? `cat:${activeConcepts[0].id}` : 'txt:flete_internacional'; + applyConceptChoice(); + addingItem = true; + } + + /** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */ + function applyConceptChoice() { + if (conceptChoice.startsWith('cat:')) { + const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4))); + if (!c) return; + // Solo se manda concept_id: el backend copia ahí la descripción del concepto. + newItem.concept_id = c.id; + newItem.concept = undefined; + if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price); + } else { + newItem.concept_id = null; + newItem.concept = conceptChoice.slice(4); + } + } + + /** Etiqueta de la partida: el concepto del catálogo si lo tiene, si no el texto libre. */ + function itemConceptLabel(it: InvoiceItem): string { + const c = it.concept_id ? concepts.find((x) => x.id === it.concept_id) : undefined; + return c ? `${c.code} — ${c.description}` : labelOf(QUOTE_CONCEPTS, it.concept); + } + async function saveItem() { if (!companyId) return; try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); } @@ -207,7 +243,25 @@
{#if addingItem}
- + @@ -222,7 +276,7 @@ {#each items as it (it.id)} - {labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}{it.description}{/if} + {itemConceptLabel(it)}{#if it.description}{it.description}{/if} {it.quantity} {formatMoney(it.unit_amount, invoice.currency)} {formatMoney(it.line_total, invoice.currency)} diff --git a/frontend/src/routes/dashboard/settings/facturacion/+page.svelte b/frontend/src/routes/dashboard/settings/facturacion/+page.svelte new file mode 100644 index 0000000..1113d26 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/facturacion/+page.svelte @@ -0,0 +1,200 @@ + + + + Configuración de Facturación + + +
+
+

+ + Datos fiscales del emisor +

+

+ Identidad fiscal con la que la empresa emite sus comprobantes. +

+
+ + {#if !canView} + + +

+ No tienes permiso para consultar los datos fiscales del emisor. +

+
+
+ {:else} + + + {isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'} + + {isNew + ? 'Esta empresa aún no tiene datos fiscales configurados.' + : 'Actualiza la información con la que se emiten los comprobantes.'} + + + + {#if loading} +

Cargando…

+ {:else} +
+ + + + + + + + +
+ +
+ {#if !canEdit} +

+ Solo puedes consultar: se requiere el permiso de edición de datos fiscales. +

+ {/if} +
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/settings/facturacion/+page.ts b/frontend/src/routes/dashboard/settings/facturacion/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/facturacion/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/dashboard/settings/general/+page.svelte b/frontend/src/routes/dashboard/settings/general/+page.svelte index 0f1206d..d01c340 100644 --- a/frontend/src/routes/dashboard/settings/general/+page.svelte +++ b/frontend/src/routes/dashboard/settings/general/+page.svelte @@ -1,6 +1,10 @@ @@ -18,6 +22,25 @@

+ {#if canViewIssuerSettings} + + + + + Facturación + + + Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición. + + + + + + + {/if} + Configuración del sistema