erp-core/docs/02-fase-core-business/MGN-010-financial/especificaciones/ET-FIN-backend.md

29 KiB

ET-FIN-BACKEND: Servicios y API REST

Identificacion

Campo Valor
ID ET-FIN-BACKEND
Modulo MGN-010 Financial
Version 1.0
Estado En Diseno
Framework NestJS
Autor Requirements-Analyst
Fecha 2025-12-05

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
├── 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-line.entity.ts
│   └── cost-center.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;
}

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);
  }
}

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

Historial

Version Fecha Autor Cambios
1.0 2025-12-05 Requirements-Analyst Creacion inicial