Jair Cedillo 1f8fdf2866 feat(fin): el IVA se calcula por partida, no con un % global
El impuesto vivía en dos planos que podían divergir: el dinero salía de invoices.tax_rate
aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada partida. Una partida
que no causa IVA se lo cobraba igual, y con una retención capturada la factura pedía 1160
mientras el comprobante declaraba 1060 — cobranza persiguiendo un adeudo inexistente.

Ahora fin.invoice_item_taxes es la fuente del impuesto y _recompute la lee:
total = subtotal + trasladado - retenido, la misma composición del comprobante. Se agrega
withheld_amount, porque sin guardarlo el total no cuadraba con subtotal + tax_amount y nada
en la fila lo explicaba.

NINGUNA factura existente cambia de total. El cálculo se versiona con taxes_per_item: las
nuevas nacen en true, las 9 que ya existían quedaron en false con la fórmula que las emitió.
Backfillear habría exigido poner tax_object_id='02' en partidas que nadie clasificó —
inventar una afirmación fiscal — y _recompute corre desde create_payment, así que un pago
meses después le habría bajado el total, dejado saldo negativo, marcado 'pagada' y pisado su
paid_at. El rollback es un UPDATE.

Conceptos que no causan IVA: fin.concepts gana impuesto, tasa y tipo de factor por defecto,
que la partida hereda como ya heredaba las claves fiscales. Exento (ObjetoImp 02 +
TipoFactor Exento) y tasa 0% son distintos y ahora los dos son expresables; el 0% era
incapturable, el rate==0 borraba el traslado y el timbrado fallaba pidiendo el desglose.

Redondeo: manda el comprobante. subtotal = Σ round(qty × precio) por renglón, no round(Σ),
y tax_amount es la suma de los importes ya materializados, todo ROUND_HALF_UP con el mismo
`cents` que usa el builder. El PAC valida que SubTotal sea la suma de los Importe.

Trampas que el cambio cerró:
- _build_data construía TaxLine sin factor: un exento se habría timbrado como gravado al 0%,
  un CFDI incorrecto que el PAC acepta.
- CfdiData.transferred no excluía Exento mientras _add_totals sí: una fila exenta con importe
  dejaba el XML inconsistente consigo mismo.
- El guard de captura manual era heurístico (retención o impuesto != IVA), así que un IVA al
  8% capturado volvía al 16% por cambiarle la cantidad a la partida. Ahora is_manual es un
  hecho registrado.
- delete_item dejaba los impuestos vivos: cobro fantasma de una partida que ya no existe.
- set_item_tax y delete_item_tax no recalculaban la factura.
- El PDF imprimía "IVA (16%)" y no mostraba retenciones. Ahora desglosa por
  (impuesto, factor, tasa) con el mismo criterio del comprobante, y los exentos se listan con
  su base y sin importe: es lo que explica por qué el total no es subtotal × 1.16.

stamp_invoice verifica que invoice.total sea el del comprobante antes de sellar, y falla en
vez de corregir: el timbrado es donde el dinero se vuelve irreversible y recalcular ahí
cambiaría montos sin que nadie lo vea.

Cuota queda fuera con 422 explícito: su importe es cuota × cantidad, no base × tasa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:37:26 -05:00

CRM — Aduanasoft

Sistema CRM construido sobre la plantilla Workspace de Aduanasoft (SvelteKit 5 + FastAPI + PostgreSQL, multi-tenant con Keycloak/Hub). Gestiona cuentas, contactos, prospectos, oportunidades (pipeline Kanban) y actividades comerciales.

Base: plantillas-proyectos. Este repo conserva el core de la plantilla (auth, tenants, permisos, licencias) y agrega el dominio CRM en backend y frontend.


Stack

Capa Tecnología
Frontend SvelteKit 5 (runes) + Tailwind + shadcn-svelte
Backend FastAPI + SQLAlchemy 2.0 + Pydantic v2
Auth Keycloak (OIDC) vía Workspace Hub
Base de datos PostgreSQL (schema crm)
Cache / Queue Valkey (Redis) + Celery
Storage MinIO (S3-compatible)
Contenedores Docker Compose

Módulo CRM

Esquema dedicado crm con 7 tablas multi-tenant (tenant_id + company_id, soft delete):

Entidad Tabla Descripción
Cuentas crm.accounts Empresas cliente/prospecto (importador, IMMEX, agencia aduanal, transportista). Incluye RFC y patente aduanal.
Contactos crm.contacts Personas asociadas a una cuenta.
Prospectos crm.leads Leads sin calificar; se convierten en cuenta + contacto + oportunidad.
Embudos crm.pipelines Embudos de venta por compañía.
Etapas crm.pipeline_stages Columnas del Kanban (con probabilidad y etapas terminales ganada/perdida).
Oportunidades crm.opportunities Negocios que avanzan por el embudo.
Actividades crm.activities Llamadas, reuniones, tareas, correos y notas.

Endpoints (prefijo /v1/crm)

Todos reciben company_id como query param y validan permisos vía Keycloak/PermissionService.

  • GET|POST /accounts, GET|PATCH|DELETE /accounts/{id}
  • GET|POST /contacts, GET|PATCH|DELETE /contacts/{id}
  • GET|POST /leads, GET|PATCH|DELETE /leads/{id}, POST /leads/{id}/convert
  • GET|POST /pipelines, PATCH|DELETE /pipelines/{id}
  • GET|POST /stages, PATCH|DELETE /stages/{id}
  • GET|POST /opportunities, GET|PATCH|DELETE /opportunities/{id}, PATCH /opportunities/{id}/move
  • GET|POST /activities, GET|PATCH|DELETE /activities/{id}, PATCH /activities/{id}/complete
  • GET /metrics — KPIs y embudo por etapa para el dashboard

Permisos

Se registran en el PermissionRegistry al arrancar (25 permisos: crm.access + crm.{account,contact,lead,opportunity,pipeline,activity}.{view,create,edit,delete}). Para persistirlos en BD: POST /v1/core/permissions/sync (o el CLI de sincronización).


Inicio rápido (dev)

cp .env.example .env          # ajustar CORE_DB_NAME, Keycloak, etc.
./scripts/auth-mode.sh local  # login local sin workspace
docker compose up -d

Abre http://localhost:5173CRM en el sidebar.

Migraciones

cd backend
alembic upgrade head          # crea el schema crm y sus tablas (revisión f1a2b3c4d5e6)
alembic downgrade -1          # revierte el CRM (down() probado)

Pruebas

Backend (servicios CRM):

cd backend
pytest tests/ -q              # 24 pruebas de servicios (SQLite en memoria)

En CI/PostgreSQL, define TEST_DATABASE_URL para ejercitar el esquema real y las políticas RLS (ver docs/ARCHITECTURE.md).


Estructura del CRM

backend/api/v1/modules/crm/
├── router.py            # agrega submódulos bajo /crm (y registra permisos)
├── permissions.py       # alta de permisos CRM en el registry
├── accounts/  contacts/  leads/  pipelines/  opportunities/  activities/  metrics/
│   └── models.py · dto.py · service.py · routes.py
backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py

frontend/src/
├── lib/api/crm/         # clientes API tipados por entidad
├── lib/components/crm/  # helpers de formato/etiquetas
└── routes/dashboard/crm/
    ├── +page.svelte             # panel (KPIs + embudo)
    ├── cuentas/  contactos/  prospectos/  actividades/   # CRUD
    └── oportunidades/           # Kanban con drag & drop

Convenciones

  • Commits: Conventional Commits (feat:, fix:, refactor:, chore:)
  • Ramas: feature/AS-###-desc, fix/AS-###-desc
  • Código: nombres en inglés, comentarios de negocio en español
  • Backend: Pydantic v2, routers por dominio, filtros multi-tenant explícitos
  • Frontend: runes ($state, $derived, $effect, $props), Tailwind utility-first
Description
No description provided
Readme 2.5 MiB
Languages
Python 55.9%
Svelte 21.3%
TypeScript 12%
HTML 9.6%
XSLT 0.6%
Other 0.5%