erp-core-backend-v2/src/modules/financial/services/bank-reconciliation.service.ts
rckrdmrd af3cc5a25d [TASK-2026-01-20-003] feat: Add bank reconciliation and 3-way matching
Bank Reconciliation (financial module):
- Entities: BankStatement, BankStatementLine, BankReconciliationRule
- Service: BankReconciliationService with auto-reconcile
- DTOs: CreateBankStatement, ReconcileLine

3-Way Matching (purchases module):
- Entities: PurchaseOrderMatching, PurchaseMatchingLine, MatchingException
- Service: ThreeWayMatchingService with tolerance validation
- DTOs: MatchingStatus, ResolveException

Tolerances:
- Quantity: 0.5%
- Price: 2%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 03:47:19 -06:00

811 lines
24 KiB
TypeScript

import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import {
CreateBankStatementDto,
BankStatementFilters,
BankStatementWithLines,
BankStatementLineResponse,
SuggestedMatch,
} from '../dto/create-bank-statement.dto.js';
import {
ReconcileResult,
AutoReconcileResult,
MatchCandidate,
FindMatchCandidatesDto,
CreateReconciliationRuleDto,
UpdateReconciliationRuleDto,
} from '../dto/reconcile-line.dto.js';
/**
* Representacion de extracto bancario
*/
export interface BankStatement {
id: string;
tenant_id: string;
company_id: string | null;
bank_account_id: string | null;
bank_account_name?: string;
statement_date: Date;
opening_balance: number;
closing_balance: number;
status: 'draft' | 'reconciling' | 'reconciled';
imported_at: Date | null;
imported_by: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
created_at: Date;
}
/**
* Representacion de regla de conciliacion
*/
export interface ReconciliationRule {
id: string;
tenant_id: string;
company_id: string | null;
name: string;
match_type: 'exact_amount' | 'reference_contains' | 'partner_name';
match_value: string;
auto_account_id: string | null;
auto_account_name?: string;
is_active: boolean;
priority: number;
created_at: Date;
}
/**
* Servicio para conciliacion bancaria
*/
class BankReconciliationService {
// ==========================================
// EXTRACTOS BANCARIOS
// ==========================================
/**
* Importar un extracto bancario con sus lineas
*/
async importStatement(
dto: CreateBankStatementDto,
tenantId: string,
userId: string
): Promise<BankStatement> {
// Validaciones
if (!dto.lines || dto.lines.length === 0) {
throw new ValidationError('El extracto debe tener al menos una linea');
}
// Validar que el balance cuadre
const calculatedClosing = dto.opening_balance + dto.lines.reduce((sum, line) => sum + line.amount, 0);
if (Math.abs(calculatedClosing - dto.closing_balance) > 0.01) {
throw new ValidationError(
`El balance no cuadra. Apertura (${dto.opening_balance}) + Movimientos (${dto.lines.reduce((s, l) => s + l.amount, 0)}) = ${calculatedClosing}, pero cierre declarado es ${dto.closing_balance}`
);
}
const client = await getClient();
try {
await client.query('BEGIN');
// Crear el extracto
const statementResult = await client.query<BankStatement>(
`INSERT INTO financial.bank_statements (
tenant_id, company_id, bank_account_id, statement_date,
opening_balance, closing_balance, status,
imported_at, imported_by, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', CURRENT_TIMESTAMP, $7, $7)
RETURNING *`,
[
tenantId,
dto.company_id || null,
dto.bank_account_id || null,
dto.statement_date,
dto.opening_balance,
dto.closing_balance,
userId,
]
);
const statement = statementResult.rows[0];
// Insertar las lineas
for (const line of dto.lines) {
await client.query(
`INSERT INTO financial.bank_statement_lines (
statement_id, tenant_id, transaction_date, value_date,
description, reference, amount, partner_id, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
statement.id,
tenantId,
line.transaction_date,
line.value_date || null,
line.description || null,
line.reference || null,
line.amount,
line.partner_id || null,
line.notes || null,
]
);
}
await client.query('COMMIT');
return statement;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Obtener lista de extractos con filtros
*/
async findAll(
tenantId: string,
filters: BankStatementFilters = {}
): Promise<{ data: BankStatement[]; total: number }> {
const { company_id, bank_account_id, status, date_from, date_to, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE bs.tenant_id = $1';
const params: unknown[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND bs.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (bank_account_id) {
whereClause += ` AND bs.bank_account_id = $${paramIndex++}`;
params.push(bank_account_id);
}
if (status) {
whereClause += ` AND bs.status = $${paramIndex++}`;
params.push(status);
}
if (date_from) {
whereClause += ` AND bs.statement_date >= $${paramIndex++}`;
params.push(date_from);
}
if (date_to) {
whereClause += ` AND bs.statement_date <= $${paramIndex++}`;
params.push(date_to);
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM financial.bank_statements bs ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<BankStatement>(
`SELECT bs.*,
a.name as bank_account_name,
financial.get_reconciliation_progress(bs.id) as reconciliation_progress
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
${whereClause}
ORDER BY bs.statement_date DESC, bs.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
/**
* Obtener un extracto con todas sus lineas
*/
async getStatementWithLines(id: string, tenantId: string): Promise<BankStatementWithLines> {
const statement = await queryOne<BankStatementWithLines>(
`SELECT bs.*,
a.name as bank_account_name,
financial.get_reconciliation_progress(bs.id) as reconciliation_progress
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
WHERE bs.id = $1 AND bs.tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
// Obtener lineas
const lines = await query<BankStatementLineResponse>(
`SELECT bsl.*,
p.name as partner_name
FROM financial.bank_statement_lines bsl
LEFT JOIN core.partners p ON bsl.partner_id = p.id
WHERE bsl.statement_id = $1
ORDER BY bsl.transaction_date, bsl.created_at`,
[id]
);
// Calcular balance calculado
const calculatedBalance =
Number(statement.opening_balance) + lines.reduce((sum, line) => sum + Number(line.amount), 0);
statement.lines = lines;
statement.calculated_balance = calculatedBalance;
return statement;
}
/**
* Eliminar un extracto (solo en estado draft)
*/
async deleteStatement(id: string, tenantId: string): Promise<void> {
const statement = await queryOne<BankStatement>(
`SELECT * FROM financial.bank_statements WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
if (statement.status !== 'draft') {
throw new ValidationError('Solo se pueden eliminar extractos en estado borrador');
}
await query(`DELETE FROM financial.bank_statements WHERE id = $1`, [id]);
}
// ==========================================
// CONCILIACION
// ==========================================
/**
* Ejecutar auto-conciliacion de un extracto
* Busca matches automaticos por monto, fecha y referencia
*/
async autoReconcile(statementId: string, tenantId: string, userId: string): Promise<AutoReconcileResult> {
const statement = await this.getStatementWithLines(statementId, tenantId);
if (statement.status === 'reconciled') {
throw new ValidationError('El extracto ya esta completamente conciliado');
}
// Cambiar estado a reconciling si esta en draft
if (statement.status === 'draft') {
await query(
`UPDATE financial.bank_statements SET status = 'reconciling', updated_by = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[userId, statementId]
);
}
const result: AutoReconcileResult = {
total_lines: statement.lines.length,
reconciled_count: 0,
unreconciled_count: 0,
reconciled_lines: [],
lines_with_suggestions: [],
};
// Obtener reglas activas
const rules = await query<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules
WHERE tenant_id = $1 AND is_active = true
ORDER BY priority DESC`,
[tenantId]
);
// Procesar cada linea no conciliada
for (const line of statement.lines) {
if (line.is_reconciled) {
continue;
}
// Buscar candidatos a match
const candidates = await this.findMatchCandidates(
{
amount: Math.abs(Number(line.amount)),
date: line.transaction_date.toString(),
reference: line.reference || undefined,
partner_id: line.partner_id || undefined,
amount_tolerance: 0,
date_tolerance_days: 3,
limit: 5,
},
tenantId,
statement.bank_account_id || undefined
);
// Aplicar reglas personalizadas
for (const rule of rules) {
const ruleMatch = this.applyRule(rule, line);
if (ruleMatch) {
// Si la regla tiene cuenta auto, se podria crear asiento automatico
// Por ahora solo marcamos como sugerencia
}
}
// Si hay un match con confianza >= 90%, conciliar automaticamente
const exactMatch = candidates.find((c) => c.confidence >= 90);
if (exactMatch) {
try {
await this.reconcileLine(line.id, exactMatch.id, tenantId, userId);
result.reconciled_count++;
result.reconciled_lines.push({
statement_line_id: line.id,
entry_line_id: exactMatch.id,
match_type: exactMatch.match_type,
confidence: exactMatch.confidence,
});
} catch {
// Si falla, agregar a sugerencias
result.lines_with_suggestions.push({
statement_line_id: line.id,
suggestions: candidates.length,
});
result.unreconciled_count++;
}
} else if (candidates.length > 0) {
result.lines_with_suggestions.push({
statement_line_id: line.id,
suggestions: candidates.length,
});
result.unreconciled_count++;
} else {
result.unreconciled_count++;
}
}
return result;
}
/**
* Buscar lineas de asiento candidatas a conciliar
*/
async findMatchCandidates(
dto: FindMatchCandidatesDto,
tenantId: string,
bankAccountId?: string
): Promise<MatchCandidate[]> {
const { amount, date, reference, partner_id, amount_tolerance = 0, date_tolerance_days = 3, limit = 10 } = dto;
let whereClause = `
WHERE jel.tenant_id = $1
AND je.status = 'posted'
AND NOT EXISTS (
SELECT 1 FROM financial.bank_statement_lines bsl
WHERE bsl.reconciled_entry_id = jel.id
)
`;
const params: unknown[] = [tenantId];
let paramIndex = 2;
// Filtrar por cuenta bancaria si se especifica
if (bankAccountId) {
whereClause += ` AND jel.account_id = $${paramIndex++}`;
params.push(bankAccountId);
}
// Filtrar por monto con tolerancia
const amountMin = amount * (1 - amount_tolerance);
const amountMax = amount * (1 + amount_tolerance);
whereClause += ` AND (
(jel.debit BETWEEN $${paramIndex} AND $${paramIndex + 1})
OR (jel.credit BETWEEN $${paramIndex} AND $${paramIndex + 1})
)`;
params.push(amountMin, amountMax);
paramIndex += 2;
// Filtrar por fecha con tolerancia
if (date) {
whereClause += ` AND je.date BETWEEN ($${paramIndex}::date - interval '${date_tolerance_days} days') AND ($${paramIndex}::date + interval '${date_tolerance_days} days')`;
params.push(date);
paramIndex++;
}
// Filtrar por partner si se especifica
if (partner_id) {
whereClause += ` AND jel.partner_id = $${paramIndex++}`;
params.push(partner_id);
}
params.push(limit);
const candidates = await query<MatchCandidate>(
`SELECT
jel.id,
jel.entry_id,
je.name as entry_name,
je.ref as entry_ref,
je.date as entry_date,
jel.account_id,
a.code as account_code,
a.name as account_name,
jel.debit,
jel.credit,
(jel.debit - jel.credit) as net_amount,
jel.description,
jel.partner_id,
p.name as partner_name
FROM financial.journal_entry_lines jel
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
INNER JOIN financial.accounts a ON jel.account_id = a.id
LEFT JOIN core.partners p ON jel.partner_id = p.id
${whereClause}
ORDER BY je.date DESC
LIMIT $${paramIndex}`,
params
);
// Calcular confianza y tipo de match para cada candidato
return candidates.map((c) => {
let confidence = 50; // Base
let matchType: MatchCandidate['match_type'] = 'exact_amount';
const candidateAmount = Math.abs(Number(c.debit) - Number(c.credit));
// Match exacto de monto
if (Math.abs(candidateAmount - amount) < 0.01) {
confidence += 30;
matchType = 'exact_amount';
}
// Match de fecha exacta
if (date && c.entry_date.toString().substring(0, 10) === date.substring(0, 10)) {
confidence += 15;
matchType = 'amount_date';
}
// Match de referencia
if (reference && c.entry_ref && c.entry_ref.toLowerCase().includes(reference.toLowerCase())) {
confidence += 20;
matchType = 'reference';
}
// Match de partner
if (partner_id && c.partner_id === partner_id) {
confidence += 15;
matchType = 'partner';
}
return {
...c,
match_type: matchType,
confidence: Math.min(100, confidence),
};
});
}
/**
* Conciliar manualmente una linea de extracto con una linea de asiento
*/
async reconcileLine(
lineId: string,
entryLineId: string,
tenantId: string,
userId: string
): Promise<ReconcileResult> {
// Verificar que la linea de extracto existe y no esta conciliada
const line = await queryOne<BankStatementLineResponse>(
`SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`,
[lineId, tenantId]
);
if (!line) {
throw new NotFoundError('Linea de extracto no encontrada');
}
if (line.is_reconciled) {
throw new ValidationError('La linea ya esta conciliada');
}
// Verificar que la linea de asiento existe y no esta conciliada con otra linea
const entryLine = await queryOne<{ id: string; debit: number; credit: number }>(
`SELECT jel.* FROM financial.journal_entry_lines jel
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
WHERE jel.id = $1 AND jel.tenant_id = $2 AND je.status = 'posted'`,
[entryLineId, tenantId]
);
if (!entryLine) {
throw new NotFoundError('Linea de asiento no encontrada o no publicada');
}
// Verificar que no este ya conciliada
const alreadyReconciled = await queryOne<{ id: string }>(
`SELECT id FROM financial.bank_statement_lines WHERE reconciled_entry_id = $1`,
[entryLineId]
);
if (alreadyReconciled) {
throw new ValidationError('La linea de asiento ya esta conciliada con otra linea de extracto');
}
// Actualizar la linea de extracto
await query(
`UPDATE financial.bank_statement_lines SET
is_reconciled = true,
reconciled_entry_id = $1,
reconciled_at = CURRENT_TIMESTAMP,
reconciled_by = $2
WHERE id = $3`,
[entryLineId, userId, lineId]
);
return {
success: true,
statement_line_id: lineId,
entry_line_id: entryLineId,
};
}
/**
* Deshacer la conciliacion de una linea
*/
async unreconcileLine(lineId: string, tenantId: string): Promise<void> {
const line = await queryOne<BankStatementLineResponse>(
`SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`,
[lineId, tenantId]
);
if (!line) {
throw new NotFoundError('Linea de extracto no encontrada');
}
if (!line.is_reconciled) {
throw new ValidationError('La linea no esta conciliada');
}
await query(
`UPDATE financial.bank_statement_lines SET
is_reconciled = false,
reconciled_entry_id = NULL,
reconciled_at = NULL,
reconciled_by = NULL
WHERE id = $1`,
[lineId]
);
}
/**
* Cerrar un extracto completamente conciliado
*/
async closeStatement(statementId: string, tenantId: string, userId: string): Promise<BankStatement> {
const statement = await this.getStatementWithLines(statementId, tenantId);
if (statement.status === 'reconciled') {
throw new ValidationError('El extracto ya esta cerrado');
}
// Verificar que todas las lineas esten conciliadas
const unreconciledLines = statement.lines.filter((l) => !l.is_reconciled);
if (unreconciledLines.length > 0) {
throw new ValidationError(
`No se puede cerrar el extracto. Hay ${unreconciledLines.length} linea(s) sin conciliar`
);
}
await query(
`UPDATE financial.bank_statements SET
status = 'reconciled',
reconciled_at = CURRENT_TIMESTAMP,
reconciled_by = $1,
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[userId, statementId]
);
return this.findById(statementId, tenantId);
}
/**
* Obtener un extracto por ID
*/
async findById(id: string, tenantId: string): Promise<BankStatement> {
const statement = await queryOne<BankStatement>(
`SELECT bs.*, a.name as bank_account_name
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
WHERE bs.id = $1 AND bs.tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
return statement;
}
// ==========================================
// REGLAS DE CONCILIACION
// ==========================================
/**
* Crear una regla de conciliacion
*/
async createRule(dto: CreateReconciliationRuleDto, tenantId: string, userId: string): Promise<ReconciliationRule> {
const result = await queryOne<ReconciliationRule>(
`INSERT INTO financial.bank_reconciliation_rules (
tenant_id, company_id, name, match_type, match_value,
auto_account_id, priority, is_active, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
tenantId,
dto.company_id || null,
dto.name,
dto.match_type,
dto.match_value,
dto.auto_account_id || null,
dto.priority || 0,
dto.is_active !== false,
userId,
]
);
return result!;
}
/**
* Obtener reglas de conciliacion
*/
async findRules(tenantId: string, companyId?: string): Promise<ReconciliationRule[]> {
let whereClause = 'WHERE r.tenant_id = $1';
const params: unknown[] = [tenantId];
if (companyId) {
whereClause += ' AND (r.company_id = $2 OR r.company_id IS NULL)';
params.push(companyId);
}
return query<ReconciliationRule>(
`SELECT r.*, a.name as auto_account_name
FROM financial.bank_reconciliation_rules r
LEFT JOIN financial.accounts a ON r.auto_account_id = a.id
${whereClause}
ORDER BY r.priority DESC, r.name`,
params
);
}
/**
* Actualizar una regla de conciliacion
*/
async updateRule(
id: string,
dto: UpdateReconciliationRuleDto,
tenantId: string,
userId: string
): Promise<ReconciliationRule> {
const existing = await queryOne<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
const updateFields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(dto.name);
}
if (dto.match_type !== undefined) {
updateFields.push(`match_type = $${paramIndex++}`);
values.push(dto.match_type);
}
if (dto.match_value !== undefined) {
updateFields.push(`match_value = $${paramIndex++}`);
values.push(dto.match_value);
}
if (dto.auto_account_id !== undefined) {
updateFields.push(`auto_account_id = $${paramIndex++}`);
values.push(dto.auto_account_id);
}
if (dto.priority !== undefined) {
updateFields.push(`priority = $${paramIndex++}`);
values.push(dto.priority);
}
if (dto.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(dto.is_active);
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id, tenantId);
await query(
`UPDATE financial.bank_reconciliation_rules SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findRuleById(id, tenantId);
}
/**
* Obtener regla por ID
*/
async findRuleById(id: string, tenantId: string): Promise<ReconciliationRule> {
const rule = await queryOne<ReconciliationRule>(
`SELECT r.*, a.name as auto_account_name
FROM financial.bank_reconciliation_rules r
LEFT JOIN financial.accounts a ON r.auto_account_id = a.id
WHERE r.id = $1 AND r.tenant_id = $2`,
[id, tenantId]
);
if (!rule) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
return rule;
}
/**
* Eliminar una regla
*/
async deleteRule(id: string, tenantId: string): Promise<void> {
const existing = await queryOne<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
await query(`DELETE FROM financial.bank_reconciliation_rules WHERE id = $1`, [id]);
}
// ==========================================
// HELPERS
// ==========================================
/**
* Aplicar una regla a una linea de extracto
*/
private applyRule(
rule: ReconciliationRule,
line: BankStatementLineResponse
): boolean {
switch (rule.match_type) {
case 'exact_amount':
return Math.abs(Number(line.amount)) === parseFloat(rule.match_value);
case 'reference_contains':
return line.reference?.toLowerCase().includes(rule.match_value.toLowerCase()) || false;
case 'partner_name':
// Esto requeriria el nombre del partner, que ya esta en partner_name
return line.partner_name?.toLowerCase().includes(rule.match_value.toLowerCase()) || false;
default:
return false;
}
}
}
export const bankReconciliationService = new BankReconciliationService();