# 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 ```typescript @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 ```typescript @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 ```typescript @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 ```typescript 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 ```typescript @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 ```typescript 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 ```typescript @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 ```typescript @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 ```typescript @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 ```typescript 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 ```typescript 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 ```typescript @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 ```typescript 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 ```typescript @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 ```typescript 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 ```typescript @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 ```typescript @Injectable() export class AccountsService { constructor( @InjectRepository(Account) private readonly repo: Repository, @InjectRepository(AccountType) private readonly typeRepo: Repository, ) {} async findByChart(chartId: string): Promise { return this.repo.find({ where: { chartId }, relations: ['accountType', 'parent'], order: { code: 'ASC' }, }); } async findTreeByChart(chartId: string): Promise { const accounts = await this.findByChart(chartId); return this.buildTree(accounts); } async create(chartId: string, dto: CreateAccountDto): Promise { // 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 { 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 { 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 { 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(); 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 { const count = await this.repo.manager.count(JournalLine, { where: { accountId }, }); return count > 0; } } ``` ### JournalService ```typescript @Injectable() export class JournalService { constructor( @InjectRepository(JournalEntry) private readonly entryRepo: Repository, @InjectRepository(JournalLine) private readonly lineRepo: Repository, private readonly periodsService: FiscalPeriodsService, private readonly currenciesService: CurrenciesService, private readonly accountsService: AccountsService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async findAll( tenantId: string, query: QueryJournalDto ): Promise> { 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 { return this.entryRepo.findOne({ where: { id }, relations: ['lines', 'lines.account', 'lines.costCenter', 'fiscalPeriod'], }); } async create( tenantId: string, userId: string, dto: CreateJournalEntryDto ): Promise { // 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 { 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 { 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 { 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 { 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 ```typescript @Injectable() export class CurrenciesService { constructor( @InjectRepository(TenantCurrency) private readonly currencyRepo: Repository, @InjectRepository(ExchangeRate) private readonly rateRepo: Repository, ) {} async findByTenant(tenantId: string): Promise { return this.currencyRepo.find({ where: { tenantId, isActive: true }, relations: ['currency'], }); } async getBaseCurrency(tenantId: string): Promise { 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 { 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 { 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 { 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 ```typescript @Injectable() export class FiscalPeriodsService { constructor( @InjectRepository(FiscalYear) private readonly yearRepo: Repository, @InjectRepository(FiscalPeriod) private readonly periodRepo: Repository, ) {} async findOpenForDate(tenantId: string, date: Date): Promise { return this.periodRepo.findOne({ where: { tenantId, startDate: LessThanOrEqual(date), endDate: MoreThanOrEqual(date), status: 'open', }, }); } async createYear( tenantId: string, dto: CreateFiscalYearDto ): Promise { 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 { 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 { 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 { const periods: Partial[] = []; 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 ```typescript @Injectable() export class InvoicesService { constructor( @InjectRepository(Invoice) private readonly invoiceRepo: Repository, @InjectRepository(InvoiceLine) private readonly lineRepo: Repository, private readonly taxesService: TaxesService, private readonly journalEntriesService: JournalEntriesService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async findAll( tenantId: string, query: QueryInvoiceDto ): Promise> { 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 { return this.invoiceRepo.findOne({ where: { id }, relations: ['lines', 'lines.account', 'journal', 'journalEntry'], }); } async create( tenantId: string, companyId: string, userId: string, dto: CreateInvoiceDto ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // Implementation creates corresponding journal entry // for the invoice based on configured accounts } } ``` ### PaymentsService ```typescript /** * PaymentsService - Gestion de pagos con reconciliacion * Tests: 36 tests unitarios */ @Injectable() export class PaymentsService { constructor( @InjectRepository(Payment) private readonly paymentRepo: Repository, @InjectRepository(PaymentInvoice) private readonly paymentInvoiceRepo: Repository, private readonly invoicesService: InvoicesService, private readonly journalEntriesService: JournalEntriesService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async findAll( tenantId: string, query: QueryPaymentDto ): Promise> { 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 { return this.paymentRepo.findOne({ where: { id }, relations: ['journal', 'journalEntry'], }); } async create( tenantId: string, companyId: string, userId: string, dto: CreatePaymentDto ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // Implementation creates journal entry for payment // based on payment type and configured accounts } } ``` ### PaymentMethodsService ```typescript @Injectable() export class PaymentMethodsService { constructor( @InjectRepository(PaymentMethodCatalog) private readonly repo: Repository, ) {} async findAll(query?: QueryPaymentMethodDto): Promise { 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 { return this.repo.findOneOrFail({ where: { id } }); } async findByCode(code: string, paymentType: string): Promise { return this.repo.findOne({ where: { code, paymentType }, }); } async create(dto: CreatePaymentMethodDto): Promise { 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 { const method = await this.repo.findOneOrFail({ where: { id } }); Object.assign(method, dto); return this.repo.save(method); } async deactivate(id: string): Promise { const method = await this.repo.findOneOrFail({ where: { id } }); method.isActive = false; return this.repo.save(method); } } ``` ### PaymentTermsService ```typescript @Injectable() export class PaymentTermsService { constructor( @InjectRepository(PaymentTerm) private readonly termRepo: Repository, @InjectRepository(PaymentTermLine) private readonly lineRepo: Repository, ) {} async findAll( tenantId: string, companyId: string ): Promise { return this.termRepo.find({ where: { tenantId, companyId, active: true }, relations: ['lines'], order: { name: 'ASC' }, }); } async findById(id: string): Promise { return this.termRepo.findOne({ where: { id }, relations: ['lines'], }); } async create( tenantId: string, companyId: string, userId: string, dto: CreatePaymentTermDto ): Promise { // 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 { 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 { 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 { 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 ```typescript /** * JournalEntriesService - Servicio para asientos contables * Incluye funcionalidad de posting de asientos */ @Injectable() export class JournalEntriesService { constructor( @InjectRepository(JournalEntry) private readonly entryRepo: Repository, @InjectRepository(JournalEntryLine) private readonly lineRepo: Repository, private readonly accountsService: AccountsService, private readonly periodsService: FiscalPeriodsService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async findAll( tenantId: string, query: QueryJournalEntryDto ): Promise> { 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 { return this.entryRepo.findOne({ where: { id }, relations: ['lines', 'lines.account', 'journal'], }); } async create( tenantId: string, companyId: string, userId: string, dto: CreateJournalEntryDto ): Promise { // 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 { 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 { 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 { 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 { 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 ```typescript @Injectable() export class ReconcileModelsService { constructor( @InjectRepository(ReconcileModel) private readonly modelRepo: Repository, @InjectRepository(ReconcileModelLine) private readonly lineRepo: Repository, ) {} async findAll( tenantId: string, companyId?: string ): Promise { 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 { return this.modelRepo.findOne({ where: { id }, relations: ['lines', 'lines.account', 'lines.journal'], }); } async create( tenantId: string, dto: CreateReconcileModelDto ): Promise { 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 { 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 { 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 { 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 { 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 ```typescript @Injectable() export class TaxesService { constructor( @InjectRepository(Tax) private readonly repo: Repository, ) {} async findAll( tenantId: string, companyId: string, query?: QueryTaxDto ): Promise { 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 { return this.repo.findOneOrFail({ where: { id } }); } async findByCode( tenantId: string, code: string ): Promise { return this.repo.findOne({ where: { tenantId, code } }); } async create( tenantId: string, companyId: string, userId: string, dto: CreateTaxDto ): Promise { 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 { 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 { 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 { 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 ```typescript @Injectable() export class IncotermsService { constructor( @InjectRepository(Incoterm) private readonly repo: Repository, ) {} async findAll(activeOnly: boolean = true): Promise { const where = activeOnly ? { isActive: true } : {}; return this.repo.find({ where, order: { code: 'ASC' }, }); } async findById(id: string): Promise { return this.repo.findOneOrFail({ where: { id } }); } async findByCode(code: string): Promise { return this.repo.findOne({ where: { code } }); } async create(dto: CreateIncotermDto): Promise { 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 { 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 { 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 ```typescript @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 ```typescript @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 |