erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-LOCALIZACION-PAISES.md

55 KiB

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:

  1. Plantillas contables (CoA): Plan de cuentas específico por país
  2. Impuestos por defecto: IVA, retenciones, impuestos especiales
  3. Posiciones fiscales: Mapeo de impuestos según tipo de cliente
  4. Tipos de documentos: Facturas, notas de crédito según normativa local
  5. Tipos de identificación: RFC, CUIT, NIF según país
  6. Datos maestros: Bancos, estados/provincias, formatos de dirección
  7. 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