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>
712 lines
22 KiB
TypeScript
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();
|