- Implemented API for updating company Verifactu profile. - Created entities for company Verifactu profile and defaults. - Developed hooks for querying and updating the company Verifactu profile. - Added UI components for displaying and editing company Verifactu settings. - Introduced validation and error handling for profile updates. - Created utility functions for building form data and handling feedback states. - Added tests for utility functions and error handling.
13 KiB
Backend ERP – Perfil VeriFactu empresarial
1. Estado del documento
Estado: IMPLEMENTADO PARCIALMENTE
Este documento define el diseño backend del perfil VeriFactu de empresa dentro del módulo customer-invoices.
Decisiones consolidadas:
- El perfil VeriFactu pertenece al bounded context
customer-invoices. - Es una configuración separada de
Company. - Tiene scope de empresa mediante
companyId. - El estado de configuración es explícito.
- Se permite
obligated_without_verifactucomo estado real. - Los catálogos de clasificación y exención quedan modelados por código pero no se cierran todavía como enums.
IssuedInvoicedeberá persistir dos snapshots distintos en una fase posterior:- clasificación fiscal efectiva;
- política VeriFactu efectiva.
VerifactuRecordmantiene responsabilidad operativa y no absorbe el perfil empresarial.- El perfil no expone secretos ni passwords de certificados.
2. Propósito
Proporcionar una fuente de verdad empresarial para:
- aplicabilidad de la normativa;
- exención y su motivo;
- uso o no de modalidad VeriFactu;
- fecha efectiva de activación;
- renovación automática si aplica;
- defaults de clasificación fiscal;
- snapshot histórico al emitir una factura.
El diseño debe integrarse con el flujo actual:
Proforma
→ IssueProformaUseCase
→ ProformaIssueReadModelAssembler
→ proforma-to-issued-invoice-props-converter
→ IssuedInvoice
→ VerifactuRecord
sin trasladar esta resolución al frontend.
3. Ownership
El perfil debe vivir como agregado/configuración separado dentro de:
modules/customer-invoices
No debe vivir dentro de:
identity
companies
Company aggregate
Motivo:
- gobierna facturación;
- afecta directamente a
IssuedInvoice; - sus defaults deben materializarse al emitir;
- identidad solo aporta
companyId, company context y autorización.
4. Modelo de estados
export const CompanyVerifactuProfileStatus = {
NotConfigured: "not_configured",
Exempt: "exempt",
ObligatedWithoutVerifactu: "obligated_without_verifactu",
ObligatedWithVerifactu: "obligated_with_verifactu",
} as const;
export type CompanyVerifactuProfileStatus =
(typeof CompanyVerifactuProfileStatus)[keyof typeof CompanyVerifactuProfileStatus];
Semántica:
not_configured
→ la empresa aún no ha definido su perfil.
exempt
→ la empresa queda configurada como exenta/no aplicable.
obligated_without_verifactu
→ la empresa está obligada, pero no opera bajo modalidad VeriFactu.
obligated_with_verifactu
→ la empresa está obligada y utiliza VeriFactu.
Se acepta obligated_without_verifactu como estado real del modelo.
5. Invariantes del estado
not_configured
exemptionReasonCode = null
exemptionReasonDetail = null
verifactuEffectiveAt = null
autoRenewMode = null
No debe producir defaults efectivos de clasificación VeriFactu.
exempt
Puede requerir:
exemptionReasonCode
exemptionReasonDetail
No permite activación VeriFactu.
obligated_without_verifactu
verifactuEffectiveAt = null
autoRenewMode = null
Puede tener defaults fiscales si son aplicables.
obligated_with_verifactu
Requiere:
verifactuEffectiveAt != null
autoRenewMode solo tiene sentido aquí.
6. Obligación y exención
V1:
exemptionReasonCode: string | null;
exemptionReasonDetail: string | null;
No se cierra todavía un enum legal. Los códigos deberán validarse contra un catálogo backend cuando exista.
7. Activación VeriFactu
export const AutoRenewMode = {
Disabled: "disabled",
Enabled: "enabled",
} as const;
export type AutoRenewMode =
(typeof AutoRenewMode)[keyof typeof AutoRenewMode];
Campos:
verifactuEffectiveAt: Date | null;
autoRenewMode: AutoRenewMode | null;
No se modelan periodos históricos en V1.
8. Defaults empresariales
Solo defaults de clasificación fiscal:
export interface CompanyVerifactuDefaults {
taxRegimeCode: string | null;
operationQualificationCode: string | null;
exemptionReasonCode: string | null;
}
No pertenecen:
taxPercentage
recPercentage
retentionPercentage
taxMode
tax breakdown
9. Precedencia de defaults
document explicit
→ customer
→ company VeriFactu defaults
→ backend fallback
Backend resolverá la precedencia cuando la integración con emisión entre en alcance.
10. Clasificación fiscal vs cálculo fiscal
Cálculo fiscal
taxPercentage
recPercentage
retentionPercentage
taxable base
totals
tax breakdown
Clasificación VeriFactu
regimeCode
operationQualificationCode
exemptionReasonCode
No reutilizar ProformaFiscalData como sustituto de clasificación VeriFactu.
11. Snapshot de clasificación fiscal
export interface IssuedInvoiceFiscalClassificationSnapshot {
regimeCode: string | null;
regimeLabel: string | null;
operationQualificationCode: string | null;
operationQualificationLabel: string | null;
exemptionReasonCode: string | null;
exemptionReasonLabel: string | null;
}
Queda diseñado, pero no implementado todavía en esta fase.
12. Snapshot de política VeriFactu
export interface IssuedInvoiceVerifactuPolicySnapshot {
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: string | null;
}
Queda diseñado, pero no implementado todavía en esta fase.
13. Resolución durante la emisión
CompanyVerifactuProfile
│
▼
ProformaIssueReadModelAssembler
│
├── carga proforma
├── carga cliente
├── carga perfil VeriFactu
└── prepara contexto
│
▼
proforma-to-issued-invoice-props-converter
│
├── resuelve precedencia
├── construye FiscalClassificationSnapshot
└── construye VerifactuPolicySnapshot
│
▼
IssuedInvoice.create(...)
14. Responsabilidad de VerifactuRecord
IssuedInvoice:
- snapshot documental e histórico;
- clasificación fiscal aplicada;
- política VeriFactu aplicada.
VerifactuRecord:
- estado operativo;
- URL;
- QR;
- UUID;
- tipo de operación.
No debe contener defaults empresariales ni obligación/exención.
15. Certificados
El perfil no contiene:
password
private key
secret name
certificate binary
Solo podría admitir una referencia lógica no sensible si realmente fuera necesaria.
16. Boundary AEAT
Fuera de alcance:
enqueue
worker/jobs
retries
queries
hash chaining
cancelación
rectificación
payload AEAT
outbox
17. API
GET /api/customer-invoices/settings/verifactu
PUT /api/customer-invoices/settings/verifactu
Ambos usan requireIdentityTenant(params) y los controladores resuelven companyId mediante this.getTenantId().
18. GET
Si no existe fila:
profile_status = "not_configured"
configured = false
Response conceptual:
export interface CompanyVerifactuProfileResponseDTO {
profile_status: CompanyVerifactuProfileStatus;
configured: boolean;
uses_verifactu: boolean;
exemption_reason_code: string | null;
exemption_reason_detail: string | null;
verifactu_effective_at: string | null;
auto_renew_mode: AutoRenewMode | null;
defaults: {
tax_regime_code: string | null;
operation_qualification_code: string | null;
exemption_reason_code: string | null;
};
updated_at: string | null;
}
19. PUT
export interface UpdateCompanyVerifactuProfileRequestDTO {
profile_status: CompanyVerifactuProfileStatus;
exemption_reason_code?: string | null;
exemption_reason_detail?: string | null;
verifactu_effective_at?: string | null;
auto_renew_mode?: AutoRenewMode | null;
defaults: {
tax_regime_code?: string | null;
operation_qualification_code?: string | null;
exemption_reason_code?: string | null;
};
}
Debe ser idempotente para el mismo payload.
20. Errores
400 → combinación estructural inválida
403 → falta de autorización
409 → conflicto de concurrencia si se activa
422 → validación de negocio si encaja con la convención del módulo
No usar 404 por ausencia inicial de perfil.
21. Persistencia
Tabla conceptual:
company_verifactu_profiles
Campos:
id
company_id
profile_status
exemption_reason_code
exemption_reason_detail
verifactu_effective_at
auto_renew_mode
default_tax_regime_code
default_operation_qualification_code
default_exemption_reason_code
created_at
updated_at
Restricciones:
company_id NOT NULL
profile_status NOT NULL
UNIQUE(company_id)
Una fila activa por empresa.
DDL operativo vigente:
docs/customer-invoices/sql/add-company-verifactu-profiles.sql
docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql
22. Concurrencia
Se difiere en V1.
La implementación actual no introduce expected_updated_at ni control optimista específico, porque el backend real no aporta hoy un patrón reutilizable de concurrency/versionado para este tipo de settings.
23. Rollout
sin fila
→ not_configured
Orden recomendado:
1. ejecutar DDL de `company_verifactu_profiles`
2. ejecutar DDL de snapshots VeriFactu en `issued_invoices`
3. persistencia y GET seguro
4. PUT/configuración
5. UI Settings
6. uso de defaults en emisión
7. enforcement posterior
La ausencia de perfil no debe romper automáticamente la emisión actual durante transición.
24. Seguridad
Nunca aceptar companyId del frontend.
Implementado:
- autenticación;
- company context;
- aislamiento por
companyId.
Permisos finos propuestos para una fase posterior:
customer-invoices:verifactu-profile:read
customer-invoices:verifactu-profile:update
Aún no se han activado guards de permisos específicos, porque el módulo customer-invoices no tenía previamente una infraestructura de permiso fino conectada en estas rutas.
25. Auditoría
Deben ser auditables cambios en:
profileStatus
exemptionReasonCode
verifactuEffectiveAt
defaults
No se ha implementado auditoría adicional en esta fase.
26. Modelo conceptual del agregado
export interface CompanyVerifactuProfileProps {
companyId: UniqueID;
profileStatus: CompanyVerifactuProfileStatus;
exemptionReasonCode: string | null;
exemptionReasonDetail: string | null;
verifactuEffectiveAt: Date | null;
autoRenewMode: AutoRenewMode | null;
defaults: CompanyVerifactuDefaults;
createdAt: Date;
updatedAt: Date;
}
export class CompanyVerifactuProfile extends AggregateRoot<CompanyVerifactuProfileProps> {
public static create(
props: CompanyVerifactuProfileProps,
): Result<CompanyVerifactuProfile, Error>;
public reconfigure(
props: CompanyVerifactuProfileReconfiguration,
): Result<void, Error>;
}
Usar import type explícito en cabecera.
27. Catálogos pendientes
No se consolidan todavía enums para:
operationQualificationCode
exemptionReasonCode
Se requiere una fase específica para definir:
fuente normativa
códigos
labels
compatibilidades
versionado
28. Integración en IssuedInvoice
Implementado en Fase B:
CompanyVerifactuProfile
→ ProformaIssueReadModelAssembler
→ resolución backend de clasificación/política
→ ProformaToIssuedInvoiceConverter
→ IssuedInvoice
→ persistencia física en issued_invoices
IssuedInvoice mantiene taxRegime como snapshot legacy existente y añade además:
fiscalClassification
verifactuPolicy
Decisiones reales:
taxRegimeCodese resuelve con precedenciadocument -> customer -> company -> sin valor.operationQualificationCodese resuelve hoy desde defaults empresariales onull.exemptionReasonCodese resuelve hoy desde defaults empresariales onull.verifactuPolicy.profileStatusse persiste como señal de snapshot V2.- facturas antiguas sin esa señal cargan con
fiscalClassification = nullyverifactuPolicy = null. - no se inventan labels de
operationQualificationniexemptionReason; se exponen comonullhasta disponer de catálogo backend real.
29. Tests implementados
Aggregate:
- invariantes por cada estado;
- combinaciones inválidas;
- reconfigure.
Repository:
- una fila por company;
- aislamiento entre companies.
Use cases / controllers:
- GET sin fila;
- PUT válido/inválido;
- autorización;
Fase B:
- precedencia
document -> customer -> companyparataxRegimeCode; - fallback legacy sin perfil;
- validación de
IssuedInvoiceVerifactuPolicy.
30. Criterios de aceptación
- Existe una configuración VeriFactu por empresa.
- Pertenece a
customer-invoices. - Los cuatro estados están explícitamente modelados.
obligated_without_verifactuestá soportado.- Los defaults se resuelven en backend.
- Cálculo fiscal y clasificación VeriFactu están separados.
GETdevuelvenot_configuredsi no existe fila.PUTcrea o reconfigura el perfil de forma idempotente para el mismo payload.VerifactuRecordsigue siendo operativo.- No se exponen secretos.
- Empresas sin perfil se leen como
not_configured. IssuedInvoicepersiste snapshots de clasificación/política sin reescribir facturas legacy.
31. Regla mental final
Company
→ identidad/contexto
CompanyVerifactuProfile
→ política/defaults empresariales
IssuedInvoice
→ snapshot histórico efectivo
VerifactuRecord
→ estado operativo
AEAT integration
→ lifecycle futuro