SPEC-LOCALIZACION-PAISES
Metadatos
| Campo |
Valor |
| Código |
SPEC-TRANS-012 |
| Versión |
1.0.0 |
| Fecha |
2025-01-15 |
| Autor |
Requirements-Analyst Agent |
| Estado |
DRAFT |
| Prioridad |
P1 |
| Módulos Afectados |
MGN-002 (Empresas), MGN-004 (Financiero) |
| Gaps Cubiertos |
GAP-MGN-002-002 |
Resumen Ejecutivo
Esta especificación define el sistema completo de localización por país, permitiendo configurar automáticamente:
- Plantillas contables (CoA): Plan de cuentas específico por país
- Impuestos por defecto: IVA, retenciones, impuestos especiales
- Posiciones fiscales: Mapeo de impuestos según tipo de cliente
- Tipos de documentos: Facturas, notas de crédito según normativa local
- Tipos de identificación: RFC, CUIT, NIF según país
- Datos maestros: Bancos, estados/provincias, formatos de dirección
- Configuración de empresa: Campos específicos por legislación
Referencia Odoo 18
Basado en análisis exhaustivo de módulos l10n_* de Odoo 18:
- 221 módulos de localización disponibles
- Patrón
@template para registro de datos
- Herencia de plantillas para variantes regionales
- Validación externa con
stdnum para identificaciones fiscales
Parte 1: Arquitectura del Sistema de Localización
1.1 Visión General
┌─────────────────────────────────────────────────────────────────┐
│ SISTEMA DE LOCALIZACIÓN │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Plantilla │ │ Plantilla │ │ Plantilla │ │
│ │ l10n_mx │ │ l10n_ar │ │ l10n_es │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ LocalizationService │ │
│ │ ───────────────────── │ │
│ │ • loadTemplate() │ │
│ │ • getCountryConfig() │ │
│ │ • applyToCompany() │ │
│ └──────────────┬───────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Empresa │ │ Impuestos │ │ Cuentas │ │
│ │ Config │ │ Config │ │ Config │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 Componentes del Sistema
| Componente |
Descripción |
Datos Incluidos |
| Plantilla Base |
Configuración raíz del país |
CoA, impuestos, posiciones fiscales |
| Datos Maestros |
Catálogos específicos |
Bancos, provincias, tipos de ID |
| Extensiones de Modelo |
Campos específicos país |
RFC, CUIT, tipo responsabilidad |
| Validadores |
Reglas de validación |
Formato RFC, checksum CUIT |
| Formateadores |
Presentación de datos |
Formato dirección, moneda |
Parte 2: Modelo de Datos
2.1 Tipos Enumerados
-- Estado de plantilla de localización
CREATE TYPE localization_template_status AS ENUM (
'draft', -- En desarrollo
'active', -- Disponible para uso
'deprecated', -- Obsoleto pero funcional
'archived' -- No disponible
);
-- Tipo de dato de localización
CREATE TYPE localization_data_type AS ENUM (
'chart_of_accounts', -- Plan de cuentas
'taxes', -- Impuestos
'fiscal_positions', -- Posiciones fiscales
'document_types', -- Tipos de documento
'identification_types', -- Tipos de identificación
'banks', -- Bancos
'address_format', -- Formato de dirección
'company_fields' -- Campos de empresa
);
-- Tipo de impuesto por uso
CREATE TYPE tax_use_type AS ENUM (
'sale', -- Ventas
'purchase', -- Compras
'none' -- Ninguno (ajustes)
);
-- Tipo de cálculo de impuesto
CREATE TYPE tax_amount_type AS ENUM (
'percent', -- Porcentaje
'fixed', -- Monto fijo
'division', -- División
'group' -- Grupo de impuestos
);
2.2 Tabla de Plantillas de Localización
-- Plantillas de localización por país
CREATE TABLE localization_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(20) NOT NULL UNIQUE, -- Ej: 'mx', 'ar_ri', 'es_pymes'
name VARCHAR(200) NOT NULL, -- Nombre descriptivo
country_id UUID NOT NULL REFERENCES countries(id),
-- Jerarquía (para variantes)
parent_template_id UUID REFERENCES localization_templates(id),
sequence INTEGER DEFAULT 10, -- Orden de prioridad
-- Configuración
code_digits INTEGER DEFAULT 6, -- Longitud de códigos de cuenta
currency_id UUID REFERENCES currencies(id),
-- Estado
status localization_template_status DEFAULT 'active',
-- Metadatos
version VARCHAR(20),
description TEXT,
-- Auditoría
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
-- Constraints
CONSTRAINT uq_template_country_code UNIQUE (country_id, code)
);
-- Índices
CREATE INDEX idx_loc_templates_country ON localization_templates(country_id);
CREATE INDEX idx_loc_templates_parent ON localization_templates(parent_template_id);
CREATE INDEX idx_loc_templates_status ON localization_templates(status);
COMMENT ON TABLE localization_templates IS
'Plantillas de configuración por país. Soporta herencia para variantes regionales.';
2.3 Datos de Plantilla (Configuración)
-- Configuración específica de cada plantilla
CREATE TABLE localization_template_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES localization_templates(id) ON DELETE CASCADE,
-- Tipo de configuración
data_type localization_data_type NOT NULL,
-- Datos en formato JSONB para flexibilidad
config_data JSONB NOT NULL,
-- Para ordenamiento y prioridad
sequence INTEGER DEFAULT 10,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraint único por plantilla y tipo
CONSTRAINT uq_template_data_type UNIQUE (template_id, data_type)
);
-- Índice para búsqueda por tipo
CREATE INDEX idx_loc_template_data_type ON localization_template_data(data_type);
CREATE INDEX idx_loc_template_data_gin ON localization_template_data USING GIN (config_data);
COMMENT ON TABLE localization_template_data IS
'Datos de configuración por tipo para cada plantilla de localización.';
2.4 Tipos de Identificación por País
-- Tipos de identificación fiscal/legal por país
CREATE TABLE identification_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(20) NOT NULL, -- Ej: 'rfc', 'cuit', 'nif'
name VARCHAR(100) NOT NULL,
description TEXT,
-- País
country_id UUID REFERENCES countries(id), -- NULL = global
-- Configuración
is_vat BOOLEAN DEFAULT false, -- ¿Es identificación fiscal?
validation_regex VARCHAR(200), -- Patrón de validación
validation_module VARCHAR(100), -- Módulo de validación (ej: 'mx.rfc')
format_mask VARCHAR(100), -- Máscara de formato
-- Ordenamiento
sequence INTEGER DEFAULT 10,
-- Estado
is_active BOOLEAN DEFAULT true,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_identification_type_code UNIQUE (country_id, code)
);
-- Índices
CREATE INDEX idx_id_types_country ON identification_types(country_id);
CREATE INDEX idx_id_types_vat ON identification_types(is_vat) WHERE is_vat = true;
-- Datos iniciales globales
INSERT INTO identification_types (code, name, is_vat, sequence) VALUES
('vat', 'Tax ID / VAT', true, 1),
('passport', 'Passport', false, 100),
('foreign_id', 'Foreign ID', false, 110);
COMMENT ON TABLE identification_types IS
'Catálogo de tipos de identificación por país (RFC, CUIT, NIF, etc.)';
2.5 Configuración de Empresa por País
-- Campos adicionales de empresa por localización
CREATE TABLE company_localization_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
-- Localización aplicada
template_id UUID REFERENCES localization_templates(id),
country_id UUID NOT NULL REFERENCES countries(id),
-- Identificación fiscal
identification_type_id UUID REFERENCES identification_types(id),
tax_id VARCHAR(50), -- RFC, CUIT, NIF
tax_id_formatted VARCHAR(60), -- Versión formateada
-- Configuración contable
chart_template_loaded BOOLEAN DEFAULT false,
fiscal_country_id UUID REFERENCES countries(id),
-- Prefijos de cuentas
bank_account_code_prefix VARCHAR(20),
cash_account_code_prefix VARCHAR(20),
-- Impuestos por defecto
default_sale_tax_id UUID, -- REFERENCES taxes(id)
default_purchase_tax_id UUID, -- REFERENCES taxes(id)
-- Configuración de redondeo
tax_calculation_rounding VARCHAR(20) DEFAULT 'round_per_line',
-- Campos específicos por país (JSONB para flexibilidad)
country_specific_data JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
CONSTRAINT uq_company_localization UNIQUE (company_id)
);
-- Índices
CREATE INDEX idx_company_loc_template ON company_localization_config(template_id);
CREATE INDEX idx_company_loc_country ON company_localization_config(country_id);
COMMENT ON TABLE company_localization_config IS
'Configuración de localización aplicada a cada empresa.';
2.6 Posiciones Fiscales
-- Posiciones fiscales (mapeo de impuestos)
CREATE TABLE fiscal_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
name VARCHAR(200) NOT NULL,
code VARCHAR(50),
-- Ámbito
company_id UUID NOT NULL REFERENCES companies(id),
country_id UUID REFERENCES countries(id),
country_group_id UUID, -- Para grupos como "EU"
-- Configuración
auto_apply BOOLEAN DEFAULT false, -- Aplicar automáticamente
vat_required BOOLEAN DEFAULT false, -- Requiere VAT
-- Para matching automático
zip_from VARCHAR(20),
zip_to VARCHAR(20),
-- Ordenamiento
sequence INTEGER DEFAULT 10,
-- Estado
is_active BOOLEAN DEFAULT true,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL
);
-- Mapeo de impuestos en posición fiscal
CREATE TABLE fiscal_position_tax_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fiscal_position_id UUID NOT NULL REFERENCES fiscal_positions(id) ON DELETE CASCADE,
-- Mapeo
tax_src_id UUID NOT NULL, -- Impuesto origen
tax_dest_id UUID, -- Impuesto destino (NULL = no aplicar)
CONSTRAINT uq_fp_tax_mapping UNIQUE (fiscal_position_id, tax_src_id)
);
-- Mapeo de cuentas en posición fiscal
CREATE TABLE fiscal_position_account_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fiscal_position_id UUID NOT NULL REFERENCES fiscal_positions(id) ON DELETE CASCADE,
-- Mapeo
account_src_id UUID NOT NULL, -- Cuenta origen
account_dest_id UUID NOT NULL, -- Cuenta destino
CONSTRAINT uq_fp_account_mapping UNIQUE (fiscal_position_id, account_src_id)
);
-- Índices
CREATE INDEX idx_fiscal_pos_company ON fiscal_positions(company_id);
CREATE INDEX idx_fiscal_pos_country ON fiscal_positions(country_id);
CREATE INDEX idx_fiscal_pos_auto ON fiscal_positions(auto_apply) WHERE auto_apply = true;
COMMENT ON TABLE fiscal_positions IS
'Posiciones fiscales para mapeo automático de impuestos y cuentas según cliente.';
2.7 Tipos de Documento (LATAM)
-- Tipos de documento fiscal (facturas, NC, ND, etc.)
CREATE TABLE document_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
-- País
country_id UUID REFERENCES countries(id),
-- Clasificación
internal_type VARCHAR(50) NOT NULL, -- invoice, credit_note, debit_note
doc_code_prefix VARCHAR(10), -- Prefijo (FA, NC, ND)
-- Configuración
active BOOLEAN DEFAULT true,
-- Ordenamiento
sequence INTEGER DEFAULT 10,
-- Campos específicos (JSONB)
country_specific_data JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_document_type_code UNIQUE (country_id, code)
);
-- Índices
CREATE INDEX idx_doc_types_country ON document_types(country_id);
CREATE INDEX idx_doc_types_internal ON document_types(internal_type);
COMMENT ON TABLE document_types IS
'Tipos de documento fiscal por país (facturas A/B/C en Argentina, etc.)';
2.8 Bancos por País
-- Extensión de tabla de bancos para datos de localización
ALTER TABLE banks ADD COLUMN IF NOT EXISTS
country_id UUID REFERENCES countries(id);
ALTER TABLE banks ADD COLUMN IF NOT EXISTS
country_specific_code VARCHAR(50); -- Código local (ej: código CLABE en México)
ALTER TABLE banks ADD COLUMN IF NOT EXISTS
country_specific_data JSONB DEFAULT '{}';
-- Índice por país
CREATE INDEX IF NOT EXISTS idx_banks_country ON banks(country_id);
COMMENT ON COLUMN banks.country_specific_code IS
'Código específico del país (ej: código interbancario en México)';
2.9 Formato de Dirección por País
-- Configuración de formato de dirección por país
CREATE TABLE address_formats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_id UUID NOT NULL REFERENCES countries(id) UNIQUE,
-- Formato de dirección
address_format TEXT NOT NULL, -- Template con placeholders
-- Campos requeridos
street_required BOOLEAN DEFAULT true,
city_required BOOLEAN DEFAULT true,
state_required BOOLEAN DEFAULT false,
zip_required BOOLEAN DEFAULT false,
-- Configuración adicional
zip_regex VARCHAR(100), -- Validación de código postal
phone_format VARCHAR(100), -- Formato de teléfono
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Datos iniciales
INSERT INTO address_formats (country_id, address_format, state_required, zip_required) VALUES
((SELECT id FROM countries WHERE code = 'MX'),
'%(street)s\n%(street2)s\n%(zip)s %(city)s, %(state_name)s\n%(country_name)s',
true, true),
((SELECT id FROM countries WHERE code = 'US'),
'%(street)s\n%(street2)s\n%(city)s, %(state_code)s %(zip)s\n%(country_name)s',
true, true),
((SELECT id FROM countries WHERE code = 'ES'),
'%(street)s\n%(street2)s\n%(zip)s %(city)s\n%(state_name)s\n%(country_name)s',
false, true);
COMMENT ON TABLE address_formats IS
'Formato de presentación de direcciones por país.';
Parte 3: Interfaces de Dominio
3.1 Entidades de Localización
// src/modules/localization/domain/entities/localization-template.entity.ts
export interface LocalizationTemplateProps {
id: string;
code: string;
name: string;
countryId: string;
parentTemplateId?: string;
sequence: number;
codeDigits: number;
currencyId?: string;
status: LocalizationTemplateStatus;
version?: string;
description?: string;
isActive: boolean;
createdAt: Date;
createdBy: string;
updatedAt: Date;
updatedBy: string;
}
export enum LocalizationTemplateStatus {
DRAFT = 'draft',
ACTIVE = 'active',
DEPRECATED = 'deprecated',
ARCHIVED = 'archived'
}
export class LocalizationTemplate extends Entity<LocalizationTemplateProps> {
get code(): string {
return this.props.code;
}
get countryId(): string {
return this.props.countryId;
}
get hasParent(): boolean {
return !!this.props.parentTemplateId;
}
get isActive(): boolean {
return this.props.isActive && this.props.status === LocalizationTemplateStatus.ACTIVE;
}
public static create(props: Omit<LocalizationTemplateProps, 'id' | 'createdAt' | 'updatedAt'>): LocalizationTemplate {
return new LocalizationTemplate({
...props,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date()
});
}
}
3.2 Value Objects
// src/modules/localization/domain/value-objects/identification-number.vo.ts
export class IdentificationNumber {
private readonly value: string;
private readonly type: IdentificationType;
private readonly countryCode: string;
private constructor(value: string, type: IdentificationType, countryCode: string) {
this.value = value;
this.type = type;
this.countryCode = countryCode;
}
get raw(): string {
return this.value;
}
get formatted(): string {
return this.format();
}
get isVat(): boolean {
return this.type.isVat;
}
private format(): string {
// Formateo según país
switch (this.countryCode) {
case 'MX':
return this.formatMexicanRFC();
case 'AR':
return this.formatArgentineCUIT();
case 'ES':
return this.formatSpanishNIF();
default:
return this.value;
}
}
private formatMexicanRFC(): string {
// RFC: XXXX-XXXXXX-XXX
if (this.value.length === 13) {
return `${this.value.slice(0, 4)}-${this.value.slice(4, 10)}-${this.value.slice(10)}`;
}
if (this.value.length === 12) {
return `${this.value.slice(0, 3)}-${this.value.slice(3, 9)}-${this.value.slice(9)}`;
}
return this.value;
}
private formatArgentineCUIT(): string {
// CUIT: XX-XXXXXXXX-X
if (this.value.length === 11) {
return `${this.value.slice(0, 2)}-${this.value.slice(2, 10)}-${this.value.slice(10)}`;
}
return this.value;
}
private formatSpanishNIF(): string {
// NIF ya viene formateado típicamente
return this.value;
}
public static create(
value: string,
type: IdentificationType,
countryCode: string
): IdentificationNumber {
const cleanValue = value.replace(/[-\s]/g, '').toUpperCase();
// Validar según tipo
if (type.validationRegex) {
const regex = new RegExp(type.validationRegex);
if (!regex.test(cleanValue)) {
throw new DomainError(`Invalid ${type.name} format`);
}
}
return new IdentificationNumber(cleanValue, type, countryCode);
}
}
interface IdentificationType {
code: string;
name: string;
isVat: boolean;
validationRegex?: string;
}
3.3 Configuración de Plantilla
// src/modules/localization/domain/value-objects/template-config.vo.ts
export interface ChartOfAccountsConfig {
codeDigits: number;
accounts: AccountTemplateData[];
defaultReceivableAccountCode: string;
defaultPayableAccountCode: string;
defaultIncomeAccountCode: string;
defaultExpenseAccountCode: string;
bankAccountPrefix: string;
cashAccountPrefix: string;
}
export interface TaxConfig {
taxes: TaxTemplateData[];
defaultSaleTaxCode: string;
defaultPurchaseTaxCode: string;
calculationRounding: 'round_per_line' | 'round_globally';
}
export interface FiscalPositionConfig {
positions: FiscalPositionData[];
}
export interface CompanyConfig {
angloSaxonAccounting: boolean;
fiscalCountryId: string;
additionalFields: Record<string, any>;
}
export interface TemplateConfig {
chartOfAccounts: ChartOfAccountsConfig;
taxes: TaxConfig;
fiscalPositions: FiscalPositionConfig;
company: CompanyConfig;
documentTypes?: DocumentTypeData[];
identificationTypes?: IdentificationTypeData[];
banks?: BankData[];
}
// Datos de plantilla de cuenta
interface AccountTemplateData {
code: string;
name: string;
accountType: string;
reconcile: boolean;
tags?: string[];
}
// Datos de plantilla de impuesto
interface TaxTemplateData {
code: string;
name: string;
description?: string;
amountType: 'percent' | 'fixed' | 'division' | 'group';
amount: number;
taxUse: 'sale' | 'purchase' | 'none';
tags?: string[];
countrySpecificData?: Record<string, any>;
}
3.4 Servicio de Localización
// src/modules/localization/domain/services/localization.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class LocalizationService {
constructor(
private readonly templateRepository: LocalizationTemplateRepository,
private readonly companyRepository: CompanyRepository,
private readonly accountService: AccountService,
private readonly taxService: TaxService,
private readonly fiscalPositionService: FiscalPositionService
) {}
/**
* Obtener plantillas disponibles para un país
*/
async getTemplatesForCountry(countryId: string): Promise<LocalizationTemplate[]> {
return this.templateRepository.findByCountry(countryId);
}
/**
* Obtener la mejor plantilla para un país
*/
async guessTemplate(countryCode: string): Promise<LocalizationTemplate | null> {
const templates = await this.templateRepository.findByCountryCode(countryCode);
// Ordenar por secuencia y retornar la primera
return templates.sort((a, b) => a.sequence - b.sequence)[0] || null;
}
/**
* Cargar plantilla de localización en una empresa
*/
async loadTemplate(
templateCode: string,
companyId: string,
options?: LoadTemplateOptions
): Promise<LocalizationResult> {
const template = await this.templateRepository.findByCode(templateCode);
if (!template) {
throw new NotFoundException(`Template ${templateCode} not found`);
}
const company = await this.companyRepository.findById(companyId);
if (!company) {
throw new NotFoundException(`Company ${companyId} not found`);
}
// Verificar si ya tiene contabilidad
if (options?.forceCreate !== true) {
const hasAccounting = await this.accountService.companyHasAccounting(companyId);
if (hasAccounting) {
throw new ConflictError('Company already has accounting data. Use forceCreate to override.');
}
}
// Obtener configuración completa (con herencia)
const config = await this.getTemplateConfig(template);
// Cargar en orden de dependencias
const result: LocalizationResult = {
templateCode,
companyId,
accountsCreated: 0,
taxesCreated: 0,
fiscalPositionsCreated: 0,
errors: []
};
try {
// 1. Pre-procesamiento
await this.preLoadData(company, config);
// 2. Cargar cuentas
result.accountsCreated = await this.loadAccounts(company, config.chartOfAccounts);
// 3. Cargar impuestos
result.taxesCreated = await this.loadTaxes(company, config.taxes);
// 4. Cargar posiciones fiscales
result.fiscalPositionsCreated = await this.loadFiscalPositions(company, config.fiscalPositions);
// 5. Configurar empresa
await this.configureCompany(company, template, config.company);
// 6. Post-procesamiento
await this.postLoadData(company, config);
// 7. Cargar traducciones
if (options?.loadTranslations !== false) {
await this.loadTranslations(template, company);
}
} catch (error) {
result.errors.push(error.message);
throw error;
}
return result;
}
/**
* Obtener configuración completa de plantilla (resolviendo herencia)
*/
private async getTemplateConfig(template: LocalizationTemplate): Promise<TemplateConfig> {
const configs: TemplateConfig[] = [];
// Construir cadena de herencia
let current: LocalizationTemplate | null = template;
while (current) {
const templateData = await this.templateRepository.getTemplateData(current.id);
configs.unshift(this.parseTemplateData(templateData));
if (current.hasParent) {
current = await this.templateRepository.findById(current.props.parentTemplateId!);
} else {
current = null;
}
}
// Merge de configuraciones (hijo sobrescribe padre)
return configs.reduce((merged, config) => this.mergeConfigs(merged, config), {} as TemplateConfig);
}
/**
* Pre-procesamiento antes de cargar datos
*/
private async preLoadData(company: Company, config: TemplateConfig): Promise<void> {
// Normalizar códigos de cuenta según code_digits
config.chartOfAccounts.accounts = config.chartOfAccounts.accounts.map(account => ({
...account,
code: this.normalizeAccountCode(account.code, config.chartOfAccounts.codeDigits)
}));
// Establecer país fiscal
if (!company.fiscalCountryId) {
await this.companyRepository.updateFiscalCountry(company.id, company.countryId);
}
}
/**
* Cargar cuentas contables
*/
private async loadAccounts(company: Company, config: ChartOfAccountsConfig): Promise<number> {
let created = 0;
for (const accountData of config.accounts) {
try {
await this.accountService.createFromTemplate(company.id, accountData);
created++;
} catch (error) {
// Log pero continuar
console.warn(`Error creating account ${accountData.code}: ${error.message}`);
}
}
return created;
}
/**
* Cargar impuestos
*/
private async loadTaxes(company: Company, config: TaxConfig): Promise<number> {
let created = 0;
for (const taxData of config.taxes) {
try {
await this.taxService.createFromTemplate(company.id, taxData);
created++;
} catch (error) {
console.warn(`Error creating tax ${taxData.code}: ${error.message}`);
}
}
return created;
}
/**
* Cargar posiciones fiscales
*/
private async loadFiscalPositions(company: Company, config: FiscalPositionConfig): Promise<number> {
let created = 0;
for (const fpData of config.positions) {
try {
await this.fiscalPositionService.createFromTemplate(company.id, fpData);
created++;
} catch (error) {
console.warn(`Error creating fiscal position ${fpData.name}: ${error.message}`);
}
}
return created;
}
/**
* Configurar empresa con datos de localización
*/
private async configureCompany(
company: Company,
template: LocalizationTemplate,
config: CompanyConfig
): Promise<void> {
const updateData: Partial<CompanyLocalizationConfig> = {
templateId: template.id,
chartTemplateLoaded: true,
taxCalculationRounding: config.angloSaxonAccounting ? 'round_globally' : 'round_per_line'
};
// Asignar cuentas por defecto
// ... (lógica de asignación)
await this.companyRepository.updateLocalizationConfig(company.id, updateData);
}
/**
* Post-procesamiento después de cargar datos
*/
private async postLoadData(company: Company, config: TemplateConfig): Promise<void> {
// Crear cuenta de ganancias no distribuidas si no existe
await this.accountService.ensureUnaffectedEarningsAccount(company.id);
// Configurar diarios con impuestos por defecto
await this.taxService.assignDefaultTaxesToJournals(company.id);
}
/**
* Normalizar código de cuenta
*/
private normalizeAccountCode(code: string, digits: number): string {
return code.padEnd(digits, '0');
}
/**
* Merge de configuraciones
*/
private mergeConfigs(base: TemplateConfig, override: TemplateConfig): TemplateConfig {
return {
chartOfAccounts: {
...base.chartOfAccounts,
...override.chartOfAccounts,
accounts: [
...(base.chartOfAccounts?.accounts || []),
...(override.chartOfAccounts?.accounts || [])
]
},
taxes: {
...base.taxes,
...override.taxes,
taxes: [
...(base.taxes?.taxes || []),
...(override.taxes?.taxes || [])
]
},
fiscalPositions: {
positions: [
...(base.fiscalPositions?.positions || []),
...(override.fiscalPositions?.positions || [])
]
},
company: {
...base.company,
...override.company
}
};
}
}
interface LoadTemplateOptions {
forceCreate?: boolean;
loadTranslations?: boolean;
installDemo?: boolean;
}
interface LocalizationResult {
templateCode: string;
companyId: string;
accountsCreated: number;
taxesCreated: number;
fiscalPositionsCreated: number;
errors: string[];
}
3.5 Servicio de Validación de Identificación
// src/modules/localization/domain/services/identification-validator.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class IdentificationValidatorService {
/**
* Validar número de identificación según país y tipo
*/
validate(value: string, countryCode: string, typeCode: string): ValidationResult {
const cleanValue = this.clean(value);
switch (countryCode) {
case 'MX':
return this.validateMexican(cleanValue, typeCode);
case 'AR':
return this.validateArgentine(cleanValue, typeCode);
case 'ES':
return this.validateSpanish(cleanValue, typeCode);
default:
return { isValid: true, value: cleanValue };
}
}
/**
* Validar RFC mexicano
*/
private validateMexican(value: string, typeCode: string): ValidationResult {
if (typeCode !== 'rfc') {
return { isValid: true, value };
}
// RFC persona moral: 3 letras + 6 dígitos + 3 homoclave
// RFC persona física: 4 letras + 6 dígitos + 3 homoclave
const rfcRegex = /^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/;
if (!rfcRegex.test(value)) {
return {
isValid: false,
value,
error: 'Invalid RFC format'
};
}
// Validar fecha de nacimiento/constitución
const dateStr = value.slice(-9, -3);
if (!this.isValidDate(dateStr)) {
return {
isValid: false,
value,
error: 'Invalid date in RFC'
};
}
return { isValid: true, value };
}
/**
* Validar CUIT argentino
*/
private validateArgentine(value: string, typeCode: string): ValidationResult {
if (typeCode !== 'cuit' && typeCode !== 'cuil') {
return { isValid: true, value };
}
// CUIT/CUIL: 11 dígitos con dígito verificador
if (!/^\d{11}$/.test(value)) {
return {
isValid: false,
value,
error: 'CUIT must be 11 digits'
};
}
// Validar dígito verificador
const multipliers = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
let sum = 0;
for (let i = 0; i < 10; i++) {
sum += parseInt(value[i]) * multipliers[i];
}
const checkDigit = 11 - (sum % 11);
const expectedCheck = checkDigit === 11 ? 0 : checkDigit === 10 ? 9 : checkDigit;
if (parseInt(value[10]) !== expectedCheck) {
return {
isValid: false,
value,
error: 'Invalid CUIT check digit'
};
}
return { isValid: true, value };
}
/**
* Validar NIF/CIF español
*/
private validateSpanish(value: string, typeCode: string): ValidationResult {
if (typeCode !== 'nif' && typeCode !== 'cif') {
return { isValid: true, value };
}
// NIF: 8 dígitos + letra
const nifRegex = /^[0-9]{8}[A-Z]$/;
// NIE: X/Y/Z + 7 dígitos + letra
const nieRegex = /^[XYZ][0-9]{7}[A-Z]$/;
// CIF: letra + 7 dígitos + letra/dígito
const cifRegex = /^[ABCDEFGHJNPQRSUVW][0-9]{7}[A-J0-9]$/;
if (!nifRegex.test(value) && !nieRegex.test(value) && !cifRegex.test(value)) {
return {
isValid: false,
value,
error: 'Invalid NIF/NIE/CIF format'
};
}
// Validar letra de control para NIF
if (nifRegex.test(value)) {
const letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
const number = parseInt(value.slice(0, 8));
const expectedLetter = letters[number % 23];
if (value[8] !== expectedLetter) {
return {
isValid: false,
value,
error: 'Invalid NIF control letter'
};
}
}
return { isValid: true, value };
}
private clean(value: string): string {
return value.replace(/[-\s.]/g, '').toUpperCase();
}
private isValidDate(dateStr: string): boolean {
// Formato YYMMDD
const year = parseInt(dateStr.slice(0, 2));
const month = parseInt(dateStr.slice(2, 4));
const day = parseInt(dateStr.slice(4, 6));
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
return true;
}
}
interface ValidationResult {
isValid: boolean;
value: string;
error?: string;
}
Parte 4: API REST
4.1 Controlador de Localización
// src/modules/localization/interfaces/http/localization.controller.ts
@Controller('localization')
@ApiTags('Localization')
export class LocalizationController {
constructor(
private readonly localizationService: LocalizationService
) {}
@Get('templates')
@ApiOperation({ summary: 'List available localization templates' })
@ApiQuery({ name: 'countryId', required: false })
async getTemplates(
@Query('countryId') countryId?: string
): Promise<LocalizationTemplateDto[]> {
if (countryId) {
return this.localizationService.getTemplatesForCountry(countryId);
}
return this.localizationService.getAllTemplates();
}
@Get('templates/:code')
@ApiOperation({ summary: 'Get template details' })
async getTemplate(
@Param('code') code: string
): Promise<LocalizationTemplateDetailDto> {
return this.localizationService.getTemplateByCode(code);
}
@Post('templates/:code/load')
@ApiOperation({ summary: 'Load localization template into company' })
async loadTemplate(
@Param('code') code: string,
@Body() dto: LoadTemplateDto,
@CurrentUser() user: User
): Promise<LocalizationResultDto> {
return this.localizationService.loadTemplate(code, dto.companyId, {
forceCreate: dto.forceCreate,
loadTranslations: dto.loadTranslations,
installDemo: dto.installDemo
});
}
@Get('guess/:countryCode')
@ApiOperation({ summary: 'Guess best template for country' })
async guessTemplate(
@Param('countryCode') countryCode: string
): Promise<LocalizationTemplateDto | null> {
return this.localizationService.guessTemplate(countryCode);
}
@Get('identification-types')
@ApiOperation({ summary: 'List identification types' })
@ApiQuery({ name: 'countryId', required: false })
async getIdentificationTypes(
@Query('countryId') countryId?: string
): Promise<IdentificationTypeDto[]> {
return this.localizationService.getIdentificationTypes(countryId);
}
@Post('validate-identification')
@ApiOperation({ summary: 'Validate identification number' })
async validateIdentification(
@Body() dto: ValidateIdentificationDto
): Promise<IdentificationValidationResult> {
return this.localizationService.validateIdentification(
dto.value,
dto.countryCode,
dto.typeCode
);
}
@Get('fiscal-positions')
@ApiOperation({ summary: 'List fiscal positions for company' })
async getFiscalPositions(
@Query('companyId') companyId: string
): Promise<FiscalPositionDto[]> {
return this.localizationService.getFiscalPositions(companyId);
}
@Get('document-types')
@ApiOperation({ summary: 'List document types for country' })
async getDocumentTypes(
@Query('countryId') countryId: string
): Promise<DocumentTypeDto[]> {
return this.localizationService.getDocumentTypes(countryId);
}
}
// DTOs
class LoadTemplateDto {
@IsUUID()
companyId: string;
@IsOptional()
@IsBoolean()
forceCreate?: boolean;
@IsOptional()
@IsBoolean()
loadTranslations?: boolean;
@IsOptional()
@IsBoolean()
installDemo?: boolean;
}
class ValidateIdentificationDto {
@IsString()
value: string;
@IsString()
@Length(2, 2)
countryCode: string;
@IsString()
typeCode: string;
}
Parte 5: Datos de Localización Iniciales
5.1 México (l10n_mx)
// src/modules/localization/data/templates/mx.template.ts
export const mexicoTemplate: TemplateDefinition = {
code: 'mx',
name: 'Mexico - Plan de Cuentas General',
countryCode: 'MX',
codeDigits: 9,
sequence: 1,
chartOfAccounts: {
defaultReceivableAccountCode: '105.01',
defaultPayableAccountCode: '201.01',
defaultIncomeAccountCode: '401.01',
defaultExpenseAccountCode: '601.84',
bankAccountPrefix: '102.01.0',
cashAccountPrefix: '101.01.0',
accounts: [
// Activo
{ code: '101', name: 'Caja', accountType: 'asset_cash', reconcile: false },
{ code: '101.01', name: 'Caja y Efectivo', accountType: 'asset_cash', reconcile: false },
{ code: '102', name: 'Bancos', accountType: 'asset_cash', reconcile: true },
{ code: '102.01', name: 'Bancos Nacionales', accountType: 'asset_cash', reconcile: true },
{ code: '105', name: 'Clientes', accountType: 'asset_receivable', reconcile: true },
{ code: '105.01', name: 'Clientes Nacionales', accountType: 'asset_receivable', reconcile: true },
// ... más cuentas
// Pasivo
{ code: '201', name: 'Proveedores', accountType: 'liability_payable', reconcile: true },
{ code: '201.01', name: 'Proveedores Nacionales', accountType: 'liability_payable', reconcile: true },
{ code: '205', name: 'Impuestos por Pagar', accountType: 'liability_current', reconcile: false },
{ code: '205.01', name: 'IVA por Pagar', accountType: 'liability_current', reconcile: false },
// ... más cuentas
// Ingresos
{ code: '401', name: 'Ingresos', accountType: 'income', reconcile: false },
{ code: '401.01', name: 'Ventas Nacionales', accountType: 'income', reconcile: false },
// Gastos
{ code: '601', name: 'Gastos Generales', accountType: 'expense', reconcile: false },
{ code: '601.84', name: 'Gastos de Operación', accountType: 'expense', reconcile: false },
]
},
taxes: {
defaultSaleTaxCode: 'tax_iva_16_venta',
defaultPurchaseTaxCode: 'tax_iva_16_compra',
calculationRounding: 'round_globally',
taxes: [
{
code: 'tax_iva_16_venta',
name: 'IVA 16% Venta',
amountType: 'percent',
amount: 16,
taxUse: 'sale',
countrySpecificData: {
l10n_mx_factor_type: 'Tasa',
l10n_mx_tax_type: 'iva'
}
},
{
code: 'tax_iva_16_compra',
name: 'IVA 16% Compra',
amountType: 'percent',
amount: 16,
taxUse: 'purchase',
countrySpecificData: {
l10n_mx_factor_type: 'Tasa',
l10n_mx_tax_type: 'iva'
}
},
{
code: 'tax_iva_0_venta',
name: 'IVA 0% Venta',
amountType: 'percent',
amount: 0,
taxUse: 'sale',
countrySpecificData: {
l10n_mx_factor_type: 'Tasa',
l10n_mx_tax_type: 'iva'
}
},
{
code: 'tax_exento',
name: 'Exento de IVA',
amountType: 'percent',
amount: 0,
taxUse: 'sale',
countrySpecificData: {
l10n_mx_factor_type: 'Exento',
l10n_mx_tax_type: 'iva'
}
},
{
code: 'tax_ret_isr_10',
name: 'Retención ISR 10%',
amountType: 'percent',
amount: -10,
taxUse: 'purchase',
countrySpecificData: {
l10n_mx_factor_type: 'Tasa',
l10n_mx_tax_type: 'isr'
}
}
]
},
fiscalPositions: {
positions: [
{
name: 'Régimen Nacional',
code: 'fp_nacional',
autoApply: true,
sequence: 1
},
{
name: 'Exportación',
code: 'fp_export',
autoApply: false,
vatRequired: false,
sequence: 10,
taxMappings: [
{ srcCode: 'tax_iva_16_venta', destCode: 'tax_iva_0_venta' }
]
}
]
},
identificationTypes: [
{
code: 'rfc',
name: 'RFC',
description: 'Registro Federal de Contribuyentes',
isVat: true,
validationRegex: '^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$',
sequence: 1
},
{
code: 'curp',
name: 'CURP',
description: 'Clave Única de Registro de Población',
isVat: false,
validationRegex: '^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9A-Z]{2}$',
sequence: 2
}
],
company: {
angloSaxonAccounting: true,
additionalFields: {
l10n_mx_edi_pac: null,
l10n_mx_edi_certificate: null
}
}
};
5.2 Argentina (l10n_ar)
// src/modules/localization/data/templates/ar.template.ts
// Plantilla base para Monotributista
export const argentinaBaseTemplate: TemplateDefinition = {
code: 'ar_base',
name: 'Argentina - Plan General Monotributista',
countryCode: 'AR',
codeDigits: 12,
sequence: 2,
chartOfAccounts: {
defaultReceivableAccountCode: '1.1.03.01.001',
defaultPayableAccountCode: '2.1.01.01.001',
// ... cuentas base
},
taxes: {
defaultSaleTaxCode: null, // Monotributista no tiene IVA
defaultPurchaseTaxCode: null,
taxes: []
},
identificationTypes: [
{
code: 'cuit',
name: 'CUIT',
description: 'Clave Única de Identificación Tributaria',
isVat: true,
validationRegex: '^[0-9]{11}$',
sequence: 1
},
{
code: 'cuil',
name: 'CUIL',
description: 'Código Único de Identificación Laboral',
isVat: false,
validationRegex: '^[0-9]{11}$',
sequence: 2
},
{
code: 'dni',
name: 'DNI',
description: 'Documento Nacional de Identidad',
isVat: false,
sequence: 3
}
],
company: {
angloSaxonAccounting: false,
additionalFields: {
l10n_ar_afip_responsibility_type: 'monotributo',
l10n_ar_gross_income_type: null
}
}
};
// Plantilla para Responsable Inscripto (extiende ar_base)
export const argentinaRITemplate: TemplateDefinition = {
code: 'ar_ri',
name: 'Argentina - Plan General Responsable Inscripto',
countryCode: 'AR',
parentCode: 'ar_base',
codeDigits: 12,
sequence: 1,
taxes: {
defaultSaleTaxCode: 'tax_iva_21_venta',
defaultPurchaseTaxCode: 'tax_iva_21_compra',
calculationRounding: 'round_globally',
taxes: [
{
code: 'tax_iva_21_venta',
name: 'IVA 21% Venta',
amountType: 'percent',
amount: 21,
taxUse: 'sale'
},
{
code: 'tax_iva_21_compra',
name: 'IVA 21% Compra',
amountType: 'percent',
amount: 21,
taxUse: 'purchase'
},
{
code: 'tax_iva_10_5',
name: 'IVA 10.5%',
amountType: 'percent',
amount: 10.5,
taxUse: 'sale'
},
{
code: 'tax_iva_27',
name: 'IVA 27%',
amountType: 'percent',
amount: 27,
taxUse: 'sale'
}
]
},
documentTypes: [
{
code: 'FA-A',
name: 'Factura A',
internalType: 'invoice',
docCodePrefix: 'FA-A',
countrySpecificData: { l10n_ar_letter: 'A' }
},
{
code: 'NC-A',
name: 'Nota de Crédito A',
internalType: 'credit_note',
docCodePrefix: 'NC-A',
countrySpecificData: { l10n_ar_letter: 'A' }
},
{
code: 'FA-B',
name: 'Factura B',
internalType: 'invoice',
docCodePrefix: 'FA-B',
countrySpecificData: { l10n_ar_letter: 'B' }
},
{
code: 'NC-B',
name: 'Nota de Crédito B',
internalType: 'credit_note',
docCodePrefix: 'NC-B',
countrySpecificData: { l10n_ar_letter: 'B' }
}
],
company: {
angloSaxonAccounting: false,
additionalFields: {
l10n_ar_afip_responsibility_type: 'responsable_inscripto'
}
}
};
Parte 6: Migraciones
6.1 Migración Principal
-- migrations/YYYYMMDD_add_localization_system.sql
-- Tipos enumerados
DO $$ BEGIN
CREATE TYPE localization_template_status AS ENUM ('draft', 'active', 'deprecated', 'archived');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE localization_data_type AS ENUM (
'chart_of_accounts', 'taxes', 'fiscal_positions',
'document_types', 'identification_types', 'banks',
'address_format', 'company_fields'
);
EXCEPTION WHEN duplicate_object THEN null;
END $$;
-- Tabla de plantillas de localización
CREATE TABLE IF NOT EXISTS localization_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
country_id UUID NOT NULL REFERENCES countries(id),
parent_template_id UUID REFERENCES localization_templates(id),
sequence INTEGER DEFAULT 10,
code_digits INTEGER DEFAULT 6,
currency_id UUID REFERENCES currencies(id),
status localization_template_status DEFAULT 'active',
version VARCHAR(20),
description TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
CONSTRAINT uq_template_country_code UNIQUE (country_id, code)
);
-- Datos de plantilla
CREATE TABLE IF NOT EXISTS localization_template_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES localization_templates(id) ON DELETE CASCADE,
data_type localization_data_type NOT NULL,
config_data JSONB NOT NULL,
sequence INTEGER DEFAULT 10,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_template_data_type UNIQUE (template_id, data_type)
);
-- Tipos de identificación
CREATE TABLE IF NOT EXISTS identification_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
country_id UUID REFERENCES countries(id),
is_vat BOOLEAN DEFAULT false,
validation_regex VARCHAR(200),
validation_module VARCHAR(100),
format_mask VARCHAR(100),
sequence INTEGER DEFAULT 10,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_identification_type_code UNIQUE (country_id, code)
);
-- Configuración de empresa por localización
CREATE TABLE IF NOT EXISTS company_localization_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE UNIQUE,
template_id UUID REFERENCES localization_templates(id),
country_id UUID NOT NULL REFERENCES countries(id),
identification_type_id UUID REFERENCES identification_types(id),
tax_id VARCHAR(50),
tax_id_formatted VARCHAR(60),
chart_template_loaded BOOLEAN DEFAULT false,
fiscal_country_id UUID REFERENCES countries(id),
bank_account_code_prefix VARCHAR(20),
cash_account_code_prefix VARCHAR(20),
default_sale_tax_id UUID,
default_purchase_tax_id UUID,
tax_calculation_rounding VARCHAR(20) DEFAULT 'round_per_line',
country_specific_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL
);
-- Posiciones fiscales
CREATE TABLE IF NOT EXISTS fiscal_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
code VARCHAR(50),
company_id UUID NOT NULL REFERENCES companies(id),
country_id UUID REFERENCES countries(id),
country_group_id UUID,
auto_apply BOOLEAN DEFAULT false,
vat_required BOOLEAN DEFAULT false,
zip_from VARCHAR(20),
zip_to VARCHAR(20),
sequence INTEGER DEFAULT 10,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL
);
CREATE TABLE IF NOT EXISTS fiscal_position_tax_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fiscal_position_id UUID NOT NULL REFERENCES fiscal_positions(id) ON DELETE CASCADE,
tax_src_id UUID NOT NULL,
tax_dest_id UUID,
CONSTRAINT uq_fp_tax_mapping UNIQUE (fiscal_position_id, tax_src_id)
);
CREATE TABLE IF NOT EXISTS fiscal_position_account_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fiscal_position_id UUID NOT NULL REFERENCES fiscal_positions(id) ON DELETE CASCADE,
account_src_id UUID NOT NULL,
account_dest_id UUID NOT NULL,
CONSTRAINT uq_fp_account_mapping UNIQUE (fiscal_position_id, account_src_id)
);
-- Tipos de documento
CREATE TABLE IF NOT EXISTS document_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
country_id UUID REFERENCES countries(id),
internal_type VARCHAR(50) NOT NULL,
doc_code_prefix VARCHAR(10),
active BOOLEAN DEFAULT true,
sequence INTEGER DEFAULT 10,
country_specific_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_document_type_code UNIQUE (country_id, code)
);
-- Formato de dirección
CREATE TABLE IF NOT EXISTS address_formats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_id UUID NOT NULL REFERENCES countries(id) UNIQUE,
address_format TEXT NOT NULL,
street_required BOOLEAN DEFAULT true,
city_required BOOLEAN DEFAULT true,
state_required BOOLEAN DEFAULT false,
zip_required BOOLEAN DEFAULT false,
zip_regex VARCHAR(100),
phone_format VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_loc_templates_country ON localization_templates(country_id);
CREATE INDEX IF NOT EXISTS idx_loc_templates_parent ON localization_templates(parent_template_id);
CREATE INDEX IF NOT EXISTS idx_loc_template_data_type ON localization_template_data(data_type);
CREATE INDEX IF NOT EXISTS idx_id_types_country ON identification_types(country_id);
CREATE INDEX IF NOT EXISTS idx_company_loc_template ON company_localization_config(template_id);
CREATE INDEX IF NOT EXISTS idx_fiscal_pos_company ON fiscal_positions(company_id);
CREATE INDEX IF NOT EXISTS idx_doc_types_country ON document_types(country_id);
-- Datos iniciales globales
INSERT INTO identification_types (code, name, is_vat, sequence) VALUES
('vat', 'Tax ID / VAT', true, 1),
('passport', 'Passport', false, 100),
('foreign_id', 'Foreign ID', false, 110)
ON CONFLICT DO NOTHING;
COMMENT ON TABLE localization_templates IS 'Plantillas de localización por país';
COMMENT ON TABLE identification_types IS 'Tipos de identificación fiscal por país';
COMMENT ON TABLE fiscal_positions IS 'Posiciones fiscales para mapeo de impuestos';
COMMENT ON TABLE document_types IS 'Tipos de documento fiscal por país';
Parte 7: Resumen de Implementación
7.1 Gap Cubierto
| Gap ID |
Descripción |
Estado |
| GAP-MGN-002-002 |
Configuración de plantillas por país (l10n_*) |
✅ Especificado |
7.2 Componentes Definidos
| Componente |
Tipo |
Descripción |
localization_templates |
Tabla |
Plantillas de localización |
localization_template_data |
Tabla |
Configuración por tipo |
identification_types |
Tabla |
Tipos de ID por país |
company_localization_config |
Tabla |
Config de empresa |
fiscal_positions |
Tabla |
Posiciones fiscales |
document_types |
Tabla |
Tipos de documento |
address_formats |
Tabla |
Formatos de dirección |
LocalizationService |
Service |
Carga de plantillas |
IdentificationValidatorService |
Service |
Validación de IDs |
LocalizationController |
API |
Endpoints REST |
7.3 Países Inicialmente Soportados
| País |
Código |
Plantillas |
Identificación |
| México |
MX |
mx |
RFC |
| Argentina |
AR |
ar_base, ar_ri, ar_ex |
CUIT, CUIL, DNI |
| España |
ES |
es_common, es_pymes |
NIF, NIE, CIF |
| Colombia |
CO |
co |
NIT, CC, CE |
| Chile |
CL |
cl |
RUT |
7.4 Integración con Otros Módulos
- MGN-004 (Financiero): Plan de cuentas, impuestos, posiciones fiscales
- MGN-003 (Catálogos): Bancos, monedas, países
- MGN-006/007 (Compras/Ventas): Tipos de documento, posiciones fiscales
Referencias
- Odoo 18
l10n_mx, l10n_ar, l10n_es - Módulos de localización
- Odoo 18
account/models/chart_template.py - Patrón @template
- SPEC-PLANTILLAS-CUENTAS.md - Especificación de chart templates
- SPEC-IMPUESTOS-AVANZADOS.md - Sistema de impuestos
- ISO 3166-1 - Códigos de país