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>
657 lines
20 KiB
TypeScript
657 lines
20 KiB
TypeScript
import { query, queryOne, getClient } from '../../config/database.js';
|
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
|
import { taxesService } from './taxes.service.js';
|
|
import { glPostingService, InvoiceForPosting } from './gl-posting.service.js';
|
|
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
|
import { logger } from '../../shared/utils/logger.js';
|
|
|
|
export interface InvoiceLine {
|
|
id: string;
|
|
invoice_id: string;
|
|
product_id?: string;
|
|
product_name?: string;
|
|
description: string;
|
|
quantity: number;
|
|
uom_id?: string;
|
|
uom_name?: string;
|
|
price_unit: number;
|
|
tax_ids: string[];
|
|
amount_untaxed: number;
|
|
amount_tax: number;
|
|
amount_total: number;
|
|
account_id?: string;
|
|
account_name?: string;
|
|
}
|
|
|
|
export interface Invoice {
|
|
id: string;
|
|
tenant_id: string;
|
|
company_id: string;
|
|
company_name?: string;
|
|
partner_id: string;
|
|
partner_name?: string;
|
|
invoice_type: 'customer' | 'supplier';
|
|
number?: string;
|
|
ref?: string;
|
|
invoice_date: Date;
|
|
due_date?: Date;
|
|
currency_id: string;
|
|
currency_code?: string;
|
|
amount_untaxed: number;
|
|
amount_tax: number;
|
|
amount_total: number;
|
|
amount_paid: number;
|
|
amount_residual: number;
|
|
status: 'draft' | 'open' | 'paid' | 'cancelled';
|
|
payment_term_id?: string;
|
|
journal_id?: string;
|
|
journal_entry_id?: string;
|
|
notes?: string;
|
|
lines?: InvoiceLine[];
|
|
created_at: Date;
|
|
validated_at?: Date;
|
|
}
|
|
|
|
export interface CreateInvoiceDto {
|
|
company_id: string;
|
|
partner_id: string;
|
|
invoice_type: 'customer' | 'supplier';
|
|
ref?: string;
|
|
invoice_date?: string;
|
|
due_date?: string;
|
|
currency_id: string;
|
|
payment_term_id?: string;
|
|
journal_id?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface UpdateInvoiceDto {
|
|
partner_id?: string;
|
|
ref?: string | null;
|
|
invoice_date?: string;
|
|
due_date?: string | null;
|
|
currency_id?: string;
|
|
payment_term_id?: string | null;
|
|
journal_id?: string | null;
|
|
notes?: string | null;
|
|
}
|
|
|
|
export interface CreateInvoiceLineDto {
|
|
product_id?: string;
|
|
description: string;
|
|
quantity: number;
|
|
uom_id?: string;
|
|
price_unit: number;
|
|
tax_ids?: string[];
|
|
account_id?: string;
|
|
}
|
|
|
|
export interface UpdateInvoiceLineDto {
|
|
product_id?: string | null;
|
|
description?: string;
|
|
quantity?: number;
|
|
uom_id?: string | null;
|
|
price_unit?: number;
|
|
tax_ids?: string[];
|
|
account_id?: string | null;
|
|
}
|
|
|
|
export interface InvoiceFilters {
|
|
company_id?: string;
|
|
partner_id?: string;
|
|
invoice_type?: string;
|
|
status?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
search?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
class InvoicesService {
|
|
async findAll(tenantId: string, filters: InvoiceFilters = {}): Promise<{ data: Invoice[]; total: number }> {
|
|
const { company_id, partner_id, invoice_type, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
|
const offset = (page - 1) * limit;
|
|
|
|
let whereClause = 'WHERE i.tenant_id = $1';
|
|
const params: any[] = [tenantId];
|
|
let paramIndex = 2;
|
|
|
|
if (company_id) {
|
|
whereClause += ` AND i.company_id = $${paramIndex++}`;
|
|
params.push(company_id);
|
|
}
|
|
|
|
if (partner_id) {
|
|
whereClause += ` AND i.partner_id = $${paramIndex++}`;
|
|
params.push(partner_id);
|
|
}
|
|
|
|
if (invoice_type) {
|
|
whereClause += ` AND i.invoice_type = $${paramIndex++}`;
|
|
params.push(invoice_type);
|
|
}
|
|
|
|
if (status) {
|
|
whereClause += ` AND i.status = $${paramIndex++}`;
|
|
params.push(status);
|
|
}
|
|
|
|
if (date_from) {
|
|
whereClause += ` AND i.invoice_date >= $${paramIndex++}`;
|
|
params.push(date_from);
|
|
}
|
|
|
|
if (date_to) {
|
|
whereClause += ` AND i.invoice_date <= $${paramIndex++}`;
|
|
params.push(date_to);
|
|
}
|
|
|
|
if (search) {
|
|
whereClause += ` AND (i.number ILIKE $${paramIndex} OR i.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
|
params.push(`%${search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const countResult = await queryOne<{ count: string }>(
|
|
`SELECT COUNT(*) as count
|
|
FROM financial.invoices i
|
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
|
${whereClause}`,
|
|
params
|
|
);
|
|
|
|
params.push(limit, offset);
|
|
const data = await query<Invoice>(
|
|
`SELECT i.*,
|
|
c.name as company_name,
|
|
p.name as partner_name,
|
|
cu.code as currency_code
|
|
FROM financial.invoices i
|
|
LEFT JOIN auth.companies c ON i.company_id = c.id
|
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
|
LEFT JOIN core.currencies cu ON i.currency_id = cu.id
|
|
${whereClause}
|
|
ORDER BY i.invoice_date DESC, i.created_at DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
params
|
|
);
|
|
|
|
return {
|
|
data,
|
|
total: parseInt(countResult?.count || '0', 10),
|
|
};
|
|
}
|
|
|
|
async findById(id: string, tenantId: string): Promise<Invoice> {
|
|
const invoice = await queryOne<Invoice>(
|
|
`SELECT i.*,
|
|
c.name as company_name,
|
|
p.name as partner_name,
|
|
cu.code as currency_code
|
|
FROM financial.invoices i
|
|
LEFT JOIN auth.companies c ON i.company_id = c.id
|
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
|
LEFT JOIN core.currencies cu ON i.currency_id = cu.id
|
|
WHERE i.id = $1 AND i.tenant_id = $2`,
|
|
[id, tenantId]
|
|
);
|
|
|
|
if (!invoice) {
|
|
throw new NotFoundError('Factura no encontrada');
|
|
}
|
|
|
|
// Get lines
|
|
const lines = await query<InvoiceLine>(
|
|
`SELECT il.*,
|
|
pr.name as product_name,
|
|
um.name as uom_name,
|
|
a.name as account_name
|
|
FROM financial.invoice_lines il
|
|
LEFT JOIN inventory.products pr ON il.product_id = pr.id
|
|
LEFT JOIN core.uom um ON il.uom_id = um.id
|
|
LEFT JOIN financial.accounts a ON il.account_id = a.id
|
|
WHERE il.invoice_id = $1
|
|
ORDER BY il.created_at`,
|
|
[id]
|
|
);
|
|
|
|
invoice.lines = lines;
|
|
|
|
return invoice;
|
|
}
|
|
|
|
async create(dto: CreateInvoiceDto, tenantId: string, userId: string): Promise<Invoice> {
|
|
const invoiceDate = dto.invoice_date || new Date().toISOString().split('T')[0];
|
|
|
|
const invoice = await queryOne<Invoice>(
|
|
`INSERT INTO financial.invoices (
|
|
tenant_id, company_id, partner_id, invoice_type, ref, invoice_date,
|
|
due_date, currency_id, payment_term_id, journal_id, notes,
|
|
amount_untaxed, amount_tax, amount_total, amount_paid, amount_residual, created_by
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0, 0, 0, 0, 0, $12)
|
|
RETURNING *`,
|
|
[
|
|
tenantId, dto.company_id, dto.partner_id, dto.invoice_type, dto.ref,
|
|
invoiceDate, dto.due_date, dto.currency_id, dto.payment_term_id,
|
|
dto.journal_id, dto.notes, userId
|
|
]
|
|
);
|
|
|
|
return invoice!;
|
|
}
|
|
|
|
async update(id: string, dto: UpdateInvoiceDto, tenantId: string, userId: string): Promise<Invoice> {
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
if (existing.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden editar facturas en estado borrador');
|
|
}
|
|
|
|
const updateFields: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (dto.partner_id !== undefined) {
|
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
|
values.push(dto.partner_id);
|
|
}
|
|
if (dto.ref !== undefined) {
|
|
updateFields.push(`ref = $${paramIndex++}`);
|
|
values.push(dto.ref);
|
|
}
|
|
if (dto.invoice_date !== undefined) {
|
|
updateFields.push(`invoice_date = $${paramIndex++}`);
|
|
values.push(dto.invoice_date);
|
|
}
|
|
if (dto.due_date !== undefined) {
|
|
updateFields.push(`due_date = $${paramIndex++}`);
|
|
values.push(dto.due_date);
|
|
}
|
|
if (dto.currency_id !== undefined) {
|
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
|
values.push(dto.currency_id);
|
|
}
|
|
if (dto.payment_term_id !== undefined) {
|
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
|
values.push(dto.payment_term_id);
|
|
}
|
|
if (dto.journal_id !== undefined) {
|
|
updateFields.push(`journal_id = $${paramIndex++}`);
|
|
values.push(dto.journal_id);
|
|
}
|
|
if (dto.notes !== undefined) {
|
|
updateFields.push(`notes = $${paramIndex++}`);
|
|
values.push(dto.notes);
|
|
}
|
|
|
|
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.invoices SET ${updateFields.join(', ')}
|
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
|
values
|
|
);
|
|
|
|
return this.findById(id, tenantId);
|
|
}
|
|
|
|
async delete(id: string, tenantId: string): Promise<void> {
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
if (existing.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden eliminar facturas en estado borrador');
|
|
}
|
|
|
|
await query(
|
|
`DELETE FROM financial.invoices WHERE id = $1 AND tenant_id = $2`,
|
|
[id, tenantId]
|
|
);
|
|
}
|
|
|
|
async addLine(invoiceId: string, dto: CreateInvoiceLineDto, tenantId: string): Promise<InvoiceLine> {
|
|
const invoice = await this.findById(invoiceId, tenantId);
|
|
|
|
if (invoice.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden agregar líneas a facturas en estado borrador');
|
|
}
|
|
|
|
// Calculate amounts with taxes using taxesService
|
|
// Determine transaction type based on invoice type
|
|
const transactionType = invoice.invoice_type === 'customer'
|
|
? 'sales'
|
|
: 'purchase';
|
|
|
|
const taxResult = await taxesService.calculateTaxes(
|
|
{
|
|
quantity: dto.quantity,
|
|
priceUnit: dto.price_unit,
|
|
discount: 0, // Invoices don't have line discounts by default
|
|
taxIds: dto.tax_ids || [],
|
|
},
|
|
tenantId,
|
|
transactionType
|
|
);
|
|
const amountUntaxed = taxResult.amountUntaxed;
|
|
const amountTax = taxResult.amountTax;
|
|
const amountTotal = taxResult.amountTotal;
|
|
|
|
const line = await queryOne<InvoiceLine>(
|
|
`INSERT INTO financial.invoice_lines (
|
|
invoice_id, tenant_id, product_id, description, quantity, uom_id,
|
|
price_unit, tax_ids, amount_untaxed, amount_tax, amount_total, account_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
RETURNING *`,
|
|
[
|
|
invoiceId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
|
dto.price_unit, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.account_id
|
|
]
|
|
);
|
|
|
|
// Update invoice totals
|
|
await this.updateTotals(invoiceId);
|
|
|
|
return line!;
|
|
}
|
|
|
|
async updateLine(invoiceId: string, lineId: string, dto: UpdateInvoiceLineDto, tenantId: string): Promise<InvoiceLine> {
|
|
const invoice = await this.findById(invoiceId, tenantId);
|
|
|
|
if (invoice.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden editar líneas de facturas en estado borrador');
|
|
}
|
|
|
|
const existingLine = invoice.lines?.find(l => l.id === lineId);
|
|
if (!existingLine) {
|
|
throw new NotFoundError('Línea de factura no encontrada');
|
|
}
|
|
|
|
const updateFields: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
const quantity = dto.quantity ?? existingLine.quantity;
|
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
|
|
|
if (dto.product_id !== undefined) {
|
|
updateFields.push(`product_id = $${paramIndex++}`);
|
|
values.push(dto.product_id);
|
|
}
|
|
if (dto.description !== undefined) {
|
|
updateFields.push(`description = $${paramIndex++}`);
|
|
values.push(dto.description);
|
|
}
|
|
if (dto.quantity !== undefined) {
|
|
updateFields.push(`quantity = $${paramIndex++}`);
|
|
values.push(dto.quantity);
|
|
}
|
|
if (dto.uom_id !== undefined) {
|
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
|
values.push(dto.uom_id);
|
|
}
|
|
if (dto.price_unit !== undefined) {
|
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
|
values.push(dto.price_unit);
|
|
}
|
|
if (dto.tax_ids !== undefined) {
|
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
|
values.push(dto.tax_ids);
|
|
}
|
|
if (dto.account_id !== undefined) {
|
|
updateFields.push(`account_id = $${paramIndex++}`);
|
|
values.push(dto.account_id);
|
|
}
|
|
|
|
// Recalculate amounts using taxesService
|
|
const taxIds = dto.tax_ids ?? existingLine.tax_ids ?? [];
|
|
const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase';
|
|
|
|
const taxResult = await taxesService.calculateTaxes(
|
|
{
|
|
quantity,
|
|
priceUnit,
|
|
discount: 0,
|
|
taxIds,
|
|
},
|
|
tenantId,
|
|
transactionType
|
|
);
|
|
|
|
const amountUntaxed = taxResult.amountUntaxed;
|
|
const amountTax = taxResult.amountTax;
|
|
const amountTotal = taxResult.amountTotal;
|
|
|
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
|
values.push(amountUntaxed);
|
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
|
values.push(amountTax);
|
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
|
values.push(amountTotal);
|
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
|
|
|
values.push(lineId, invoiceId);
|
|
|
|
await query(
|
|
`UPDATE financial.invoice_lines SET ${updateFields.join(', ')}
|
|
WHERE id = $${paramIndex++} AND invoice_id = $${paramIndex}`,
|
|
values
|
|
);
|
|
|
|
// Update invoice totals
|
|
await this.updateTotals(invoiceId);
|
|
|
|
const updated = await queryOne<InvoiceLine>(
|
|
`SELECT * FROM financial.invoice_lines WHERE id = $1`,
|
|
[lineId]
|
|
);
|
|
|
|
return updated!;
|
|
}
|
|
|
|
async removeLine(invoiceId: string, lineId: string, tenantId: string): Promise<void> {
|
|
const invoice = await this.findById(invoiceId, tenantId);
|
|
|
|
if (invoice.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden eliminar líneas de facturas en estado borrador');
|
|
}
|
|
|
|
await query(
|
|
`DELETE FROM financial.invoice_lines WHERE id = $1 AND invoice_id = $2`,
|
|
[lineId, invoiceId]
|
|
);
|
|
|
|
// Update invoice totals
|
|
await this.updateTotals(invoiceId);
|
|
}
|
|
|
|
async validate(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
|
const invoice = await this.findById(id, tenantId);
|
|
|
|
if (invoice.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden validar facturas en estado borrador');
|
|
}
|
|
|
|
if (!invoice.lines || invoice.lines.length === 0) {
|
|
throw new ValidationError('La factura debe tener al menos una línea');
|
|
}
|
|
|
|
logger.info('Validating invoice', { invoiceId: id, invoiceType: invoice.invoice_type });
|
|
|
|
// Generate invoice number using sequences service
|
|
const sequenceCode = invoice.invoice_type === 'customer'
|
|
? SEQUENCE_CODES.INVOICE_CUSTOMER
|
|
: SEQUENCE_CODES.INVOICE_SUPPLIER;
|
|
const invoiceNumber = await sequencesService.getNextNumber(sequenceCode, tenantId);
|
|
|
|
const client = await getClient();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Update invoice status and number
|
|
await client.query(
|
|
`UPDATE financial.invoices SET
|
|
number = $1,
|
|
status = 'open',
|
|
amount_residual = amount_total,
|
|
validated_at = CURRENT_TIMESTAMP,
|
|
validated_by = $2,
|
|
updated_by = $2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $3 AND tenant_id = $4`,
|
|
[invoiceNumber, userId, id, tenantId]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Get updated invoice with number for GL posting
|
|
const validatedInvoice = await this.findById(id, tenantId);
|
|
|
|
// Create journal entry for the invoice (GL posting)
|
|
try {
|
|
const invoiceForPosting: InvoiceForPosting = {
|
|
id: validatedInvoice.id,
|
|
tenant_id: validatedInvoice.tenant_id,
|
|
company_id: validatedInvoice.company_id,
|
|
partner_id: validatedInvoice.partner_id,
|
|
partner_name: validatedInvoice.partner_name,
|
|
invoice_type: validatedInvoice.invoice_type,
|
|
number: validatedInvoice.number!,
|
|
invoice_date: validatedInvoice.invoice_date,
|
|
amount_untaxed: Number(validatedInvoice.amount_untaxed),
|
|
amount_tax: Number(validatedInvoice.amount_tax),
|
|
amount_total: Number(validatedInvoice.amount_total),
|
|
journal_id: validatedInvoice.journal_id,
|
|
lines: (validatedInvoice.lines || []).map(line => ({
|
|
id: line.id,
|
|
product_id: line.product_id,
|
|
description: line.description,
|
|
quantity: Number(line.quantity),
|
|
price_unit: Number(line.price_unit),
|
|
amount_untaxed: Number(line.amount_untaxed),
|
|
amount_tax: Number(line.amount_tax),
|
|
amount_total: Number(line.amount_total),
|
|
account_id: line.account_id,
|
|
tax_ids: line.tax_ids || [],
|
|
})),
|
|
};
|
|
|
|
const postingResult = await glPostingService.createInvoicePosting(invoiceForPosting, userId);
|
|
|
|
// Link journal entry to invoice
|
|
await query(
|
|
`UPDATE financial.invoices SET journal_entry_id = $1 WHERE id = $2`,
|
|
[postingResult.journal_entry_id, id]
|
|
);
|
|
|
|
logger.info('Invoice validated with GL posting', {
|
|
invoiceId: id,
|
|
invoiceNumber,
|
|
journalEntryId: postingResult.journal_entry_id,
|
|
journalEntryName: postingResult.journal_entry_name,
|
|
});
|
|
} catch (postingError) {
|
|
// Log error but don't fail the validation - GL posting can be done manually
|
|
logger.error('Failed to create automatic GL posting', {
|
|
invoiceId: id,
|
|
error: (postingError as Error).message,
|
|
});
|
|
// The invoice is still valid, just without automatic GL entry
|
|
}
|
|
|
|
return this.findById(id, tenantId);
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
|
const invoice = await this.findById(id, tenantId);
|
|
|
|
if (invoice.status === 'paid') {
|
|
throw new ValidationError('No se pueden cancelar facturas pagadas');
|
|
}
|
|
|
|
if (invoice.status === 'cancelled') {
|
|
throw new ValidationError('La factura ya está cancelada');
|
|
}
|
|
|
|
if (invoice.amount_paid > 0) {
|
|
throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados');
|
|
}
|
|
|
|
logger.info('Cancelling invoice', { invoiceId: id, invoiceNumber: invoice.number });
|
|
|
|
// Reverse journal entry if exists
|
|
if (invoice.journal_entry_id) {
|
|
try {
|
|
await glPostingService.reversePosting(
|
|
invoice.journal_entry_id,
|
|
tenantId,
|
|
`Cancelación de factura ${invoice.number}`,
|
|
userId
|
|
);
|
|
logger.info('Journal entry reversed for cancelled invoice', {
|
|
invoiceId: id,
|
|
journalEntryId: invoice.journal_entry_id,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to reverse journal entry', {
|
|
invoiceId: id,
|
|
journalEntryId: invoice.journal_entry_id,
|
|
error: (error as Error).message,
|
|
});
|
|
// Continue with cancellation even if reversal fails
|
|
}
|
|
}
|
|
|
|
await query(
|
|
`UPDATE financial.invoices SET
|
|
status = 'cancelled',
|
|
cancelled_at = CURRENT_TIMESTAMP,
|
|
cancelled_by = $1,
|
|
updated_by = $1,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2 AND tenant_id = $3`,
|
|
[userId, id, tenantId]
|
|
);
|
|
|
|
return this.findById(id, tenantId);
|
|
}
|
|
|
|
private async updateTotals(invoiceId: string): Promise<void> {
|
|
const totals = await queryOne<{ amount_untaxed: number; amount_tax: number; amount_total: number }>(
|
|
`SELECT
|
|
COALESCE(SUM(amount_untaxed), 0) as amount_untaxed,
|
|
COALESCE(SUM(amount_tax), 0) as amount_tax,
|
|
COALESCE(SUM(amount_total), 0) as amount_total
|
|
FROM financial.invoice_lines WHERE invoice_id = $1`,
|
|
[invoiceId]
|
|
);
|
|
|
|
await query(
|
|
`UPDATE financial.invoices SET
|
|
amount_untaxed = $1,
|
|
amount_tax = $2,
|
|
amount_total = $3,
|
|
amount_residual = $3 - amount_paid
|
|
WHERE id = $4`,
|
|
[totals?.amount_untaxed || 0, totals?.amount_tax || 0, totals?.amount_total || 0, invoiceId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export const invoicesService = new InvoicesService();
|