erp-core/docs/02-fase-core-business/MGN-010-financial/especificaciones/ET-FIN-backend.md
rckrdmrd 0086695b4c
Some checks failed
ERP Core CI / Backend Lint (push) Has been cancelled
ERP Core CI / Backend Unit Tests (push) Has been cancelled
ERP Core CI / Backend Integration Tests (push) Has been cancelled
ERP Core CI / Frontend Lint (push) Has been cancelled
ERP Core CI / Frontend Unit Tests (push) Has been cancelled
ERP Core CI / Frontend E2E Tests (push) Has been cancelled
ERP Core CI / Database DDL Validation (push) Has been cancelled
ERP Core CI / Backend Build (push) Has been cancelled
ERP Core CI / Frontend Build (push) Has been cancelled
ERP Core CI / CI Success (push) Has been cancelled
Performance Tests / Lighthouse CI (push) Has been cancelled
Performance Tests / Bundle Size Analysis (push) Has been cancelled
Performance Tests / k6 Load Tests (push) Has been cancelled
Performance Tests / Performance Summary (push) Has been cancelled
[SIMCO-V38] feat: Actualizar a SIMCO v3.8.0 + cambios backend
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8
- Actualizaciones en modulos CRM y OpenAPI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:53:05 -06:00

90 KiB

ET-FIN-BACKEND: Servicios y API REST

Identificacion

Campo Valor
ID ET-FIN-BACKEND
Modulo MGN-010 Financial
Version 2.0
Estado En Diseno
Framework NestJS
Autor Requirements-Analyst
Fecha 2026-01-10

Estructura de Archivos

apps/backend/src/modules/financial/
├── financial.module.ts
├── controllers/
│   ├── charts.controller.ts
│   ├── accounts.controller.ts
│   ├── currencies.controller.ts
│   ├── fiscal-years.controller.ts
│   ├── fiscal-periods.controller.ts
│   ├── journal.controller.ts
│   └── cost-centers.controller.ts
├── services/
│   ├── charts.service.ts
│   ├── accounts.service.ts
│   ├── currencies.service.ts
│   ├── exchange-rates.service.ts
│   ├── fiscal-years.service.ts
│   ├── fiscal-periods.service.ts
│   ├── journal.service.ts
│   ├── cost-centers.service.ts
│   ├── invoices.service.ts
│   ├── payments.service.ts
│   ├── payment-methods.service.ts
│   ├── payment-terms.service.ts
│   ├── journal-entries.service.ts
│   ├── reconcile-models.service.ts
│   ├── taxes.service.ts
│   └── incoterms.service.ts
├── entities/
│   ├── account-type.entity.ts
│   ├── chart-of-accounts.entity.ts
│   ├── account.entity.ts
│   ├── tenant-currency.entity.ts
│   ├── exchange-rate.entity.ts
│   ├── fiscal-year.entity.ts
│   ├── fiscal-period.entity.ts
│   ├── journal-entry.entity.ts
│   ├── journal-entry-line.entity.ts
│   ├── journal-line.entity.ts
│   ├── cost-center.entity.ts
│   ├── invoice.entity.ts
│   ├── invoice-line.entity.ts
│   ├── payment.entity.ts
│   ├── payment-invoice.entity.ts
│   ├── payment-method.entity.ts
│   ├── payment-term.entity.ts
│   ├── payment-term-line.entity.ts
│   ├── reconcile-model.entity.ts
│   ├── reconcile-model-line.entity.ts
│   ├── tax.entity.ts
│   └── incoterm.entity.ts
├── dto/
│   ├── create-account.dto.ts
│   ├── create-journal-entry.dto.ts
│   ├── convert-currency.dto.ts
│   └── close-period.dto.ts
└── interfaces/
    ├── account-balance.interface.ts
    └── trial-balance.interface.ts

Entidades

Account Entity

@Entity('accounts', { schema: 'core_financial' })
@Tree('materialized-path')
export class Account {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'chart_id', type: 'uuid' })
  chartId: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'parent_id', type: 'uuid', nullable: true })
  parentId: string;

  @Column({ name: 'account_type_id', type: 'uuid' })
  accountTypeId: string;

  @Column({ length: 20 })
  code: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ default: 1 })
  level: number;

  @Column({ name: 'is_detail', default: true })
  isDetail: boolean;

  @Column({ name: 'is_bank', default: false })
  isBank: boolean;

  @Column({ name: 'is_cash', default: false })
  isCash: boolean;

  @Column({ name: 'currency_code', length: 3, nullable: true })
  currencyCode: string;

  @Column({ name: 'opening_balance', type: 'decimal', precision: 18, scale: 4, default: 0 })
  openingBalance: number;

  @Column({ name: 'current_balance', type: 'decimal', precision: 18, scale: 4, default: 0 })
  currentBalance: number;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  @ManyToOne(() => ChartOfAccounts)
  @JoinColumn({ name: 'chart_id' })
  chart: ChartOfAccounts;

  @ManyToOne(() => Account)
  @JoinColumn({ name: 'parent_id' })
  parent: Account;

  @OneToMany(() => Account, a => a.parent)
  children: Account[];

  @ManyToOne(() => AccountType)
  @JoinColumn({ name: 'account_type_id' })
  accountType: AccountType;
}

JournalEntry Entity

@Entity('journal_entries', { schema: 'core_financial' })
export class JournalEntry {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'fiscal_period_id', type: 'uuid' })
  fiscalPeriodId: string;

  @Column({ name: 'entry_number', length: 20 })
  entryNumber: string;

  @Column({ name: 'entry_date', type: 'date' })
  entryDate: Date;

  @Column({ name: 'currency_code', length: 3 })
  currencyCode: string;

  @Column({ name: 'exchange_rate', type: 'decimal', precision: 18, scale: 8, default: 1 })
  exchangeRate: number;

  @Column({ length: 100, nullable: true })
  reference: string;

  @Column({ type: 'text' })
  description: string;

  @Column({ name: 'source_module', length: 50, nullable: true })
  sourceModule: string;

  @Column({ name: 'source_document_id', type: 'uuid', nullable: true })
  sourceDocumentId: string;

  @Column({ length: 20, default: 'draft' })
  status: JournalStatus;

  @Column({ name: 'total_debit', type: 'decimal', precision: 18, scale: 4, default: 0 })
  totalDebit: number;

  @Column({ name: 'total_credit', type: 'decimal', precision: 18, scale: 4, default: 0 })
  totalCredit: number;

  @Column({ name: 'posted_at', type: 'timestamptz', nullable: true })
  postedAt: Date;

  @Column({ name: 'posted_by', type: 'uuid', nullable: true })
  postedBy: string;

  @Column({ name: 'reversed_by', type: 'uuid', nullable: true })
  reversedBy: string;

  @Column({ name: 'created_by', type: 'uuid' })
  createdBy: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  @ManyToOne(() => FiscalPeriod)
  @JoinColumn({ name: 'fiscal_period_id' })
  fiscalPeriod: FiscalPeriod;

  @OneToMany(() => JournalLine, l => l.journalEntry, { cascade: true })
  lines: JournalLine[];
}

export type JournalStatus = 'draft' | 'posted' | 'reversed';

JournalLine Entity

@Entity('journal_lines', { schema: 'core_financial' })
export class JournalLine {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'journal_entry_id', type: 'uuid' })
  journalEntryId: string;

  @Column({ name: 'line_number' })
  lineNumber: number;

  @Column({ name: 'account_id', type: 'uuid' })
  accountId: string;

  @Column({ name: 'cost_center_id', type: 'uuid', nullable: true })
  costCenterId: string;

  @Column({ type: 'decimal', precision: 18, scale: 4, default: 0 })
  debit: number;

  @Column({ type: 'decimal', precision: 18, scale: 4, default: 0 })
  credit: number;

  @Column({ name: 'debit_base', type: 'decimal', precision: 18, scale: 4, default: 0 })
  debitBase: number;

  @Column({ name: 'credit_base', type: 'decimal', precision: 18, scale: 4, default: 0 })
  creditBase: number;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ length: 100, nullable: true })
  reference: string;

  @ManyToOne(() => JournalEntry, e => e.lines)
  @JoinColumn({ name: 'journal_entry_id' })
  journalEntry: JournalEntry;

  @ManyToOne(() => Account)
  @JoinColumn({ name: 'account_id' })
  account: Account;

  @ManyToOne(() => CostCenter)
  @JoinColumn({ name: 'cost_center_id' })
  costCenter: CostCenter;
}

Invoice Entity

export enum InvoiceType {
  CUSTOMER = 'customer',
  SUPPLIER = 'supplier',
}

export enum InvoiceStatus {
  DRAFT = 'draft',
  OPEN = 'open',
  PAID = 'paid',
  CANCELLED = 'cancelled',
}

@Entity({ schema: 'financial', name: 'invoices' })
export class Invoice {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', name: 'company_id' })
  companyId: string;

  @Column({ type: 'uuid', name: 'partner_id' })
  partnerId: string;

  @Column({ type: 'enum', enum: InvoiceType, name: 'invoice_type' })
  invoiceType: InvoiceType;

  @Column({ type: 'varchar', length: 100, nullable: true })
  number: string | null;

  @Column({ type: 'varchar', length: 255, nullable: true })
  ref: string | null;

  @Column({ type: 'date', name: 'invoice_date' })
  invoiceDate: Date;

  @Column({ type: 'date', nullable: true, name: 'due_date' })
  dueDate: Date | null;

  @Column({ type: 'uuid', name: 'currency_id' })
  currencyId: string;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_untaxed' })
  amountUntaxed: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_tax' })
  amountTax: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_total' })
  amountTotal: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_paid' })
  amountPaid: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_residual' })
  amountResidual: number;

  @Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.DRAFT })
  status: InvoiceStatus;

  @Column({ type: 'uuid', nullable: true, name: 'payment_term_id' })
  paymentTermId: string | null;

  @Column({ type: 'uuid', nullable: true, name: 'journal_id' })
  journalId: string | null;

  @Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
  journalEntryId: string | null;

  @Column({ type: 'text', nullable: true })
  notes: string | null;

  @OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
  lines: InvoiceLine[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', nullable: true })
  updatedAt: Date | null;
}

InvoiceLine Entity

@Entity({ schema: 'financial', name: 'invoice_lines' })
export class InvoiceLine {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'invoice_id' })
  invoiceId: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', nullable: true, name: 'product_id' })
  productId: string | null;

  @Column({ type: 'text' })
  description: string;

  @Column({ type: 'decimal', precision: 15, scale: 4 })
  quantity: number;

  @Column({ type: 'uuid', nullable: true, name: 'uom_id' })
  uomId: string | null;

  @Column({ type: 'decimal', precision: 15, scale: 2, name: 'price_unit' })
  priceUnit: number;

  @Column({ type: 'uuid', array: true, default: '{}', name: 'tax_ids' })
  taxIds: string[];

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_untaxed' })
  amountUntaxed: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_tax' })
  amountTax: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_total' })
  amountTotal: number;

  @Column({ type: 'uuid', nullable: true, name: 'account_id' })
  accountId: string | null;

  @ManyToOne(() => Invoice, (invoice) => invoice.lines, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'invoice_id' })
  invoice: Invoice;

  @ManyToOne(() => Account)
  @JoinColumn({ name: 'account_id' })
  account: Account | null;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

Payment Entity

export enum PaymentType {
  INBOUND = 'inbound',
  OUTBOUND = 'outbound',
}

export enum PaymentMethod {
  CASH = 'cash',
  BANK_TRANSFER = 'bank_transfer',
  CHECK = 'check',
  CARD = 'card',
  OTHER = 'other',
}

export enum PaymentStatus {
  DRAFT = 'draft',
  POSTED = 'posted',
  RECONCILED = 'reconciled',
  CANCELLED = 'cancelled',
}

@Entity({ schema: 'financial', name: 'payments' })
export class Payment {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', name: 'company_id' })
  companyId: string;

  @Column({ type: 'uuid', name: 'partner_id' })
  partnerId: string;

  @Column({ type: 'enum', enum: PaymentType, name: 'payment_type' })
  paymentType: PaymentType;

  @Column({ type: 'enum', enum: PaymentMethod, name: 'payment_method' })
  paymentMethod: PaymentMethod;

  @Column({ type: 'decimal', precision: 15, scale: 2 })
  amount: number;

  @Column({ type: 'uuid', name: 'currency_id' })
  currencyId: string;

  @Column({ type: 'date', name: 'payment_date' })
  paymentDate: Date;

  @Column({ type: 'varchar', length: 255, nullable: true })
  ref: string | null;

  @Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.DRAFT })
  status: PaymentStatus;

  @Column({ type: 'uuid', name: 'journal_id' })
  journalId: string;

  @Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
  journalEntryId: string | null;

  @Column({ type: 'text', nullable: true })
  notes: string | null;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', nullable: true })
  updatedAt: Date | null;

  @Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
  postedAt: Date | null;
}

PaymentInvoice Entity

@Entity({ schema: 'financial', name: 'payment_invoice' })
export class PaymentInvoice {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'payment_id' })
  paymentId: string;

  @Column({ type: 'uuid', name: 'invoice_id' })
  invoiceId: string;

  @Column({ type: 'decimal', precision: 15, scale: 2 })
  amount: number;

  @ManyToOne(() => Payment)
  @JoinColumn({ name: 'payment_id' })
  payment: Payment;

  @ManyToOne(() => Invoice)
  @JoinColumn({ name: 'invoice_id' })
  invoice: Invoice;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

PaymentMethodCatalog Entity

@Entity({ schema: 'financial', name: 'payment_methods' })
export class PaymentMethodCatalog {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'varchar', length: 50 })
  code: string;

  @Column({ type: 'varchar', length: 20, name: 'payment_type' })
  paymentType: string; // 'inbound' | 'outbound'

  @Column({ type: 'boolean', default: true, name: 'is_active' })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

PaymentTerm Entity

@Entity({ schema: 'financial', name: 'payment_terms' })
export class PaymentTerm {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', name: 'company_id' })
  companyId: string;

  @Column({ type: 'varchar', length: 100 })
  name: string;

  @Column({ type: 'varchar', length: 20 })
  code: string;

  @Column({ type: 'jsonb', default: '[]' })
  terms: object;

  @Column({ type: 'boolean', default: true })
  active: boolean;

  @OneToMany(() => PaymentTermLine, (line) => line.paymentTerm, { cascade: true })
  lines: PaymentTermLine[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', nullable: true })
  updatedAt: Date | null;
}

PaymentTermLine Entity

export type PaymentTermValue = 'balance' | 'percent' | 'fixed';

@Entity({ schema: 'financial', name: 'payment_term_lines' })
export class PaymentTermLine {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'payment_term_id' })
  paymentTermId: string;

  @Column({ type: 'varchar', length: 20, default: 'balance' })
  value: PaymentTermValue;

  @Column({ type: 'decimal', precision: 20, scale: 6, name: 'value_amount', default: 0 })
  valueAmount: number;

  @Column({ type: 'int', name: 'nb_days', default: 0 })
  nbDays: number;

  @Column({ type: 'varchar', length: 20, name: 'delay_type', default: 'days_after' })
  delayType: string;

  @Column({ type: 'int', name: 'day_of_the_month', nullable: true })
  dayOfTheMonth: number | null;

  @Column({ type: 'int', default: 10 })
  sequence: number;

  @ManyToOne(() => PaymentTerm, (term) => term.lines, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'payment_term_id' })
  paymentTerm: PaymentTerm;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

JournalEntryV2 Entity

export enum EntryStatus {
  DRAFT = 'draft',
  POSTED = 'posted',
  CANCELLED = 'cancelled',
}

@Entity({ schema: 'financial', name: 'journal_entries' })
export class JournalEntryV2 {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', name: 'company_id' })
  companyId: string;

  @Column({ type: 'uuid', name: 'journal_id' })
  journalId: string;

  @Column({ type: 'varchar', length: 100 })
  name: string;

  @Column({ type: 'varchar', length: 255, nullable: true })
  ref: string | null;

  @Column({ type: 'date' })
  date: Date;

  @Column({ type: 'enum', enum: EntryStatus, default: EntryStatus.DRAFT })
  status: EntryStatus;

  @Column({ type: 'text', nullable: true })
  notes: string | null;

  @Column({ type: 'uuid', nullable: true, name: 'fiscal_period_id' })
  fiscalPeriodId: string | null;

  @OneToMany(() => JournalEntryLine, (line) => line.entry, { cascade: true })
  lines: JournalEntryLine[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', nullable: true })
  updatedAt: Date | null;

  @Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
  postedAt: Date | null;

  @Column({ type: 'uuid', nullable: true, name: 'posted_by' })
  postedBy: string | null;
}

JournalEntryLine Entity

@Entity({ schema: 'financial', name: 'journal_entry_lines' })
export class JournalEntryLine {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'entry_id' })
  entryId: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', name: 'account_id' })
  accountId: string;

  @Column({ type: 'uuid', nullable: true, name: 'partner_id' })
  partnerId: string | null;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
  debit: number;

  @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
  credit: number;

  @Column({ type: 'text', nullable: true })
  description: string | null;

  @Column({ type: 'varchar', length: 255, nullable: true })
  ref: string | null;

  @ManyToOne(() => JournalEntryV2, (entry) => entry.lines, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'entry_id' })
  entry: JournalEntryV2;

  @ManyToOne(() => Account)
  @JoinColumn({ name: 'account_id' })
  account: Account;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

ReconcileModel Entity

export type ReconcileModelType = 'writeoff_button' | 'writeoff_suggestion' | 'invoice_matching';

@Entity({ schema: 'financial', name: 'reconcile_models' })
export class ReconcileModel {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'int', default: 10 })
  sequence: number;

  @Column({ type: 'varchar', length: 30, name: 'rule_type', default: 'writeoff_button' })
  ruleType: ReconcileModelType;

  @Column({ type: 'boolean', name: 'auto_reconcile', default: false })
  autoReconcile: boolean;

  @Column({ type: 'varchar', length: 20, name: 'match_nature', default: 'both' })
  matchNature: string;

  @Column({ type: 'varchar', length: 20, name: 'match_amount', default: 'any' })
  matchAmount: string;

  @Column({ type: 'decimal', precision: 20, scale: 6, name: 'match_amount_min', nullable: true })
  matchAmountMin: number | null;

  @Column({ type: 'decimal', precision: 20, scale: 6, name: 'match_amount_max', nullable: true })
  matchAmountMax: number | null;

  @Column({ type: 'varchar', length: 50, name: 'match_label', nullable: true })
  matchLabel: string | null;

  @Column({ type: 'varchar', length: 255, name: 'match_label_param', nullable: true })
  matchLabelParam: string | null;

  @Column({ type: 'boolean', name: 'match_partner', default: false })
  matchPartner: boolean;

  @Column({ type: 'uuid', array: true, name: 'match_partner_ids', nullable: true })
  matchPartnerIds: string[] | null;

  @Column({ type: 'boolean', name: 'is_active', default: true })
  isActive: boolean;

  @Column({ type: 'uuid', name: 'company_id', nullable: true })
  companyId: string | null;

  @OneToMany(() => ReconcileModelLine, (line) => line.reconcileModel, { cascade: true })
  lines: ReconcileModelLine[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', nullable: true })
  updatedAt: Date | null;
}

ReconcileModelLine Entity

@Entity({ schema: 'financial', name: 'reconcile_model_lines' })
export class ReconcileModelLine {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'model_id' })
  modelId: string;

  @Column({ type: 'int', default: 10 })
  sequence: number;

  @Column({ type: 'uuid', name: 'account_id' })
  accountId: string;

  @Column({ type: 'uuid', name: 'journal_id', nullable: true })
  journalId: string | null;

  @Column({ type: 'varchar', length: 255, nullable: true })
  label: string | null;

  @Column({ type: 'varchar', length: 20, name: 'amount_type', default: 'percentage' })
  amountType: string;

  @Column({ type: 'decimal', precision: 20, scale: 6, name: 'amount_value', default: 100 })
  amountValue: number;

  @Column({ type: 'uuid', array: true, name: 'tax_ids', nullable: true })
  taxIds: string[] | null;

  @Column({ type: 'uuid', name: 'analytic_account_id', nullable: true })
  analyticAccountId: string | null;

  @ManyToOne(() => ReconcileModel, (model) => model.lines, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'model_id' })
  reconcileModel: ReconcileModel;

  @ManyToOne(() => Account)
  @JoinColumn({ name: 'account_id' })
  account: Account;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

Tax Entity

export enum TaxType {
  SALES = 'sales',
  PURCHASE = 'purchase',
  ALL = 'all',
}

@Entity({ schema: 'financial', name: 'taxes' })
export class Tax {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'tenant_id' })
  tenantId: string;

  @Column({ type: 'uuid', name: 'company_id' })
  companyId: string;

  @Column({ type: 'varchar', length: 100 })
  name: string;

  @Column({ type: 'varchar', length: 20 })
  code: string;

  @Column({ type: 'enum', enum: TaxType, name: 'tax_type' })
  taxType: TaxType;

  @Column({ type: 'decimal', precision: 5, scale: 2 })
  amount: number;

  @Column({ type: 'boolean', default: false, name: 'included_in_price' })
  includedInPrice: boolean;

  @Column({ type: 'boolean', default: true })
  active: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', nullable: true })
  updatedAt: Date | null;
}

Incoterm Entity

@Entity({ schema: 'financial', name: 'incoterms' })
export class Incoterm {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'varchar', length: 10, unique: true })
  code: string;

  @Column({ type: 'boolean', default: true, name: 'is_active' })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

Servicios

AccountsService

@Injectable()
export class AccountsService {
  constructor(
    @InjectRepository(Account)
    private readonly repo: Repository<Account>,
    @InjectRepository(AccountType)
    private readonly typeRepo: Repository<AccountType>,
  ) {}

  async findByChart(chartId: string): Promise<Account[]> {
    return this.repo.find({
      where: { chartId },
      relations: ['accountType', 'parent'],
      order: { code: 'ASC' },
    });
  }

  async findTreeByChart(chartId: string): Promise<Account[]> {
    const accounts = await this.findByChart(chartId);
    return this.buildTree(accounts);
  }

  async create(chartId: string, dto: CreateAccountDto): Promise<Account> {
    // Validate parent
    if (dto.parentId) {
      const parent = await this.repo.findOne({ where: { id: dto.parentId } });
      if (!parent) {
        throw new NotFoundException('Parent account not found');
      }
      if (parent.isDetail) {
        throw new BadRequestException('Cannot add child to detail account');
      }
    }

    // Validate code uniqueness
    const existing = await this.repo.findOne({
      where: { chartId, code: dto.code },
    });
    if (existing) {
      throw new ConflictException('Account code already exists');
    }

    // Calculate level
    const level = dto.parentId
      ? (await this.repo.findOne({ where: { id: dto.parentId } })).level + 1
      : 1;

    const account = this.repo.create({
      ...dto,
      chartId,
      tenantId: dto.tenantId,
      level,
    });

    return this.repo.save(account);
  }

  async update(id: string, dto: UpdateAccountDto): Promise<Account> {
    const account = await this.repo.findOneOrFail({ where: { id } });

    // Cannot change code if has movements
    if (dto.code && dto.code !== account.code) {
      const hasMovements = await this.hasMovements(id);
      if (hasMovements) {
        throw new BadRequestException('Cannot change code of account with movements');
      }
    }

    Object.assign(account, dto);
    return this.repo.save(account);
  }

  async getBalance(accountId: string, asOfDate?: Date): Promise<AccountBalance> {
    const account = await this.repo.findOne({
      where: { id: accountId },
      relations: ['accountType'],
    });

    const qb = this.repo.manager
      .createQueryBuilder(JournalLine, 'jl')
      .select('COALESCE(SUM(jl.debit_base), 0)', 'debitTotal')
      .addSelect('COALESCE(SUM(jl.credit_base), 0)', 'creditTotal')
      .innerJoin('jl.journalEntry', 'je')
      .where('jl.account_id = :accountId', { accountId })
      .andWhere('je.status = :status', { status: 'posted' });

    if (asOfDate) {
      qb.andWhere('je.entry_date <= :asOfDate', { asOfDate });
    }

    const result = await qb.getRawOne();

    const debitTotal = parseFloat(result.debitTotal);
    const creditTotal = parseFloat(result.creditTotal);

    const balance = account.accountType.normalBalance === 'debit'
      ? account.openingBalance + debitTotal - creditTotal
      : account.openingBalance + creditTotal - debitTotal;

    return {
      accountId,
      openingBalance: account.openingBalance,
      debitTotal,
      creditTotal,
      balance,
    };
  }

  async getTrialBalance(
    chartId: string,
    periodId: string
  ): Promise<TrialBalanceItem[]> {
    const accounts = await this.repo.find({
      where: { chartId, isDetail: true },
      relations: ['accountType'],
      order: { code: 'ASC' },
    });

    const period = await this.repo.manager.findOne(FiscalPeriod, {
      where: { id: periodId },
    });

    const result: TrialBalanceItem[] = [];

    for (const account of accounts) {
      const balance = await this.getBalance(account.id, period.endDate);

      if (balance.debitTotal > 0 || balance.creditTotal > 0 || balance.balance !== 0) {
        result.push({
          accountCode: account.code,
          accountName: account.name,
          openingBalance: account.openingBalance,
          debit: balance.debitTotal,
          credit: balance.creditTotal,
          balance: balance.balance,
        });
      }
    }

    return result;
  }

  private buildTree(accounts: Account[]): Account[] {
    const map = new Map<string, Account>();
    const roots: Account[] = [];

    accounts.forEach(a => map.set(a.id, { ...a, children: [] }));

    map.forEach(account => {
      if (account.parentId) {
        const parent = map.get(account.parentId);
        if (parent) {
          parent.children.push(account);
        }
      } else {
        roots.push(account);
      }
    });

    return roots;
  }

  private async hasMovements(accountId: string): Promise<boolean> {
    const count = await this.repo.manager.count(JournalLine, {
      where: { accountId },
    });
    return count > 0;
  }
}

JournalService

@Injectable()
export class JournalService {
  constructor(
    @InjectRepository(JournalEntry)
    private readonly entryRepo: Repository<JournalEntry>,
    @InjectRepository(JournalLine)
    private readonly lineRepo: Repository<JournalLine>,
    private readonly periodsService: FiscalPeriodsService,
    private readonly currenciesService: CurrenciesService,
    private readonly accountsService: AccountsService,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  async findAll(
    tenantId: string,
    query: QueryJournalDto
  ): Promise<PaginatedResult<JournalEntry>> {
    const qb = this.entryRepo.createQueryBuilder('je')
      .where('je.tenant_id = :tenantId', { tenantId })
      .leftJoinAndSelect('je.lines', 'lines')
      .leftJoinAndSelect('lines.account', 'account');

    if (query.periodId) {
      qb.andWhere('je.fiscal_period_id = :periodId', { periodId: query.periodId });
    }

    if (query.status) {
      qb.andWhere('je.status = :status', { status: query.status });
    }

    if (query.dateFrom) {
      qb.andWhere('je.entry_date >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('je.entry_date <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('je.entry_date', 'DESC')
      .addOrderBy('je.entry_number', 'DESC');

    return paginate(qb, query);
  }

  async findById(id: string): Promise<JournalEntry> {
    return this.entryRepo.findOne({
      where: { id },
      relations: ['lines', 'lines.account', 'lines.costCenter', 'fiscalPeriod'],
    });
  }

  async create(
    tenantId: string,
    userId: string,
    dto: CreateJournalEntryDto
  ): Promise<JournalEntry> {
    // Validate period
    const period = await this.periodsService.findOpenForDate(tenantId, dto.entryDate);
    if (!period) {
      throw new BadRequestException('No open period for the entry date');
    }

    // Get exchange rate
    const baseCurrency = await this.currenciesService.getBaseCurrency(tenantId);
    const exchangeRate = dto.currencyCode === baseCurrency.code
      ? 1
      : await this.currenciesService.getRate(tenantId, dto.currencyCode, baseCurrency.code, dto.entryDate);

    // Generate entry number
    const entryNumber = await this.generateEntryNumber(tenantId);

    // Calculate totals and base amounts
    let totalDebit = 0;
    let totalCredit = 0;

    const lines = dto.lines.map((line, index) => {
      totalDebit += line.debit || 0;
      totalCredit += line.credit || 0;

      return {
        ...line,
        lineNumber: index + 1,
        debitBase: (line.debit || 0) * exchangeRate,
        creditBase: (line.credit || 0) * exchangeRate,
      };
    });

    const entry = this.entryRepo.create({
      tenantId,
      fiscalPeriodId: period.id,
      entryNumber,
      entryDate: dto.entryDate,
      currencyCode: dto.currencyCode,
      exchangeRate,
      reference: dto.reference,
      description: dto.description,
      sourceModule: dto.sourceModule,
      sourceDocumentId: dto.sourceDocumentId,
      totalDebit,
      totalCredit,
      createdBy: userId,
      lines,
    });

    return this.entryRepo.save(entry);
  }

  async post(id: string, userId: string): Promise<JournalEntry> {
    return this.dataSource.transaction(async manager => {
      const entry = await manager.findOne(JournalEntry, {
        where: { id },
        relations: ['lines', 'lines.account', 'lines.account.accountType'],
        lock: { mode: 'pessimistic_write' },
      });

      if (!entry) {
        throw new NotFoundException('Journal entry not found');
      }

      if (entry.status !== 'draft') {
        throw new BadRequestException('Entry is not in draft status');
      }

      // Verify period is open
      const period = await manager.findOne(FiscalPeriod, {
        where: { id: entry.fiscalPeriodId },
      });

      if (period.status !== 'open') {
        throw new BadRequestException('Fiscal period is not open');
      }

      // Verify balance
      if (Math.abs(entry.totalDebit - entry.totalCredit) > 0.001) {
        throw new BadRequestException(
          `Entry is not balanced: debit=${entry.totalDebit} credit=${entry.totalCredit}`
        );
      }

      // Update account balances
      for (const line of entry.lines) {
        const amount = line.account.accountType.normalBalance === 'debit'
          ? line.debitBase - line.creditBase
          : line.creditBase - line.debitBase;

        await manager.update(Account, line.accountId, {
          currentBalance: () => `current_balance + ${amount}`,
          updatedAt: new Date(),
        });
      }

      // Mark as posted
      entry.status = 'posted';
      entry.postedAt = new Date();
      entry.postedBy = userId;

      return manager.save(entry);
    });
  }

  async reverse(id: string, userId: string, description: string): Promise<JournalEntry> {
    const original = await this.findById(id);

    if (original.status !== 'posted') {
      throw new BadRequestException('Only posted entries can be reversed');
    }

    // Create reversal entry
    const reversalDto: CreateJournalEntryDto = {
      entryDate: new Date(),
      currencyCode: original.currencyCode,
      description: description || `Reversal of ${original.entryNumber}`,
      reference: original.entryNumber,
      lines: original.lines.map(line => ({
        accountId: line.accountId,
        costCenterId: line.costCenterId,
        debit: line.credit, // Swap debit/credit
        credit: line.debit,
        description: `Reversal: ${line.description || ''}`,
      })),
    };

    const reversal = await this.create(original.tenantId, userId, reversalDto);

    // Post the reversal
    await this.post(reversal.id, userId);

    // Mark original as reversed
    await this.entryRepo.update(id, { status: 'reversed', reversedBy: reversal.id });

    return this.findById(reversal.id);
  }

  async getLedger(
    accountId: string,
    query: QueryLedgerDto
  ): Promise<LedgerEntry[]> {
    const qb = this.lineRepo.createQueryBuilder('jl')
      .innerJoinAndSelect('jl.journalEntry', 'je')
      .where('jl.account_id = :accountId', { accountId })
      .andWhere('je.status = :status', { status: 'posted' });

    if (query.dateFrom) {
      qb.andWhere('je.entry_date >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('je.entry_date <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('je.entry_date', 'ASC')
      .addOrderBy('je.entry_number', 'ASC')
      .addOrderBy('jl.line_number', 'ASC');

    const lines = await qb.getMany();

    let runningBalance = 0;
    return lines.map(line => {
      runningBalance += line.debitBase - line.creditBase;
      return {
        date: line.journalEntry.entryDate,
        entryNumber: line.journalEntry.entryNumber,
        description: line.description || line.journalEntry.description,
        reference: line.reference,
        debit: line.debitBase,
        credit: line.creditBase,
        balance: runningBalance,
      };
    });
  }

  private async generateEntryNumber(tenantId: string): Promise<string> {
    const year = new Date().getFullYear();
    const prefix = `JE-${year}-`;

    const lastEntry = await this.entryRepo.findOne({
      where: { tenantId, entryNumber: Like(`${prefix}%`) },
      order: { entryNumber: 'DESC' },
    });

    let sequence = 1;
    if (lastEntry) {
      const lastNum = parseInt(lastEntry.entryNumber.replace(prefix, ''));
      sequence = lastNum + 1;
    }

    return `${prefix}${sequence.toString().padStart(6, '0')}`;
  }
}

CurrenciesService

@Injectable()
export class CurrenciesService {
  constructor(
    @InjectRepository(TenantCurrency)
    private readonly currencyRepo: Repository<TenantCurrency>,
    @InjectRepository(ExchangeRate)
    private readonly rateRepo: Repository<ExchangeRate>,
  ) {}

  async findByTenant(tenantId: string): Promise<TenantCurrency[]> {
    return this.currencyRepo.find({
      where: { tenantId, isActive: true },
      relations: ['currency'],
    });
  }

  async getBaseCurrency(tenantId: string): Promise<TenantCurrency> {
    const base = await this.currencyRepo.findOne({
      where: { tenantId, isBase: true },
    });

    if (!base) {
      throw new NotFoundException('Base currency not configured');
    }

    return base;
  }

  async getRate(
    tenantId: string,
    fromCurrency: string,
    toCurrency: string,
    date: Date = new Date()
  ): Promise<number> {
    if (fromCurrency === toCurrency) {
      return 1;
    }

    // Try direct rate
    const directRate = await this.rateRepo.findOne({
      where: {
        tenantId,
        fromCurrency,
        toCurrency,
        effectiveDate: LessThanOrEqual(date),
      },
      order: { effectiveDate: 'DESC' },
    });

    if (directRate) {
      return directRate.rate;
    }

    // Try inverse rate
    const inverseRate = await this.rateRepo.findOne({
      where: {
        tenantId,
        fromCurrency: toCurrency,
        toCurrency: fromCurrency,
        effectiveDate: LessThanOrEqual(date),
      },
      order: { effectiveDate: 'DESC' },
    });

    if (inverseRate) {
      return 1 / inverseRate.rate;
    }

    throw new NotFoundException(
      `Exchange rate not found for ${fromCurrency} to ${toCurrency}`
    );
  }

  async convert(
    tenantId: string,
    amount: number,
    fromCurrency: string,
    toCurrency: string,
    date?: Date
  ): Promise<ConversionResult> {
    const rate = await this.getRate(tenantId, fromCurrency, toCurrency, date);
    const convertedAmount = amount * rate;

    return {
      originalAmount: amount,
      originalCurrency: fromCurrency,
      convertedAmount,
      targetCurrency: toCurrency,
      rate,
      date: date || new Date(),
    };
  }

  async setRate(
    tenantId: string,
    userId: string,
    dto: CreateExchangeRateDto
  ): Promise<ExchangeRate> {
    const rate = this.rateRepo.create({
      tenantId,
      fromCurrency: dto.fromCurrency,
      toCurrency: dto.toCurrency,
      rate: dto.rate,
      effectiveDate: dto.effectiveDate,
      source: dto.source,
      createdBy: userId,
    });

    return this.rateRepo.save(rate);
  }
}

FiscalPeriodsService

@Injectable()
export class FiscalPeriodsService {
  constructor(
    @InjectRepository(FiscalYear)
    private readonly yearRepo: Repository<FiscalYear>,
    @InjectRepository(FiscalPeriod)
    private readonly periodRepo: Repository<FiscalPeriod>,
  ) {}

  async findOpenForDate(tenantId: string, date: Date): Promise<FiscalPeriod> {
    return this.periodRepo.findOne({
      where: {
        tenantId,
        startDate: LessThanOrEqual(date),
        endDate: MoreThanOrEqual(date),
        status: 'open',
      },
    });
  }

  async createYear(
    tenantId: string,
    dto: CreateFiscalYearDto
  ): Promise<FiscalYear> {
    const year = this.yearRepo.create({
      tenantId,
      name: dto.name,
      startDate: dto.startDate,
      endDate: dto.endDate,
    });

    const savedYear = await this.yearRepo.save(year);

    // Create monthly periods
    if (dto.createMonthlyPeriods) {
      await this.createMonthlyPeriods(savedYear);
    }

    return this.yearRepo.findOne({
      where: { id: savedYear.id },
      relations: ['periods'],
    });
  }

  async closePeriod(
    periodId: string,
    userId: string
  ): Promise<FiscalPeriod> {
    const period = await this.periodRepo.findOneOrFail({
      where: { id: periodId },
    });

    if (period.status !== 'open') {
      throw new BadRequestException('Period is not open');
    }

    // Check for draft entries
    const draftCount = await this.periodRepo.manager.count(JournalEntry, {
      where: { fiscalPeriodId: periodId, status: 'draft' },
    });

    if (draftCount > 0) {
      throw new BadRequestException(
        `Cannot close period with ${draftCount} draft entries`
      );
    }

    period.status = 'closed';
    period.closedAt = new Date();
    period.closedBy = userId;

    return this.periodRepo.save(period);
  }

  async reopenPeriod(
    periodId: string,
    userId: string
  ): Promise<FiscalPeriod> {
    const period = await this.periodRepo.findOneOrFail({
      where: { id: periodId },
      relations: ['fiscalYear'],
    });

    if (period.status !== 'closed') {
      throw new BadRequestException('Period is not closed');
    }

    if (period.fiscalYear.status === 'closed') {
      throw new BadRequestException('Cannot reopen period of closed fiscal year');
    }

    period.status = 'open';
    period.closedAt = null;
    period.closedBy = null;

    return this.periodRepo.save(period);
  }

  private async createMonthlyPeriods(year: FiscalYear): Promise<void> {
    const periods: Partial<FiscalPeriod>[] = [];
    let currentDate = new Date(year.startDate);
    let periodNumber = 1;

    while (currentDate < year.endDate) {
      const startOfMonth = startOfMonth(currentDate);
      const endOfMonth = endOfMonth(currentDate);

      periods.push({
        fiscalYearId: year.id,
        tenantId: year.tenantId,
        name: format(currentDate, 'MMMM yyyy'),
        periodNumber,
        startDate: startOfMonth > year.startDate ? startOfMonth : year.startDate,
        endDate: endOfMonth < year.endDate ? endOfMonth : year.endDate,
      });

      currentDate = addMonths(currentDate, 1);
      periodNumber++;
    }

    await this.periodRepo.save(periods);
  }
}

InvoicesService

@Injectable()
export class InvoicesService {
  constructor(
    @InjectRepository(Invoice)
    private readonly invoiceRepo: Repository<Invoice>,
    @InjectRepository(InvoiceLine)
    private readonly lineRepo: Repository<InvoiceLine>,
    private readonly taxesService: TaxesService,
    private readonly journalEntriesService: JournalEntriesService,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  async findAll(
    tenantId: string,
    query: QueryInvoiceDto
  ): Promise<PaginatedResult<Invoice>> {
    const qb = this.invoiceRepo.createQueryBuilder('inv')
      .where('inv.tenant_id = :tenantId', { tenantId })
      .leftJoinAndSelect('inv.lines', 'lines');

    if (query.invoiceType) {
      qb.andWhere('inv.invoice_type = :type', { type: query.invoiceType });
    }

    if (query.status) {
      qb.andWhere('inv.status = :status', { status: query.status });
    }

    if (query.partnerId) {
      qb.andWhere('inv.partner_id = :partnerId', { partnerId: query.partnerId });
    }

    if (query.dateFrom) {
      qb.andWhere('inv.invoice_date >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('inv.invoice_date <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('inv.invoice_date', 'DESC')
      .addOrderBy('inv.number', 'DESC');

    return paginate(qb, query);
  }

  async findById(id: string): Promise<Invoice> {
    return this.invoiceRepo.findOne({
      where: { id },
      relations: ['lines', 'lines.account', 'journal', 'journalEntry'],
    });
  }

  async create(
    tenantId: string,
    companyId: string,
    userId: string,
    dto: CreateInvoiceDto
  ): Promise<Invoice> {
    const lines = await this.calculateLineAmounts(dto.lines);
    const totals = this.calculateInvoiceTotals(lines);

    const invoice = this.invoiceRepo.create({
      tenantId,
      companyId,
      partnerId: dto.partnerId,
      invoiceType: dto.invoiceType,
      invoiceDate: dto.invoiceDate,
      dueDate: dto.dueDate,
      currencyId: dto.currencyId,
      paymentTermId: dto.paymentTermId,
      journalId: dto.journalId,
      notes: dto.notes,
      ref: dto.ref,
      ...totals,
      amountResidual: totals.amountTotal,
      createdBy: userId,
      lines: lines.map((line, index) => ({
        ...line,
        tenantId,
        sequence: index + 1,
      })),
    });

    return this.invoiceRepo.save(invoice);
  }

  async update(
    id: string,
    userId: string,
    dto: UpdateInvoiceDto
  ): Promise<Invoice> {
    const invoice = await this.invoiceRepo.findOneOrFail({
      where: { id },
      relations: ['lines'],
    });

    if (invoice.status !== InvoiceStatus.DRAFT) {
      throw new BadRequestException('Only draft invoices can be updated');
    }

    if (dto.lines) {
      await this.lineRepo.delete({ invoiceId: id });
      const lines = await this.calculateLineAmounts(dto.lines);
      const totals = this.calculateInvoiceTotals(lines);

      Object.assign(invoice, dto, totals, {
        amountResidual: totals.amountTotal,
        updatedBy: userId,
        lines: lines.map((line, index) => ({
          ...line,
          invoiceId: id,
          tenantId: invoice.tenantId,
          sequence: index + 1,
        })),
      });
    } else {
      Object.assign(invoice, dto, { updatedBy: userId });
    }

    return this.invoiceRepo.save(invoice);
  }

  async validate(id: string, userId: string): Promise<Invoice> {
    return this.dataSource.transaction(async manager => {
      const invoice = await manager.findOne(Invoice, {
        where: { id },
        relations: ['lines'],
        lock: { mode: 'pessimistic_write' },
      });

      if (!invoice) {
        throw new NotFoundException('Invoice not found');
      }

      if (invoice.status !== InvoiceStatus.DRAFT) {
        throw new BadRequestException('Invoice is not in draft status');
      }

      // Generate invoice number
      const number = await this.generateInvoiceNumber(
        manager,
        invoice.tenantId,
        invoice.invoiceType
      );

      // Create journal entry
      const journalEntry = await this.createJournalEntry(manager, invoice, userId);

      invoice.number = number;
      invoice.status = InvoiceStatus.OPEN;
      invoice.journalEntryId = journalEntry.id;
      invoice.validatedAt = new Date();
      invoice.validatedBy = userId;

      return manager.save(invoice);
    });
  }

  async cancel(id: string, userId: string): Promise<Invoice> {
    const invoice = await this.invoiceRepo.findOneOrFail({ where: { id } });

    if (invoice.status === InvoiceStatus.CANCELLED) {
      throw new BadRequestException('Invoice is already cancelled');
    }

    if (invoice.amountPaid > 0) {
      throw new BadRequestException('Cannot cancel invoice with payments');
    }

    invoice.status = InvoiceStatus.CANCELLED;
    invoice.cancelledAt = new Date();
    invoice.cancelledBy = userId;

    return this.invoiceRepo.save(invoice);
  }

  async registerPayment(
    invoiceId: string,
    amount: number
  ): Promise<Invoice> {
    const invoice = await this.invoiceRepo.findOneOrFail({ where: { id: invoiceId } });

    invoice.amountPaid += amount;
    invoice.amountResidual = invoice.amountTotal - invoice.amountPaid;

    if (invoice.amountResidual <= 0) {
      invoice.status = InvoiceStatus.PAID;
    }

    return this.invoiceRepo.save(invoice);
  }

  private async calculateLineAmounts(lines: CreateInvoiceLineDto[]): Promise<InvoiceLine[]> {
    return Promise.all(lines.map(async line => {
      const amountUntaxed = line.quantity * line.priceUnit;
      let amountTax = 0;

      if (line.taxIds?.length > 0) {
        // Calculate taxes
        for (const taxId of line.taxIds) {
          const tax = await this.taxesService.findById(taxId);
          amountTax += amountUntaxed * (tax.amount / 100);
        }
      }

      return {
        ...line,
        amountUntaxed,
        amountTax,
        amountTotal: amountUntaxed + amountTax,
      } as InvoiceLine;
    }));
  }

  private calculateInvoiceTotals(lines: InvoiceLine[]): {
    amountUntaxed: number;
    amountTax: number;
    amountTotal: number;
  } {
    return lines.reduce(
      (totals, line) => ({
        amountUntaxed: totals.amountUntaxed + line.amountUntaxed,
        amountTax: totals.amountTax + line.amountTax,
        amountTotal: totals.amountTotal + line.amountTotal,
      }),
      { amountUntaxed: 0, amountTax: 0, amountTotal: 0 }
    );
  }

  private async generateInvoiceNumber(
    manager: EntityManager,
    tenantId: string,
    type: InvoiceType
  ): Promise<string> {
    const prefix = type === InvoiceType.CUSTOMER ? 'INV' : 'BILL';
    const year = new Date().getFullYear();
    const pattern = `${prefix}-${year}-%`;

    const lastInvoice = await manager.findOne(Invoice, {
      where: { tenantId, number: Like(pattern) },
      order: { number: 'DESC' },
    });

    let sequence = 1;
    if (lastInvoice) {
      const lastNum = parseInt(lastInvoice.number.split('-').pop());
      sequence = lastNum + 1;
    }

    return `${prefix}-${year}-${sequence.toString().padStart(6, '0')}`;
  }

  private async createJournalEntry(
    manager: EntityManager,
    invoice: Invoice,
    userId: string
  ): Promise<JournalEntry> {
    // Implementation creates corresponding journal entry
    // for the invoice based on configured accounts
  }
}

PaymentsService

/**
 * PaymentsService - Gestion de pagos con reconciliacion
 * Tests: 36 tests unitarios
 */
@Injectable()
export class PaymentsService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepo: Repository<Payment>,
    @InjectRepository(PaymentInvoice)
    private readonly paymentInvoiceRepo: Repository<PaymentInvoice>,
    private readonly invoicesService: InvoicesService,
    private readonly journalEntriesService: JournalEntriesService,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  async findAll(
    tenantId: string,
    query: QueryPaymentDto
  ): Promise<PaginatedResult<Payment>> {
    const qb = this.paymentRepo.createQueryBuilder('p')
      .where('p.tenant_id = :tenantId', { tenantId })
      .leftJoinAndSelect('p.journal', 'journal');

    if (query.paymentType) {
      qb.andWhere('p.payment_type = :type', { type: query.paymentType });
    }

    if (query.status) {
      qb.andWhere('p.status = :status', { status: query.status });
    }

    if (query.partnerId) {
      qb.andWhere('p.partner_id = :partnerId', { partnerId: query.partnerId });
    }

    if (query.dateFrom) {
      qb.andWhere('p.payment_date >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('p.payment_date <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('p.payment_date', 'DESC');

    return paginate(qb, query);
  }

  async findById(id: string): Promise<Payment> {
    return this.paymentRepo.findOne({
      where: { id },
      relations: ['journal', 'journalEntry'],
    });
  }

  async create(
    tenantId: string,
    companyId: string,
    userId: string,
    dto: CreatePaymentDto
  ): Promise<Payment> {
    const payment = this.paymentRepo.create({
      tenantId,
      companyId,
      partnerId: dto.partnerId,
      paymentType: dto.paymentType,
      paymentMethod: dto.paymentMethod,
      amount: dto.amount,
      currencyId: dto.currencyId,
      paymentDate: dto.paymentDate,
      journalId: dto.journalId,
      ref: dto.ref,
      notes: dto.notes,
      createdBy: userId,
    });

    return this.paymentRepo.save(payment);
  }

  async update(
    id: string,
    userId: string,
    dto: UpdatePaymentDto
  ): Promise<Payment> {
    const payment = await this.paymentRepo.findOneOrFail({ where: { id } });

    if (payment.status !== PaymentStatus.DRAFT) {
      throw new BadRequestException('Only draft payments can be updated');
    }

    Object.assign(payment, dto, { updatedBy: userId });
    return this.paymentRepo.save(payment);
  }

  async post(id: string, userId: string): Promise<Payment> {
    return this.dataSource.transaction(async manager => {
      const payment = await manager.findOne(Payment, {
        where: { id },
        lock: { mode: 'pessimistic_write' },
      });

      if (!payment) {
        throw new NotFoundException('Payment not found');
      }

      if (payment.status !== PaymentStatus.DRAFT) {
        throw new BadRequestException('Payment is not in draft status');
      }

      // Create journal entry for payment
      const journalEntry = await this.createPaymentJournalEntry(
        manager,
        payment,
        userId
      );

      payment.status = PaymentStatus.POSTED;
      payment.journalEntryId = journalEntry.id;
      payment.postedAt = new Date();
      payment.postedBy = userId;

      return manager.save(payment);
    });
  }

  async cancel(id: string, userId: string): Promise<Payment> {
    const payment = await this.paymentRepo.findOneOrFail({ where: { id } });

    if (payment.status === PaymentStatus.CANCELLED) {
      throw new BadRequestException('Payment is already cancelled');
    }

    if (payment.status === PaymentStatus.RECONCILED) {
      throw new BadRequestException('Cannot cancel reconciled payment');
    }

    // Reverse invoice allocations
    await this.reverseInvoiceAllocations(payment);

    payment.status = PaymentStatus.CANCELLED;

    return this.paymentRepo.save(payment);
  }

  /**
   * Reconcilia un pago con una o mas facturas
   */
  async reconcile(
    paymentId: string,
    invoiceAllocations: InvoiceAllocationDto[]
  ): Promise<Payment> {
    return this.dataSource.transaction(async manager => {
      const payment = await manager.findOne(Payment, {
        where: { id: paymentId },
        lock: { mode: 'pessimistic_write' },
      });

      if (!payment) {
        throw new NotFoundException('Payment not found');
      }

      if (payment.status !== PaymentStatus.POSTED) {
        throw new BadRequestException('Payment must be posted to reconcile');
      }

      // Validate total allocation amount
      const totalAllocation = invoiceAllocations.reduce(
        (sum, alloc) => sum + alloc.amount,
        0
      );

      if (totalAllocation > payment.amount) {
        throw new BadRequestException('Total allocation exceeds payment amount');
      }

      // Create payment-invoice records and update invoices
      for (const allocation of invoiceAllocations) {
        const invoice = await manager.findOne(Invoice, {
          where: { id: allocation.invoiceId },
          lock: { mode: 'pessimistic_write' },
        });

        if (!invoice) {
          throw new NotFoundException(`Invoice ${allocation.invoiceId} not found`);
        }

        if (allocation.amount > invoice.amountResidual) {
          throw new BadRequestException(
            `Allocation amount exceeds invoice residual for ${invoice.number}`
          );
        }

        // Create payment-invoice link
        const paymentInvoice = manager.create(PaymentInvoice, {
          paymentId,
          invoiceId: allocation.invoiceId,
          amount: allocation.amount,
        });
        await manager.save(paymentInvoice);

        // Update invoice amounts
        invoice.amountPaid += allocation.amount;
        invoice.amountResidual -= allocation.amount;

        if (invoice.amountResidual <= 0) {
          invoice.status = InvoiceStatus.PAID;
        }

        await manager.save(invoice);
      }

      // Mark payment as reconciled if fully allocated
      if (totalAllocation >= payment.amount) {
        payment.status = PaymentStatus.RECONCILED;
      }

      return manager.save(payment);
    });
  }

  /**
   * Obtiene facturas pendientes de un partner para reconciliar
   */
  async getUnreconciledInvoices(
    tenantId: string,
    partnerId: string,
    paymentType: PaymentType
  ): Promise<Invoice[]> {
    const invoiceType = paymentType === PaymentType.INBOUND
      ? InvoiceType.CUSTOMER
      : InvoiceType.SUPPLIER;

    return this.invoicesService.findAll(tenantId, {
      partnerId,
      invoiceType,
      status: InvoiceStatus.OPEN,
    });
  }

  /**
   * Auto-reconcilia pagos con facturas basado en reglas
   */
  async autoReconcile(
    tenantId: string,
    paymentId: string
  ): Promise<Payment> {
    const payment = await this.findById(paymentId);

    // Find matching invoices by partner and amount
    const invoices = await this.getUnreconciledInvoices(
      tenantId,
      payment.partnerId,
      payment.paymentType
    );

    // Try exact match first
    const exactMatch = invoices.find(inv => inv.amountResidual === payment.amount);
    if (exactMatch) {
      return this.reconcile(paymentId, [
        { invoiceId: exactMatch.id, amount: payment.amount }
      ]);
    }

    // Try to allocate to multiple invoices
    const allocations: InvoiceAllocationDto[] = [];
    let remainingAmount = payment.amount;

    for (const invoice of invoices) {
      if (remainingAmount <= 0) break;

      const allocAmount = Math.min(remainingAmount, invoice.amountResidual);
      allocations.push({ invoiceId: invoice.id, amount: allocAmount });
      remainingAmount -= allocAmount;
    }

    if (allocations.length > 0) {
      return this.reconcile(paymentId, allocations);
    }

    return payment;
  }

  private async reverseInvoiceAllocations(payment: Payment): Promise<void> {
    const allocations = await this.paymentInvoiceRepo.find({
      where: { paymentId: payment.id },
    });

    for (const allocation of allocations) {
      const invoice = await this.invoicesService.findById(allocation.invoiceId);
      invoice.amountPaid -= allocation.amount;
      invoice.amountResidual += allocation.amount;

      if (invoice.status === InvoiceStatus.PAID) {
        invoice.status = InvoiceStatus.OPEN;
      }

      await this.invoicesService.update(invoice.id, null, invoice);
    }

    await this.paymentInvoiceRepo.delete({ paymentId: payment.id });
  }

  private async createPaymentJournalEntry(
    manager: EntityManager,
    payment: Payment,
    userId: string
  ): Promise<JournalEntry> {
    // Implementation creates journal entry for payment
    // based on payment type and configured accounts
  }
}

PaymentMethodsService

@Injectable()
export class PaymentMethodsService {
  constructor(
    @InjectRepository(PaymentMethodCatalog)
    private readonly repo: Repository<PaymentMethodCatalog>,
  ) {}

  async findAll(query?: QueryPaymentMethodDto): Promise<PaymentMethodCatalog[]> {
    const qb = this.repo.createQueryBuilder('pm');

    if (query?.paymentType) {
      qb.andWhere('pm.payment_type = :type', { type: query.paymentType });
    }

    if (query?.isActive !== undefined) {
      qb.andWhere('pm.is_active = :isActive', { isActive: query.isActive });
    }

    return qb.orderBy('pm.name', 'ASC').getMany();
  }

  async findById(id: string): Promise<PaymentMethodCatalog> {
    return this.repo.findOneOrFail({ where: { id } });
  }

  async findByCode(code: string, paymentType: string): Promise<PaymentMethodCatalog> {
    return this.repo.findOne({
      where: { code, paymentType },
    });
  }

  async create(dto: CreatePaymentMethodDto): Promise<PaymentMethodCatalog> {
    const existing = await this.findByCode(dto.code, dto.paymentType);
    if (existing) {
      throw new ConflictException('Payment method code already exists for this type');
    }

    const method = this.repo.create(dto);
    return this.repo.save(method);
  }

  async update(id: string, dto: UpdatePaymentMethodDto): Promise<PaymentMethodCatalog> {
    const method = await this.repo.findOneOrFail({ where: { id } });
    Object.assign(method, dto);
    return this.repo.save(method);
  }

  async deactivate(id: string): Promise<PaymentMethodCatalog> {
    const method = await this.repo.findOneOrFail({ where: { id } });
    method.isActive = false;
    return this.repo.save(method);
  }
}

PaymentTermsService

@Injectable()
export class PaymentTermsService {
  constructor(
    @InjectRepository(PaymentTerm)
    private readonly termRepo: Repository<PaymentTerm>,
    @InjectRepository(PaymentTermLine)
    private readonly lineRepo: Repository<PaymentTermLine>,
  ) {}

  async findAll(
    tenantId: string,
    companyId: string
  ): Promise<PaymentTerm[]> {
    return this.termRepo.find({
      where: { tenantId, companyId, active: true },
      relations: ['lines'],
      order: { name: 'ASC' },
    });
  }

  async findById(id: string): Promise<PaymentTerm> {
    return this.termRepo.findOne({
      where: { id },
      relations: ['lines'],
    });
  }

  async create(
    tenantId: string,
    companyId: string,
    userId: string,
    dto: CreatePaymentTermDto
  ): Promise<PaymentTerm> {
    // Validate lines sum to 100% for percentage types
    const percentageLines = dto.lines.filter(l => l.value === 'percent');
    if (percentageLines.length > 0) {
      const totalPercent = percentageLines.reduce((sum, l) => sum + l.valueAmount, 0);
      if (Math.abs(totalPercent - 100) > 0.01) {
        throw new BadRequestException('Percentage lines must sum to 100%');
      }
    }

    const term = this.termRepo.create({
      tenantId,
      companyId,
      name: dto.name,
      code: dto.code,
      createdBy: userId,
      lines: dto.lines.map((line, index) => ({
        ...line,
        sequence: index * 10 + 10,
      })),
    });

    return this.termRepo.save(term);
  }

  async update(
    id: string,
    userId: string,
    dto: UpdatePaymentTermDto
  ): Promise<PaymentTerm> {
    const term = await this.termRepo.findOneOrFail({
      where: { id },
      relations: ['lines'],
    });

    if (dto.lines) {
      await this.lineRepo.delete({ paymentTermId: id });
      term.lines = dto.lines.map((line, index) => ({
        ...line,
        paymentTermId: id,
        sequence: index * 10 + 10,
      } as PaymentTermLine));
    }

    Object.assign(term, dto, { updatedBy: userId });
    return this.termRepo.save(term);
  }

  async deactivate(id: string): Promise<PaymentTerm> {
    const term = await this.termRepo.findOneOrFail({ where: { id } });
    term.active = false;
    return this.termRepo.save(term);
  }

  /**
   * Calcula fechas de vencimiento basadas en terminos de pago
   */
  async calculateDueDates(
    paymentTermId: string,
    invoiceDate: Date,
    totalAmount: number
  ): Promise<PaymentDueDate[]> {
    const term = await this.findById(paymentTermId);
    const dueDates: PaymentDueDate[] = [];

    let remainingAmount = totalAmount;

    for (const line of term.lines.sort((a, b) => a.sequence - b.sequence)) {
      let lineAmount: number;

      switch (line.value) {
        case 'balance':
          lineAmount = remainingAmount;
          break;
        case 'percent':
          lineAmount = totalAmount * (line.valueAmount / 100);
          break;
        case 'fixed':
          lineAmount = Math.min(line.valueAmount, remainingAmount);
          break;
      }

      const dueDate = this.calculateDueDate(invoiceDate, line);

      dueDates.push({
        dueDate,
        amount: lineAmount,
        sequence: line.sequence,
      });

      remainingAmount -= lineAmount;
      if (remainingAmount <= 0) break;
    }

    return dueDates;
  }

  private calculateDueDate(
    invoiceDate: Date,
    line: PaymentTermLine
  ): Date {
    let baseDate = new Date(invoiceDate);

    switch (line.delayType) {
      case 'days_after':
        baseDate.setDate(baseDate.getDate() + line.nbDays);
        break;
      case 'days_after_end_of_month':
        baseDate = endOfMonth(baseDate);
        baseDate.setDate(baseDate.getDate() + line.nbDays);
        break;
      case 'days_after_end_of_next_month':
        baseDate = addMonths(baseDate, 1);
        baseDate = endOfMonth(baseDate);
        baseDate.setDate(baseDate.getDate() + line.nbDays);
        break;
    }

    if (line.dayOfTheMonth) {
      const targetDay = Math.min(line.dayOfTheMonth, getDaysInMonth(baseDate));
      baseDate.setDate(targetDay);
    }

    return baseDate;
  }
}

JournalEntriesService

/**
 * JournalEntriesService - Servicio para asientos contables
 * Incluye funcionalidad de posting de asientos
 */
@Injectable()
export class JournalEntriesService {
  constructor(
    @InjectRepository(JournalEntry)
    private readonly entryRepo: Repository<JournalEntry>,
    @InjectRepository(JournalEntryLine)
    private readonly lineRepo: Repository<JournalEntryLine>,
    private readonly accountsService: AccountsService,
    private readonly periodsService: FiscalPeriodsService,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  async findAll(
    tenantId: string,
    query: QueryJournalEntryDto
  ): Promise<PaginatedResult<JournalEntry>> {
    const qb = this.entryRepo.createQueryBuilder('je')
      .where('je.tenant_id = :tenantId', { tenantId })
      .leftJoinAndSelect('je.lines', 'lines')
      .leftJoinAndSelect('lines.account', 'account')
      .leftJoinAndSelect('je.journal', 'journal');

    if (query.journalId) {
      qb.andWhere('je.journal_id = :journalId', { journalId: query.journalId });
    }

    if (query.status) {
      qb.andWhere('je.status = :status', { status: query.status });
    }

    if (query.dateFrom) {
      qb.andWhere('je.date >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('je.date <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('je.date', 'DESC')
      .addOrderBy('je.name', 'DESC');

    return paginate(qb, query);
  }

  async findById(id: string): Promise<JournalEntry> {
    return this.entryRepo.findOne({
      where: { id },
      relations: ['lines', 'lines.account', 'journal'],
    });
  }

  async create(
    tenantId: string,
    companyId: string,
    userId: string,
    dto: CreateJournalEntryDto
  ): Promise<JournalEntry> {
    // Validate debit = credit
    const totalDebit = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
    const totalCredit = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);

    if (Math.abs(totalDebit - totalCredit) > 0.001) {
      throw new BadRequestException(
        `Entry is not balanced: debit=${totalDebit} credit=${totalCredit}`
      );
    }

    // Generate entry name
    const name = await this.generateEntryName(tenantId, dto.journalId);

    const entry = this.entryRepo.create({
      tenantId,
      companyId,
      journalId: dto.journalId,
      name,
      date: dto.date,
      ref: dto.ref,
      notes: dto.notes,
      createdBy: userId,
      lines: dto.lines.map(line => ({
        ...line,
        tenantId,
      })),
    });

    return this.entryRepo.save(entry);
  }

  async update(
    id: string,
    userId: string,
    dto: UpdateJournalEntryDto
  ): Promise<JournalEntry> {
    const entry = await this.entryRepo.findOneOrFail({
      where: { id },
      relations: ['lines'],
    });

    if (entry.status !== EntryStatus.DRAFT) {
      throw new BadRequestException('Only draft entries can be updated');
    }

    if (dto.lines) {
      // Validate balance
      const totalDebit = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
      const totalCredit = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);

      if (Math.abs(totalDebit - totalCredit) > 0.001) {
        throw new BadRequestException('Entry is not balanced');
      }

      await this.lineRepo.delete({ entryId: id });
      entry.lines = dto.lines.map(line => ({
        ...line,
        entryId: id,
        tenantId: entry.tenantId,
      } as JournalEntryLine));
    }

    Object.assign(entry, dto, { updatedBy: userId });
    return this.entryRepo.save(entry);
  }

  /**
   * Postea un asiento contable, actualizando saldos de cuentas
   */
  async post(id: string, userId: string): Promise<JournalEntry> {
    return this.dataSource.transaction(async manager => {
      const entry = await manager.findOne(JournalEntry, {
        where: { id },
        relations: ['lines', 'lines.account'],
        lock: { mode: 'pessimistic_write' },
      });

      if (!entry) {
        throw new NotFoundException('Journal entry not found');
      }

      if (entry.status !== EntryStatus.DRAFT) {
        throw new BadRequestException('Entry is not in draft status');
      }

      // Validate fiscal period is open
      if (entry.fiscalPeriodId) {
        const period = await manager.findOne(FiscalPeriod, {
          where: { id: entry.fiscalPeriodId },
        });

        if (period?.status !== 'open') {
          throw new BadRequestException('Fiscal period is not open');
        }
      }

      // Update account balances
      for (const line of entry.lines) {
        const account = await manager.findOne(Account, {
          where: { id: line.accountId },
          relations: ['accountType'],
          lock: { mode: 'pessimistic_write' },
        });

        const balanceChange = account.accountType.normalBalance === 'debit'
          ? line.debit - line.credit
          : line.credit - line.debit;

        await manager.update(Account, line.accountId, {
          currentBalance: () => `current_balance + ${balanceChange}`,
          updatedAt: new Date(),
        });
      }

      entry.status = EntryStatus.POSTED;
      entry.postedAt = new Date();
      entry.postedBy = userId;

      return manager.save(entry);
    });
  }

  /**
   * Cancela un asiento contable, revirtiendo saldos
   */
  async cancel(id: string, userId: string): Promise<JournalEntry> {
    return this.dataSource.transaction(async manager => {
      const entry = await manager.findOne(JournalEntry, {
        where: { id },
        relations: ['lines', 'lines.account'],
        lock: { mode: 'pessimistic_write' },
      });

      if (!entry) {
        throw new NotFoundException('Journal entry not found');
      }

      if (entry.status === EntryStatus.CANCELLED) {
        throw new BadRequestException('Entry is already cancelled');
      }

      // Reverse account balances if entry was posted
      if (entry.status === EntryStatus.POSTED) {
        for (const line of entry.lines) {
          const account = await manager.findOne(Account, {
            where: { id: line.accountId },
            relations: ['accountType'],
            lock: { mode: 'pessimistic_write' },
          });

          const balanceChange = account.accountType.normalBalance === 'debit'
            ? line.credit - line.debit  // Reverse
            : line.debit - line.credit;

          await manager.update(Account, line.accountId, {
            currentBalance: () => `current_balance + ${balanceChange}`,
            updatedAt: new Date(),
          });
        }
      }

      entry.status = EntryStatus.CANCELLED;
      entry.cancelledAt = new Date();
      entry.cancelledBy = userId;

      return manager.save(entry);
    });
  }

  private async generateEntryName(
    tenantId: string,
    journalId: string
  ): Promise<string> {
    const journal = await this.dataSource.manager.findOne(Journal, {
      where: { id: journalId },
    });

    const year = new Date().getFullYear();
    const month = (new Date().getMonth() + 1).toString().padStart(2, '0');
    const prefix = `${journal.code}/${year}/${month}/`;

    const lastEntry = await this.entryRepo.findOne({
      where: { tenantId, name: Like(`${prefix}%`) },
      order: { name: 'DESC' },
    });

    let sequence = 1;
    if (lastEntry) {
      const lastNum = parseInt(lastEntry.name.split('/').pop());
      sequence = lastNum + 1;
    }

    return `${prefix}${sequence.toString().padStart(4, '0')}`;
  }
}

ReconcileModelsService

@Injectable()
export class ReconcileModelsService {
  constructor(
    @InjectRepository(ReconcileModel)
    private readonly modelRepo: Repository<ReconcileModel>,
    @InjectRepository(ReconcileModelLine)
    private readonly lineRepo: Repository<ReconcileModelLine>,
  ) {}

  async findAll(
    tenantId: string,
    companyId?: string
  ): Promise<ReconcileModel[]> {
    const where: any = { tenantId, isActive: true };
    if (companyId) {
      where.companyId = companyId;
    }

    return this.modelRepo.find({
      where,
      relations: ['lines', 'lines.account'],
      order: { sequence: 'ASC' },
    });
  }

  async findById(id: string): Promise<ReconcileModel> {
    return this.modelRepo.findOne({
      where: { id },
      relations: ['lines', 'lines.account', 'lines.journal'],
    });
  }

  async create(
    tenantId: string,
    dto: CreateReconcileModelDto
  ): Promise<ReconcileModel> {
    const model = this.modelRepo.create({
      tenantId,
      companyId: dto.companyId,
      name: dto.name,
      sequence: dto.sequence || 10,
      ruleType: dto.ruleType,
      autoReconcile: dto.autoReconcile,
      matchNature: dto.matchNature,
      matchAmount: dto.matchAmount,
      matchAmountMin: dto.matchAmountMin,
      matchAmountMax: dto.matchAmountMax,
      matchLabel: dto.matchLabel,
      matchLabelParam: dto.matchLabelParam,
      matchPartner: dto.matchPartner,
      matchPartnerIds: dto.matchPartnerIds,
      lines: dto.lines?.map((line, index) => ({
        ...line,
        sequence: index * 10 + 10,
      })),
    });

    return this.modelRepo.save(model);
  }

  async update(
    id: string,
    dto: UpdateReconcileModelDto
  ): Promise<ReconcileModel> {
    const model = await this.modelRepo.findOneOrFail({
      where: { id },
      relations: ['lines'],
    });

    if (dto.lines) {
      await this.lineRepo.delete({ modelId: id });
      model.lines = dto.lines.map((line, index) => ({
        ...line,
        modelId: id,
        sequence: index * 10 + 10,
      } as ReconcileModelLine));
    }

    Object.assign(model, dto);
    return this.modelRepo.save(model);
  }

  async deactivate(id: string): Promise<ReconcileModel> {
    const model = await this.modelRepo.findOneOrFail({ where: { id } });
    model.isActive = false;
    return this.modelRepo.save(model);
  }

  /**
   * Encuentra modelos de reconciliacion que coincidan con una transaccion
   */
  async findMatchingModels(
    tenantId: string,
    transaction: ReconcileTransaction
  ): Promise<ReconcileModel[]> {
    const models = await this.findAll(tenantId, transaction.companyId);

    return models.filter(model => this.matchesModel(model, transaction));
  }

  /**
   * Aplica un modelo de reconciliacion a una transaccion
   */
  async applyModel(
    modelId: string,
    transaction: ReconcileTransaction
  ): Promise<ReconcileResult> {
    const model = await this.findById(modelId);

    if (!this.matchesModel(model, transaction)) {
      throw new BadRequestException('Model does not match transaction');
    }

    const entries: ReconcileEntryLine[] = [];
    let remainingAmount = Math.abs(transaction.amount);

    for (const line of model.lines.sort((a, b) => a.sequence - b.sequence)) {
      let lineAmount: number;

      switch (line.amountType) {
        case 'percentage':
          lineAmount = remainingAmount * (line.amountValue / 100);
          break;
        case 'fixed':
          lineAmount = Math.min(line.amountValue, remainingAmount);
          break;
        default:
          lineAmount = remainingAmount;
      }

      entries.push({
        accountId: line.accountId,
        journalId: line.journalId,
        label: line.label,
        amount: lineAmount,
        taxIds: line.taxIds,
        analyticAccountId: line.analyticAccountId,
      });

      remainingAmount -= lineAmount;
      if (remainingAmount <= 0) break;
    }

    return {
      modelId,
      entries,
      autoReconcile: model.autoReconcile,
    };
  }

  private matchesModel(
    model: ReconcileModel,
    transaction: ReconcileTransaction
  ): boolean {
    // Match nature (inbound/outbound/both)
    if (model.matchNature !== 'both') {
      const isInbound = transaction.amount > 0;
      if (model.matchNature === 'amount_received' && !isInbound) return false;
      if (model.matchNature === 'amount_paid' && isInbound) return false;
    }

    // Match amount range
    const amount = Math.abs(transaction.amount);
    switch (model.matchAmount) {
      case 'lower':
        if (amount >= model.matchAmountMax) return false;
        break;
      case 'greater':
        if (amount <= model.matchAmountMin) return false;
        break;
      case 'between':
        if (amount < model.matchAmountMin || amount > model.matchAmountMax) return false;
        break;
    }

    // Match label
    if (model.matchLabel && transaction.label) {
      switch (model.matchLabel) {
        case 'contains':
          if (!transaction.label.includes(model.matchLabelParam)) return false;
          break;
        case 'not_contains':
          if (transaction.label.includes(model.matchLabelParam)) return false;
          break;
        case 'match_regex':
          const regex = new RegExp(model.matchLabelParam);
          if (!regex.test(transaction.label)) return false;
          break;
      }
    }

    // Match partner
    if (model.matchPartner && model.matchPartnerIds?.length > 0) {
      if (!model.matchPartnerIds.includes(transaction.partnerId)) return false;
    }

    return true;
  }
}

TaxesService

@Injectable()
export class TaxesService {
  constructor(
    @InjectRepository(Tax)
    private readonly repo: Repository<Tax>,
  ) {}

  async findAll(
    tenantId: string,
    companyId: string,
    query?: QueryTaxDto
  ): Promise<Tax[]> {
    const qb = this.repo.createQueryBuilder('t')
      .where('t.tenant_id = :tenantId', { tenantId })
      .andWhere('t.company_id = :companyId', { companyId });

    if (query?.taxType) {
      qb.andWhere('t.tax_type IN (:...types)', {
        types: query.taxType === TaxType.ALL
          ? [TaxType.SALES, TaxType.PURCHASE, TaxType.ALL]
          : [query.taxType, TaxType.ALL],
      });
    }

    if (query?.active !== undefined) {
      qb.andWhere('t.active = :active', { active: query.active });
    }

    return qb.orderBy('t.name', 'ASC').getMany();
  }

  async findById(id: string): Promise<Tax> {
    return this.repo.findOneOrFail({ where: { id } });
  }

  async findByCode(
    tenantId: string,
    code: string
  ): Promise<Tax> {
    return this.repo.findOne({ where: { tenantId, code } });
  }

  async create(
    tenantId: string,
    companyId: string,
    userId: string,
    dto: CreateTaxDto
  ): Promise<Tax> {
    const existing = await this.findByCode(tenantId, dto.code);
    if (existing) {
      throw new ConflictException('Tax code already exists');
    }

    const tax = this.repo.create({
      tenantId,
      companyId,
      name: dto.name,
      code: dto.code,
      taxType: dto.taxType,
      amount: dto.amount,
      includedInPrice: dto.includedInPrice || false,
      createdBy: userId,
    });

    return this.repo.save(tax);
  }

  async update(
    id: string,
    userId: string,
    dto: UpdateTaxDto
  ): Promise<Tax> {
    const tax = await this.repo.findOneOrFail({ where: { id } });

    if (dto.code && dto.code !== tax.code) {
      const existing = await this.findByCode(tax.tenantId, dto.code);
      if (existing) {
        throw new ConflictException('Tax code already exists');
      }
    }

    Object.assign(tax, dto, { updatedBy: userId });
    return this.repo.save(tax);
  }

  async deactivate(id: string): Promise<Tax> {
    const tax = await this.repo.findOneOrFail({ where: { id } });
    tax.active = false;
    return this.repo.save(tax);
  }

  /**
   * Calcula el monto de impuesto para un valor base
   */
  calculateTax(
    baseAmount: number,
    tax: Tax
  ): { taxAmount: number; baseAmount: number } {
    if (tax.includedInPrice) {
      // Tax is included, extract it
      const baseWithoutTax = baseAmount / (1 + tax.amount / 100);
      return {
        baseAmount: baseWithoutTax,
        taxAmount: baseAmount - baseWithoutTax,
      };
    } else {
      // Tax is added on top
      return {
        baseAmount,
        taxAmount: baseAmount * (tax.amount / 100),
      };
    }
  }

  /**
   * Calcula impuestos multiples para un valor base
   */
  async calculateTaxes(
    baseAmount: number,
    taxIds: string[]
  ): Promise<TaxCalculationResult> {
    let totalTax = 0;
    const taxDetails: TaxDetail[] = [];

    for (const taxId of taxIds) {
      const tax = await this.findById(taxId);
      const { taxAmount } = this.calculateTax(baseAmount, tax);

      totalTax += taxAmount;
      taxDetails.push({
        taxId,
        taxName: tax.name,
        taxCode: tax.code,
        rate: tax.amount,
        baseAmount,
        taxAmount,
      });
    }

    return {
      baseAmount,
      totalTax,
      totalAmount: baseAmount + totalTax,
      taxes: taxDetails,
    };
  }
}

IncotermsService

@Injectable()
export class IncotermsService {
  constructor(
    @InjectRepository(Incoterm)
    private readonly repo: Repository<Incoterm>,
  ) {}

  async findAll(activeOnly: boolean = true): Promise<Incoterm[]> {
    const where = activeOnly ? { isActive: true } : {};
    return this.repo.find({
      where,
      order: { code: 'ASC' },
    });
  }

  async findById(id: string): Promise<Incoterm> {
    return this.repo.findOneOrFail({ where: { id } });
  }

  async findByCode(code: string): Promise<Incoterm> {
    return this.repo.findOne({ where: { code } });
  }

  async create(dto: CreateIncotermDto): Promise<Incoterm> {
    const existing = await this.findByCode(dto.code);
    if (existing) {
      throw new ConflictException('Incoterm code already exists');
    }

    const incoterm = this.repo.create(dto);
    return this.repo.save(incoterm);
  }

  async update(id: string, dto: UpdateIncotermDto): Promise<Incoterm> {
    const incoterm = await this.repo.findOneOrFail({ where: { id } });

    if (dto.code && dto.code !== incoterm.code) {
      const existing = await this.findByCode(dto.code);
      if (existing) {
        throw new ConflictException('Incoterm code already exists');
      }
    }

    Object.assign(incoterm, dto);
    return this.repo.save(incoterm);
  }

  async deactivate(id: string): Promise<Incoterm> {
    const incoterm = await this.repo.findOneOrFail({ where: { id } });
    incoterm.isActive = false;
    return this.repo.save(incoterm);
  }

  /**
   * Obtiene los Incoterms estandar 2020
   * EXW, FCA, CPT, CIP, DAP, DPU, DDP (any mode)
   * FAS, FOB, CFR, CIF (sea/inland waterway)
   */
  getStandardIncoterms(): IncotermInfo[] {
    return [
      { code: 'EXW', name: 'Ex Works', mode: 'any' },
      { code: 'FCA', name: 'Free Carrier', mode: 'any' },
      { code: 'CPT', name: 'Carriage Paid To', mode: 'any' },
      { code: 'CIP', name: 'Carriage and Insurance Paid To', mode: 'any' },
      { code: 'DAP', name: 'Delivered at Place', mode: 'any' },
      { code: 'DPU', name: 'Delivered at Place Unloaded', mode: 'any' },
      { code: 'DDP', name: 'Delivered Duty Paid', mode: 'any' },
      { code: 'FAS', name: 'Free Alongside Ship', mode: 'sea' },
      { code: 'FOB', name: 'Free on Board', mode: 'sea' },
      { code: 'CFR', name: 'Cost and Freight', mode: 'sea' },
      { code: 'CIF', name: 'Cost, Insurance and Freight', mode: 'sea' },
    ];
  }
}

Controladores

JournalController

@ApiTags('Journal Entries')
@Controller('financial/journal')
@UseGuards(JwtAuthGuard, RbacGuard)
export class JournalController {
  constructor(private readonly service: JournalService) {}

  @Get()
  @Permissions('financial.journal.read')
  async findAll(
    @TenantId() tenantId: string,
    @Query() query: QueryJournalDto
  ) {
    return this.service.findAll(tenantId, query);
  }

  @Get(':id')
  @Permissions('financial.journal.read')
  async findById(@Param('id') id: string) {
    return this.service.findById(id);
  }

  @Post()
  @Permissions('financial.journal.create')
  async create(
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: CreateJournalEntryDto
  ) {
    return this.service.create(tenantId, user.id, dto);
  }

  @Post(':id/post')
  @Permissions('financial.journal.post')
  async post(
    @Param('id') id: string,
    @CurrentUser() user: User
  ) {
    return this.service.post(id, user.id);
  }

  @Post(':id/reverse')
  @Permissions('financial.journal.reverse')
  async reverse(
    @Param('id') id: string,
    @CurrentUser() user: User,
    @Body('description') description?: string
  ) {
    return this.service.reverse(id, user.id, description);
  }
}

AccountsController

@ApiTags('Chart of Accounts')
@Controller('financial/charts/:chartId/accounts')
@UseGuards(JwtAuthGuard, RbacGuard)
export class AccountsController {
  constructor(private readonly service: AccountsService) {}

  @Get()
  @Permissions('financial.accounts.read')
  async findAll(@Param('chartId') chartId: string) {
    return this.service.findByChart(chartId);
  }

  @Get('tree')
  @Permissions('financial.accounts.read')
  async findTree(@Param('chartId') chartId: string) {
    return this.service.findTreeByChart(chartId);
  }

  @Post()
  @Permissions('financial.accounts.manage')
  async create(
    @Param('chartId') chartId: string,
    @TenantId() tenantId: string,
    @Body() dto: CreateAccountDto
  ) {
    return this.service.create(chartId, { ...dto, tenantId });
  }

  @Get(':id/balance')
  @Permissions('financial.accounts.read')
  async getBalance(
    @Param('id') id: string,
    @Query('asOfDate') asOfDate?: string
  ) {
    return this.service.getBalance(id, asOfDate ? new Date(asOfDate) : undefined);
  }

  @Get(':id/ledger')
  @Permissions('financial.accounts.read')
  async getLedger(
    @Param('id') id: string,
    @Query() query: QueryLedgerDto
  ) {
    return this.service.getLedger(id, query);
  }
}

API Endpoints Summary

Method Path Permission Description
GET /financial/charts financial.accounts.read List charts
POST /financial/charts financial.accounts.manage Create chart
GET /financial/charts/:id/accounts financial.accounts.read List accounts
GET /financial/charts/:id/accounts/tree financial.accounts.read Accounts tree
POST /financial/charts/:id/accounts financial.accounts.manage Create account
GET /financial/accounts/:id/balance financial.accounts.read Account balance
GET /financial/accounts/:id/ledger financial.accounts.read Account ledger
GET /financial/currencies financial.currencies.read List currencies
POST /financial/currencies/convert financial.currencies.read Convert amount
POST /financial/currencies/rates financial.currencies.manage Set rate
GET /financial/fiscal-years financial.periods.read List years
POST /financial/fiscal-years financial.periods.manage Create year
GET /financial/fiscal-periods financial.periods.read List periods
POST /financial/fiscal-periods/:id/close financial.periods.manage Close period
POST /financial/fiscal-periods/:id/reopen financial.periods.manage Reopen period
GET /financial/journal financial.journal.read List entries
GET /financial/journal/:id financial.journal.read Get entry
POST /financial/journal financial.journal.create Create entry
POST /financial/journal/:id/post financial.journal.post Post entry
POST /financial/journal/:id/reverse financial.journal.reverse Reverse entry
GET /financial/cost-centers financial.costcenters.read List cost centers
POST /financial/cost-centers financial.costcenters.manage Create cost center
GET /financial/reports/trial-balance financial.reports.read Trial balance
GET /financial/invoices financial.invoices.read List invoices
GET /financial/invoices/:id financial.invoices.read Get invoice
POST /financial/invoices financial.invoices.create Create invoice
PUT /financial/invoices/:id financial.invoices.update Update invoice
POST /financial/invoices/:id/validate financial.invoices.validate Validate invoice
POST /financial/invoices/:id/cancel financial.invoices.cancel Cancel invoice
GET /financial/payments financial.payments.read List payments
GET /financial/payments/:id financial.payments.read Get payment
POST /financial/payments financial.payments.create Create payment
PUT /financial/payments/:id financial.payments.update Update payment
POST /financial/payments/:id/post financial.payments.post Post payment
POST /financial/payments/:id/cancel financial.payments.cancel Cancel payment
POST /financial/payments/:id/reconcile financial.payments.reconcile Reconcile with invoices
POST /financial/payments/:id/auto-reconcile financial.payments.reconcile Auto-reconcile payment
GET /financial/payment-methods financial.paymentmethods.read List payment methods
GET /financial/payment-methods/:id financial.paymentmethods.read Get payment method
POST /financial/payment-methods financial.paymentmethods.manage Create payment method
PUT /financial/payment-methods/:id financial.paymentmethods.manage Update payment method
GET /financial/payment-terms financial.paymentterms.read List payment terms
GET /financial/payment-terms/:id financial.paymentterms.read Get payment term
POST /financial/payment-terms financial.paymentterms.manage Create payment term
PUT /financial/payment-terms/:id financial.paymentterms.manage Update payment term
POST /financial/payment-terms/:id/calculate financial.paymentterms.read Calculate due dates
GET /financial/journal-entries financial.journalentries.read List journal entries
GET /financial/journal-entries/:id financial.journalentries.read Get journal entry
POST /financial/journal-entries financial.journalentries.create Create journal entry
PUT /financial/journal-entries/:id financial.journalentries.update Update journal entry
POST /financial/journal-entries/:id/post financial.journalentries.post Post journal entry
POST /financial/journal-entries/:id/cancel financial.journalentries.cancel Cancel journal entry
GET /financial/reconcile-models financial.reconcilemodels.read List reconcile models
GET /financial/reconcile-models/:id financial.reconcilemodels.read Get reconcile model
POST /financial/reconcile-models financial.reconcilemodels.manage Create reconcile model
PUT /financial/reconcile-models/:id financial.reconcilemodels.manage Update reconcile model
POST /financial/reconcile-models/:id/apply financial.reconcilemodels.apply Apply reconcile model
GET /financial/taxes financial.taxes.read List taxes
GET /financial/taxes/:id financial.taxes.read Get tax
POST /financial/taxes financial.taxes.manage Create tax
PUT /financial/taxes/:id financial.taxes.manage Update tax
POST /financial/taxes/calculate financial.taxes.read Calculate taxes
GET /financial/incoterms financial.incoterms.read List incoterms
GET /financial/incoterms/:id financial.incoterms.read Get incoterm
POST /financial/incoterms financial.incoterms.manage Create incoterm
PUT /financial/incoterms/:id financial.incoterms.manage Update incoterm

Historial

Version Fecha Autor Cambios
1.0 2025-12-05 Requirements-Analyst Creacion inicial
2.0 2026-01-10 Requirements-Analyst Documentacion de servicios: InvoicesService, PaymentsService (36 tests, reconciliacion), PaymentMethodsService, PaymentTermsService, JournalEntriesService (posting), ReconcileModelsService, TaxesService, IncotermsService