erp-core-backend-v2/src/modules/financial/gl-posting.service.ts
rckrdmrd edadaf3180 [FASE 3-4] feat: Complete Financial, Inventory, CRM, and Projects modules
EPIC-005 - Financial Module:
- Add AccountMapping entity for GL account configuration
- Create GLPostingService for automatic journal entries
- Integrate GL posting with invoice validation
- Fix tax calculation in invoice lines

EPIC-006 - Inventory Automation:
- Integrate FIFO valuation with pickings
- Create ReorderAlertsService for stock monitoring
- Add lot validation for tracked products
- Integrate valuation with inventory adjustments

EPIC-007 - CRM Improvements:
- Create ActivitiesService for activity management
- Create ForecastingService for pipeline analytics
- Add win/loss reporting and user performance metrics

EPIC-008 - Project Billing:
- Create BillingService with billing rate management
- Add getUnbilledTimesheets and createInvoiceFromTimesheets
- Support grouping options for invoice generation

EPIC-009 - HR-Projects Integration:
- Create HRIntegrationService for employee-user linking
- Add employee cost rate management
- Implement project profitability calculations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 05:49:20 -06:00

712 lines
22 KiB
TypeScript

import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { AccountMappingType } from './entities/account-mapping.entity.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface AccountMapping {
id: string;
tenant_id: string;
company_id: string;
mapping_type: AccountMappingType | string;
account_id: string;
account_code?: string;
account_name?: string;
description: string | null;
is_active: boolean;
}
export interface JournalEntryLineInput {
account_id: string;
partner_id?: string;
debit: number;
credit: number;
description?: string;
ref?: string;
}
export interface PostingResult {
journal_entry_id: string;
journal_entry_name: string;
total_debit: number;
total_credit: number;
lines_count: number;
}
export interface InvoiceForPosting {
id: string;
tenant_id: string;
company_id: string;
partner_id: string;
partner_name?: string;
invoice_type: 'customer' | 'supplier';
number: string;
invoice_date: Date;
amount_untaxed: number;
amount_tax: number;
amount_total: number;
journal_id?: string;
lines: InvoiceLineForPosting[];
}
export interface InvoiceLineForPosting {
id: string;
product_id?: string;
description: string;
quantity: number;
price_unit: number;
amount_untaxed: number;
amount_tax: number;
amount_total: number;
account_id?: string;
tax_ids: string[];
}
// ============================================================================
// SERVICE
// ============================================================================
class GLPostingService {
/**
* Get account mapping for a specific type and company
*/
async getMapping(
mappingType: AccountMappingType | string,
tenantId: string,
companyId: string
): Promise<AccountMapping | null> {
const mapping = await queryOne<AccountMapping>(
`SELECT am.*, a.code as account_code, a.name as account_name
FROM financial.account_mappings am
LEFT JOIN financial.accounts a ON am.account_id = a.id
WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.mapping_type = $3 AND am.is_active = true`,
[tenantId, companyId, mappingType]
);
return mapping;
}
/**
* Get all active mappings for a company
*/
async getMappings(tenantId: string, companyId: string): Promise<AccountMapping[]> {
return query<AccountMapping>(
`SELECT am.*, a.code as account_code, a.name as account_name
FROM financial.account_mappings am
LEFT JOIN financial.accounts a ON am.account_id = a.id
WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.is_active = true
ORDER BY am.mapping_type`,
[tenantId, companyId]
);
}
/**
* Create or update an account mapping
*/
async setMapping(
mappingType: AccountMappingType | string,
accountId: string,
tenantId: string,
companyId: string,
description?: string,
userId?: string
): Promise<AccountMapping> {
const result = await queryOne<AccountMapping>(
`INSERT INTO financial.account_mappings (tenant_id, company_id, mapping_type, account_id, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tenant_id, company_id, mapping_type)
DO UPDATE SET account_id = $4, description = $5, updated_by = $6, updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[tenantId, companyId, mappingType, accountId, description, userId]
);
return result!;
}
/**
* Create a journal entry from a validated invoice
*
* For customer invoice (sale):
* - Debit: Accounts Receivable (partner balance)
* - Credit: Sales Revenue (per line or default mapping)
* - Credit: Tax Payable (if taxes apply)
*
* For supplier invoice (bill):
* - Credit: Accounts Payable (partner balance)
* - Debit: Purchase Expense (per line or default mapping)
* - Debit: Tax Receivable (if taxes apply)
*/
async createInvoicePosting(
invoice: InvoiceForPosting,
userId: string
): Promise<PostingResult> {
const { tenant_id: tenantId, company_id: companyId } = invoice;
logger.info('Creating GL posting for invoice', {
invoiceId: invoice.id,
invoiceNumber: invoice.number,
invoiceType: invoice.invoice_type,
});
// Validate invoice has lines
if (!invoice.lines || invoice.lines.length === 0) {
throw new ValidationError('La factura debe tener al menos una línea para contabilizar');
}
// Get required account mappings based on invoice type
const isCustomerInvoice = invoice.invoice_type === 'customer';
// Get receivable/payable account
const partnerAccountType = isCustomerInvoice
? AccountMappingType.CUSTOMER_INVOICE
: AccountMappingType.SUPPLIER_INVOICE;
const partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId);
if (!partnerMapping) {
throw new ValidationError(
`No hay cuenta configurada para ${isCustomerInvoice ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}. Configure account_mappings.`
);
}
// Get default revenue/expense account
const revenueExpenseType = isCustomerInvoice
? AccountMappingType.SALES_REVENUE
: AccountMappingType.PURCHASE_EXPENSE;
const revenueExpenseMapping = await this.getMapping(revenueExpenseType, tenantId, companyId);
// Get tax accounts if there are taxes
let taxPayableMapping: AccountMapping | null = null;
let taxReceivableMapping: AccountMapping | null = null;
if (invoice.amount_tax > 0) {
if (isCustomerInvoice) {
taxPayableMapping = await this.getMapping(AccountMappingType.TAX_PAYABLE, tenantId, companyId);
if (!taxPayableMapping) {
throw new ValidationError('No hay cuenta configurada para IVA por Pagar');
}
} else {
taxReceivableMapping = await this.getMapping(AccountMappingType.TAX_RECEIVABLE, tenantId, companyId);
if (!taxReceivableMapping) {
throw new ValidationError('No hay cuenta configurada para IVA por Recuperar');
}
}
}
// Build journal entry lines
const jeLines: JournalEntryLineInput[] = [];
// Line 1: Partner account (AR/AP)
if (isCustomerInvoice) {
// Customer invoice: Debit AR
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: invoice.partner_id,
debit: invoice.amount_total,
credit: 0,
description: `Factura ${invoice.number} - ${invoice.partner_name || 'Cliente'}`,
ref: invoice.number,
});
} else {
// Supplier invoice: Credit AP
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: invoice.partner_id,
debit: 0,
credit: invoice.amount_total,
description: `Factura ${invoice.number} - ${invoice.partner_name || 'Proveedor'}`,
ref: invoice.number,
});
}
// Lines for each invoice line (revenue/expense)
for (const line of invoice.lines) {
// Use line's account_id if specified, otherwise use default mapping
const lineAccountId = line.account_id || revenueExpenseMapping?.account_id;
if (!lineAccountId) {
throw new ValidationError(
`No hay cuenta de ${isCustomerInvoice ? 'ingresos' : 'gastos'} configurada para la línea: ${line.description}`
);
}
if (isCustomerInvoice) {
// Customer invoice: Credit Revenue
jeLines.push({
account_id: lineAccountId,
debit: 0,
credit: line.amount_untaxed,
description: line.description,
ref: invoice.number,
});
} else {
// Supplier invoice: Debit Expense
jeLines.push({
account_id: lineAccountId,
debit: line.amount_untaxed,
credit: 0,
description: line.description,
ref: invoice.number,
});
}
}
// Tax line if applicable
if (invoice.amount_tax > 0) {
if (isCustomerInvoice && taxPayableMapping) {
// Customer invoice: Credit Tax Payable
jeLines.push({
account_id: taxPayableMapping.account_id,
debit: 0,
credit: invoice.amount_tax,
description: `IVA - Factura ${invoice.number}`,
ref: invoice.number,
});
} else if (!isCustomerInvoice && taxReceivableMapping) {
// Supplier invoice: Debit Tax Receivable
jeLines.push({
account_id: taxReceivableMapping.account_id,
debit: invoice.amount_tax,
credit: 0,
description: `IVA - Factura ${invoice.number}`,
ref: invoice.number,
});
}
}
// Validate balance
const totalDebit = jeLines.reduce((sum, l) => sum + l.debit, 0);
const totalCredit = jeLines.reduce((sum, l) => sum + l.credit, 0);
if (Math.abs(totalDebit - totalCredit) > 0.01) {
logger.error('Journal entry not balanced', {
invoiceId: invoice.id,
totalDebit,
totalCredit,
difference: totalDebit - totalCredit,
});
throw new ValidationError(
`El asiento contable no está balanceado. Débitos: ${totalDebit.toFixed(2)}, Créditos: ${totalCredit.toFixed(2)}`
);
}
// Get journal (use invoice's journal or find default)
let journalId = invoice.journal_id;
if (!journalId) {
const journalType = isCustomerInvoice ? 'sale' : 'purchase';
const defaultJournal = await queryOne<{ id: string }>(
`SELECT id FROM financial.journals
WHERE tenant_id = $1 AND company_id = $2 AND type = $3 AND is_active = true
LIMIT 1`,
[tenantId, companyId, journalType]
);
if (!defaultJournal) {
throw new ValidationError(
`No hay diario de ${isCustomerInvoice ? 'ventas' : 'compras'} configurado`
);
}
journalId = defaultJournal.id;
}
// Create journal entry
const client = await getClient();
try {
await client.query('BEGIN');
// Generate journal entry number
const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId);
// Create entry header
const entryResult = await client.query(
`INSERT INTO financial.journal_entries (
tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8)
RETURNING id, name`,
[
tenantId,
companyId,
journalId,
jeName,
invoice.number,
invoice.invoice_date,
`Asiento automático - Factura ${invoice.number}`,
userId,
]
);
const journalEntry = entryResult.rows[0];
// Create entry lines
for (const line of jeLines) {
await client.query(
`INSERT INTO financial.journal_entry_lines (
entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
journalEntry.id,
tenantId,
line.account_id,
line.partner_id,
line.debit,
line.credit,
line.description,
line.ref,
]
);
}
// Update journal entry posted_at
await client.query(
`UPDATE financial.journal_entries SET posted_at = CURRENT_TIMESTAMP, posted_by = $1 WHERE id = $2`,
[userId, journalEntry.id]
);
await client.query('COMMIT');
logger.info('GL posting created successfully', {
invoiceId: invoice.id,
journalEntryId: journalEntry.id,
journalEntryName: journalEntry.name,
totalDebit,
totalCredit,
linesCount: jeLines.length,
});
return {
journal_entry_id: journalEntry.id,
journal_entry_name: journalEntry.name,
total_debit: totalDebit,
total_credit: totalCredit,
lines_count: jeLines.length,
};
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error creating GL posting', {
invoiceId: invoice.id,
error: (error as Error).message,
});
throw error;
} finally {
client.release();
}
}
/**
* Create a journal entry from a posted payment
*
* For inbound payment (from customer):
* - Debit: Cash/Bank account
* - Credit: Accounts Receivable
*
* For outbound payment (to supplier):
* - Credit: Cash/Bank account
* - Debit: Accounts Payable
*/
async createPaymentPosting(
payment: {
id: string;
tenant_id: string;
company_id: string;
partner_id: string;
partner_name?: string;
payment_type: 'inbound' | 'outbound';
amount: number;
payment_date: Date;
ref?: string;
journal_id: string;
},
userId: string,
client?: PoolClient
): Promise<PostingResult> {
const { tenant_id: tenantId, company_id: companyId } = payment;
const isInbound = payment.payment_type === 'inbound';
logger.info('Creating GL posting for payment', {
paymentId: payment.id,
paymentType: payment.payment_type,
amount: payment.amount,
});
// Get cash/bank account from journal
const journal = await queryOne<{ default_debit_account_id: string; default_credit_account_id: string }>(
`SELECT default_debit_account_id, default_credit_account_id FROM financial.journals WHERE id = $1`,
[payment.journal_id]
);
if (!journal) {
throw new ValidationError('Diario de pago no encontrado');
}
const cashAccountId = isInbound ? journal.default_debit_account_id : journal.default_credit_account_id;
if (!cashAccountId) {
throw new ValidationError('El diario no tiene cuenta de efectivo/banco configurada');
}
// Get AR/AP account
const partnerAccountType = isInbound
? AccountMappingType.CUSTOMER_PAYMENT
: AccountMappingType.SUPPLIER_PAYMENT;
let partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId);
// Fall back to invoice mapping if payment-specific not configured
if (!partnerMapping) {
const fallbackType = isInbound
? AccountMappingType.CUSTOMER_INVOICE
: AccountMappingType.SUPPLIER_INVOICE;
partnerMapping = await this.getMapping(fallbackType, tenantId, companyId);
}
if (!partnerMapping) {
throw new ValidationError(
`No hay cuenta configurada para ${isInbound ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}`
);
}
// Build journal entry lines
const jeLines: JournalEntryLineInput[] = [];
const paymentRef = payment.ref || `PAY-${payment.id.substring(0, 8)}`;
if (isInbound) {
// Inbound: Debit Cash, Credit AR
jeLines.push({
account_id: cashAccountId,
debit: payment.amount,
credit: 0,
description: `Pago recibido - ${payment.partner_name || 'Cliente'}`,
ref: paymentRef,
});
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: payment.partner_id,
debit: 0,
credit: payment.amount,
description: `Pago recibido - ${payment.partner_name || 'Cliente'}`,
ref: paymentRef,
});
} else {
// Outbound: Credit Cash, Debit AP
jeLines.push({
account_id: cashAccountId,
debit: 0,
credit: payment.amount,
description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`,
ref: paymentRef,
});
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: payment.partner_id,
debit: payment.amount,
credit: 0,
description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`,
ref: paymentRef,
});
}
// Create journal entry
const ownClient = !client;
const dbClient = client || await getClient();
try {
if (ownClient) {
await dbClient.query('BEGIN');
}
// Generate journal entry number
const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId);
// Create entry header
const entryResult = await dbClient.query(
`INSERT INTO financial.journal_entries (
tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8, CURRENT_TIMESTAMP, $8)
RETURNING id, name`,
[
tenantId,
companyId,
payment.journal_id,
jeName,
paymentRef,
payment.payment_date,
`Asiento automático - Pago ${paymentRef}`,
userId,
]
);
const journalEntry = entryResult.rows[0];
// Create entry lines
for (const line of jeLines) {
await dbClient.query(
`INSERT INTO financial.journal_entry_lines (
entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
journalEntry.id,
tenantId,
line.account_id,
line.partner_id,
line.debit,
line.credit,
line.description,
line.ref,
]
);
}
if (ownClient) {
await dbClient.query('COMMIT');
}
logger.info('Payment GL posting created successfully', {
paymentId: payment.id,
journalEntryId: journalEntry.id,
journalEntryName: journalEntry.name,
});
return {
journal_entry_id: journalEntry.id,
journal_entry_name: journalEntry.name,
total_debit: payment.amount,
total_credit: payment.amount,
lines_count: 2,
};
} catch (error) {
if (ownClient) {
await dbClient.query('ROLLBACK');
}
throw error;
} finally {
if (ownClient) {
dbClient.release();
}
}
}
/**
* Reverse a journal entry (create a contra entry)
*/
async reversePosting(
journalEntryId: string,
tenantId: string,
reason: string,
userId: string
): Promise<PostingResult> {
// Get original entry
const originalEntry = await queryOne<{
id: string;
company_id: string;
journal_id: string;
name: string;
ref: string;
date: Date;
}>(
`SELECT id, company_id, journal_id, name, ref, date
FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`,
[journalEntryId, tenantId]
);
if (!originalEntry) {
throw new NotFoundError('Asiento contable no encontrado');
}
// Get original lines
const originalLines = await query<JournalEntryLineInput & { id: string }>(
`SELECT account_id, partner_id, debit, credit, description, ref
FROM financial.journal_entry_lines WHERE entry_id = $1`,
[journalEntryId]
);
// Reverse debits and credits
const reversedLines: JournalEntryLineInput[] = originalLines.map(line => ({
account_id: line.account_id,
partner_id: line.partner_id,
debit: line.credit, // Swap
credit: line.debit, // Swap
description: `Reverso: ${line.description || ''}`,
ref: line.ref,
}));
const client = await getClient();
try {
await client.query('BEGIN');
// Generate new entry number
const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId);
// Create reversal entry
const entryResult = await client.query(
`INSERT INTO financial.journal_entries (
tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by
)
VALUES ($1, $2, $3, $4, $5, CURRENT_DATE, $6, 'posted', $7, CURRENT_TIMESTAMP, $7)
RETURNING id, name`,
[
tenantId,
originalEntry.company_id,
originalEntry.journal_id,
jeName,
`REV-${originalEntry.name}`,
`Reverso de ${originalEntry.name}: ${reason}`,
userId,
]
);
const reversalEntry = entryResult.rows[0];
// Create reversal lines
for (const line of reversedLines) {
await client.query(
`INSERT INTO financial.journal_entry_lines (
entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
reversalEntry.id,
tenantId,
line.account_id,
line.partner_id,
line.debit,
line.credit,
line.description,
line.ref,
]
);
}
// Mark original as cancelled
await client.query(
`UPDATE financial.journal_entries SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1 WHERE id = $2`,
[userId, journalEntryId]
);
await client.query('COMMIT');
const totalDebit = reversedLines.reduce((sum, l) => sum + l.debit, 0);
logger.info('GL posting reversed', {
originalEntryId: journalEntryId,
reversalEntryId: reversalEntry.id,
reason,
});
return {
journal_entry_id: reversalEntry.id,
journal_entry_name: reversalEntry.name,
total_debit: totalDebit,
total_credit: totalDebit,
lines_count: reversedLines.length,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
export const glPostingService = new GLPostingService();