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>
811 lines
24 KiB
TypeScript
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();
|