refactor: Clean up finance services (AP, AR, cash-flow, bank-reconciliation)

- Cleaned up ap.service.ts
- Refactored ar.service.ts
- Minor fixes in bank-reconciliation.service.ts
- Updated cash-flow.service.ts
- Cleaned erp-integration.service.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 14:31:51 -06:00
parent 369f461695
commit cf23727b2b
5 changed files with 111 additions and 131 deletions

View File

@ -189,48 +189,44 @@ export class APService {
}
async create(ctx: ServiceContext, data: CreateAPDto): Promise<AccountPayable> {
// Calcular monto neto (con retenciones)
const netAmount =
data.originalAmount -
(data.retentionIsr ?? 0) -
(data.retentionIva ?? 0) -
// Calcular retención total
const retentionAmount =
(data.retentionIsr ?? 0) +
(data.retentionIva ?? 0) +
(data.guaranteeFund ?? 0);
const subtotal = data.originalAmount - (data.taxAmount ?? 0);
const totalAmount = data.originalAmount;
const balance = totalAmount - retentionAmount;
const ap = this.apRepository.create({
tenantId: ctx.tenantId,
documentType: data.documentType,
documentNumber: data.documentNumber,
documentDate: data.documentDate,
invoiceDate: data.documentDate,
dueDate: data.dueDate,
partnerId: data.supplierId,
partnerName: data.supplierName,
partnerRfc: data.partnerRfc,
supplierId: data.partnerId,
supplierName: data.partnerName,
supplierRfc: data.partnerRfc,
projectId: data.projectId,
projectCode: data.projectCode,
contractId: data.contractId,
purchaseOrderId: data.purchaseOrderId,
originalAmount: data.originalAmount,
subtotal,
taxAmount: data.taxAmount ?? 0,
retentionIsr: data.retentionIsr ?? 0,
retentionIva: data.retentionIva ?? 0,
guaranteeFund: data.guaranteeFund ?? 0,
netAmount,
retentionAmount,
totalAmount,
paidAmount: 0,
balanceAmount: netAmount,
currencyCode: data.currencyCode ?? 'MXN',
balance,
currency: data.currencyCode ?? 'MXN',
exchangeRate: data.exchangeRate ?? 1,
cfdiUuid: data.cfdiUuid,
cfdiXml: data.cfdiXml,
description: data.description,
paymentTermDays: data.paymentTermDays,
ledgerAccountId: data.ledgerAccountId,
paymentDays: data.paymentTermDays ?? 30,
notes: data.notes,
metadata: data.metadata,
status: 'pending',
createdBy: ctx.userId,
});
return this.apRepository.save(ap);
const result = await this.apRepository.save(ap);
return result;
}
async update(
@ -252,9 +248,10 @@ export class APService {
updatedBy: ctx.userId,
});
// Recalcular saldo si cambió el monto total
if (data.totalAmount !== undefined) {
ap.balance = ap.totalAmount - ap.paidAmount;
// Recalcular saldo si cambió el monto original
if (data.originalAmount !== undefined) {
ap.totalAmount = data.originalAmount;
ap.balance = Number(ap.totalAmount) - Number(ap.retentionAmount) - Number(ap.paidAmount);
}
return this.apRepository.save(ap);

View File

@ -13,7 +13,6 @@ import {
ARDocumentType,
ARPayment,
CollectionMethod,
CollectionStatus,
} from '../entities';
interface ServiceContext {
@ -84,8 +83,10 @@ interface AgingBucket {
export class ARService {
private arRepository: Repository<AccountReceivable>;
private collectionRepository: Repository<ARPayment>;
dataSource: DataSource;
constructor(private dataSource: DataSource) {
constructor(dataSource: DataSource) {
this.dataSource = dataSource;
this.arRepository = dataSource.getRepository(AccountReceivable);
this.collectionRepository = dataSource.getRepository(ARPayment);
}
@ -128,7 +129,7 @@ export class ARService {
}
if (partnerId) {
queryBuilder.andWhere('ar.partnerId = :partnerId', { partnerId });
queryBuilder.andWhere('ar.customerId = :partnerId', { partnerId });
}
if (projectId) {
@ -136,11 +137,11 @@ export class ARService {
}
if (startDate) {
queryBuilder.andWhere('ar.documentDate >= :startDate', { startDate });
queryBuilder.andWhere('ar.invoiceDate >= :startDate', { startDate });
}
if (endDate) {
queryBuilder.andWhere('ar.documentDate <= :endDate', { endDate });
queryBuilder.andWhere('ar.invoiceDate <= :endDate', { endDate });
}
if (overdue) {
@ -152,7 +153,7 @@ export class ARService {
if (search) {
queryBuilder.andWhere(
'(ar.documentNumber ILIKE :search OR ar.partnerName ILIKE :search)',
'(ar.documentNumber ILIKE :search OR ar.customerName ILIKE :search)',
{ search: `%${search}%` }
);
}
@ -186,41 +187,36 @@ export class ARService {
}
async create(ctx: ServiceContext, data: CreateARDto): Promise<AccountReceivable> {
// Calcular monto neto (con retenciones)
const netAmount =
data.originalAmount -
(data.retentionIsr ?? 0) -
(data.retentionIva ?? 0) -
// Calcular retención total y monto neto
const retentionAmount =
(data.retentionIsr ?? 0) +
(data.retentionIva ?? 0) +
(data.guaranteeFund ?? 0);
const subtotal = data.originalAmount - (data.taxAmount ?? 0);
const totalAmount = data.originalAmount;
const balance = totalAmount - retentionAmount;
const ar = this.arRepository.create({
tenantId: ctx.tenantId,
documentType: data.documentType,
documentNumber: data.documentNumber,
documentDate: data.documentDate,
invoiceDate: data.documentDate,
dueDate: data.dueDate,
partnerId: data.partnerId,
partnerName: data.partnerName,
partnerRfc: data.partnerRfc,
customerId: data.partnerId,
customerName: data.partnerName,
customerRfc: data.partnerRfc,
projectId: data.projectId,
projectCode: data.projectCode,
contractId: data.contractId,
estimationId: data.estimationId,
originalAmount: data.originalAmount,
subtotal,
taxAmount: data.taxAmount ?? 0,
retentionIsr: data.retentionIsr ?? 0,
retentionIva: data.retentionIva ?? 0,
guaranteeFund: data.guaranteeFund ?? 0,
netAmount,
retentionAmount,
totalAmount,
collectedAmount: 0,
balanceAmount: netAmount,
currencyCode: data.currencyCode ?? 'MXN',
balance,
currency: data.currencyCode ?? 'MXN',
exchangeRate: data.exchangeRate ?? 1,
cfdiUuid: data.cfdiUuid,
cfdiXml: data.cfdiXml,
description: data.description,
paymentTermDays: data.paymentTermDays,
ledgerAccountId: data.ledgerAccountId,
paymentDays: data.paymentTermDays ?? 30,
notes: data.notes,
metadata: data.metadata,
status: 'pending',
@ -252,12 +248,8 @@ export class ARService {
// Recalcular montos si cambió el monto original
if (data.originalAmount !== undefined) {
ar.netAmount =
ar.originalAmount -
(ar.retentionIsr ?? 0) -
(ar.retentionIva ?? 0) -
(ar.guaranteeFund ?? 0);
ar.balanceAmount = ar.netAmount - ar.collectedAmount;
ar.totalAmount = data.originalAmount;
ar.balance = ar.totalAmount - ar.retentionAmount - ar.collectedAmount;
}
return this.arRepository.save(ar);
@ -327,7 +319,7 @@ export class ARService {
throw new Error(`Cuenta por cobrar ${ar.documentNumber} ya está cobrada, cancelada o castigada`);
}
arRecords.push(ar);
totalToApply += ar.balanceAmount;
totalToApply += Number(ar.balance);
}
// Validar monto
@ -347,16 +339,13 @@ export class ARService {
collectionMethod: data.collectionMethod,
collectionDate: data.collectionDate,
bankAccountId: data.bankAccountId,
collectionAmount: data.collectionAmount,
currencyCode: data.currencyCode ?? 'MXN',
accountReceivableId: arRecords[0]?.id,
amount: data.collectionAmount,
currency: data.currencyCode ?? 'MXN',
exchangeRate: data.exchangeRate ?? 1,
reference: data.reference,
depositReference: data.depositReference,
transferReference: data.transferReference,
collectionConcept: data.collectionConcept,
notes: data.notes,
status: 'pending',
documentCount: arRecords.length,
createdBy: ctx.userId,
});
@ -373,14 +362,15 @@ export class ARService {
for (const ar of sortedAR) {
if (remainingAmount <= 0) break;
const amountToApply = Math.min(remainingAmount, ar.balanceAmount);
const arBalance = Number(ar.balance);
const amountToApply = Math.min(remainingAmount, arBalance);
applications.push({ arId: ar.id, amount: amountToApply });
ar.collectedAmount += amountToApply;
ar.balanceAmount -= amountToApply;
ar.lastCollectionDate = data.collectionDate;
ar.collectedAmount = Number(ar.collectedAmount) + amountToApply;
ar.balance = arBalance - amountToApply;
ar.collectionDate = data.collectionDate;
if (ar.balanceAmount <= 0.01) {
if (ar.balance <= 0.01) {
ar.status = 'collected';
} else {
ar.status = 'partial';
@ -394,9 +384,9 @@ export class ARService {
// Guardar aplicaciones en metadata del cobro
savedCollection.metadata = { applications };
await this.collectionRepository.save(savedCollection);
const result = await this.collectionRepository.save(savedCollection);
return savedCollection;
return result;
}
private async generateCollectionNumber(ctx: ServiceContext): Promise<string> {
@ -435,9 +425,8 @@ export class ARService {
throw new Error('Solo se pueden confirmar cobros pendientes');
}
collection.status = 'confirmed';
collection.confirmedAt = new Date();
collection.confirmedById = ctx.userId;
collection.status = 'deposited';
collection.depositDate = new Date();
collection.updatedBy = ctx.userId;
return this.collectionRepository.save(collection);
@ -468,9 +457,10 @@ export class ARService {
for (const app of applications) {
const ar = await this.arRepository.findOne({ where: { id: app.arId } });
if (ar) {
ar.collectedAmount -= app.amount;
ar.balanceAmount += app.amount;
ar.status = ar.balanceAmount >= ar.netAmount ? 'pending' : 'partial';
ar.collectedAmount = Number(ar.collectedAmount) - app.amount;
ar.balance = Number(ar.balance) + app.amount;
const expectedBalance = Number(ar.totalAmount) - Number(ar.retentionAmount);
ar.status = Number(ar.balance) >= expectedBalance ? 'pending' : 'partial';
ar.updatedBy = ctx.userId;
await this.arRepository.save(ar);
}
@ -505,7 +495,7 @@ export class ARService {
.andWhere('ar.deletedAt IS NULL');
if (partnerId) {
queryBuilder.andWhere('ar.partnerId = :partnerId', { partnerId });
queryBuilder.andWhere('ar.customerId = :partnerId', { partnerId });
}
if (projectId) {
@ -532,7 +522,7 @@ export class ARService {
const daysOverdue = Math.floor(
(asOfDate.getTime() - new Date(ar.dueDate).getTime()) / (1000 * 60 * 60 * 24)
);
const balance = ar.balanceAmount;
const balance = Number(ar.balance);
// Clasificar en bucket
let bucket: keyof AgingBucket;
@ -552,10 +542,10 @@ export class ARService {
summary.total += balance;
// Por cliente
if (!partnerMap.has(ar.partnerId)) {
partnerMap.set(ar.partnerId, {
partnerId: ar.partnerId,
partnerName: ar.partnerName,
if (!partnerMap.has(ar.customerId)) {
partnerMap.set(ar.customerId, {
partnerId: ar.customerId,
partnerName: ar.customerName,
aging: {
current: 0,
days1to30: 0,
@ -567,7 +557,7 @@ export class ARService {
});
}
const partnerData = partnerMap.get(ar.partnerId)!;
const partnerData = partnerMap.get(ar.customerId)!;
partnerData.aging[bucket] += balance;
partnerData.aging.total += balance;
}
@ -594,7 +584,7 @@ export class ARService {
.andWhere('ar.deletedAt IS NULL');
if (partnerId) {
queryBuilder.andWhere('ar.partnerId = :partnerId', { partnerId });
queryBuilder.andWhere('ar.customerId = :partnerId', { partnerId });
}
if (projectId) {
@ -621,7 +611,7 @@ export class ARService {
const entry = forecastMap.get(dateKey)!;
entry.documents.push(ar);
entry.totalAmount += ar.balanceAmount;
entry.totalAmount += Number(ar.balance);
}
return Array.from(forecastMap.values()).sort(
@ -653,28 +643,28 @@ export class ARService {
// Total pendiente
const totalPending = await baseQuery
.clone()
.select('SUM(ar.balanceAmount)', 'total')
.select('SUM(ar.balance)', 'total')
.getRawOne();
// Vencido
const totalOverdue = await baseQuery
.clone()
.andWhere('ar.dueDate < :today', { today })
.select('SUM(ar.balanceAmount)', 'total')
.select('SUM(ar.balance)', 'total')
.getRawOne();
// Por cobrar esta semana
const dueThisWeek = await baseQuery
.clone()
.andWhere('ar.dueDate BETWEEN :today AND :endOfWeek', { today, endOfWeek })
.select('SUM(ar.balanceAmount)', 'total')
.select('SUM(ar.balance)', 'total')
.getRawOne();
// Por cobrar este mes
const dueThisMonth = await baseQuery
.clone()
.andWhere('ar.dueDate BETWEEN :today AND :endOfMonth', { today, endOfMonth })
.select('SUM(ar.balanceAmount)', 'total')
.select('SUM(ar.balance)', 'total')
.getRawOne();
// Conteos
@ -689,11 +679,11 @@ export class ARService {
const monthlyInvoiced = await this.arRepository
.createQueryBuilder('ar')
.where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ar.documentDate BETWEEN :startOfMonth AND :endOfMonth', {
.andWhere('ar.invoiceDate BETWEEN :startOfMonth AND :endOfMonth', {
startOfMonth,
endOfMonth,
})
.select('SUM(ar.netAmount)', 'total')
.select('SUM(ar.totalAmount)', 'total')
.getRawOne();
const monthlyCollected = await this.collectionRepository
@ -704,7 +694,7 @@ export class ARService {
endOfMonth,
})
.andWhere('col.status != :cancelled', { cancelled: 'cancelled' })
.select('SUM(col.collectionAmount)', 'total')
.select('SUM(col.amount)', 'total')
.getRawOne();
const invoicedAmount = parseFloat(monthlyInvoiced?.total) || 0;

View File

@ -107,8 +107,10 @@ export class BankReconciliationService {
private bankAccountRepository: Repository<BankAccount>;
private movementRepository: Repository<BankMovement>;
private reconciliationRepository: Repository<BankReconciliation>;
dataSource: DataSource;
constructor(private dataSource: DataSource) {
constructor(dataSource: DataSource) {
this.dataSource = dataSource;
this.bankAccountRepository = dataSource.getRepository(BankAccount);
this.movementRepository = dataSource.getRepository(BankMovement);
this.reconciliationRepository = dataSource.getRepository(BankReconciliation);

View File

@ -79,7 +79,10 @@ export class CashFlowService {
private arRepository: Repository<AccountReceivable>;
private bankAccountRepository: Repository<BankAccount>;
constructor(private dataSource: DataSource) {
dataSource: DataSource;
constructor(dataSource: DataSource) {
this.dataSource = dataSource;
this.projectionRepository = dataSource.getRepository(CashFlowProjection);
this.apRepository = dataSource.getRepository(AccountPayable);
this.arRepository = dataSource.getRepository(AccountReceivable);
@ -347,10 +350,10 @@ export class CashFlowService {
const arRecords = await arQuery.getMany();
const incomeEstimations = arRecords
.filter((ar) => ar.documentType === 'estimation')
.reduce((sum, ar) => sum + Number(ar.balanceAmount), 0);
.reduce((sum, ar) => sum + Number(ar.balance), 0);
const incomeSales = arRecords
.filter((ar) => ar.documentType !== 'estimation')
.reduce((sum, ar) => sum + Number(ar.balanceAmount), 0);
.reduce((sum, ar) => sum + Number(ar.balance), 0);
// Obtener pagos esperados (AP por vencer en el periodo)
const apQuery = this.apRepository
@ -367,7 +370,7 @@ export class CashFlowService {
const apRecords = await apQuery.getMany();
const expenseSuppliers = apRecords
.filter((ap) => ap.documentType === 'invoice')
.reduce((sum, ap) => sum + Number(ap.balanceAmount), 0);
.reduce((sum, ap) => sum + Number(ap.balance), 0);
// Determinar año y periodo fiscal
const fiscalYear = periodStart.getFullYear();
@ -401,16 +404,16 @@ export class CashFlowService {
incomeBreakdown: arRecords.map((ar) => ({
id: ar.id,
documentNumber: ar.documentNumber,
partnerName: ar.partnerName,
partnerName: ar.customerName,
dueDate: ar.dueDate,
amount: ar.balanceAmount,
amount: ar.balance,
})),
expenseBreakdown: apRecords.map((ap) => ({
id: ap.id,
documentNumber: ap.documentNumber,
partnerName: ap.partnerName,
partnerName: ap.supplierName,
dueDate: ap.dueDate,
amount: ap.balanceAmount,
amount: ap.balance,
})),
notes: `Proyección automática generada el ${new Date().toISOString()}`,
});

View File

@ -9,11 +9,9 @@
import { DataSource, Repository, IsNull, Between } from 'typeorm';
import {
ChartOfAccounts,
AccountType,
AccountingEntry,
AccountingEntryLine,
AccountPayable,
AccountReceivable,
BankMovement,
} from '../entities';
interface ServiceContext {
@ -21,13 +19,6 @@ interface ServiceContext {
userId?: string;
}
interface ExportConfig {
format: 'csv' | 'xml' | 'json' | 'txt';
encoding?: string;
delimiter?: string;
includeHeaders?: boolean;
}
interface SAPExportEntry {
BUKRS: string; // Sociedad
BELNR: string; // Número de documento
@ -86,17 +77,13 @@ export class ERPIntegrationService {
private accountRepository: Repository<ChartOfAccounts>;
private entryRepository: Repository<AccountingEntry>;
private lineRepository: Repository<AccountingEntryLine>;
private apRepository: Repository<AccountPayable>;
private arRepository: Repository<AccountReceivable>;
private movementRepository: Repository<BankMovement>;
dataSource: DataSource;
constructor(private dataSource: DataSource) {
constructor(dataSource: DataSource) {
this.dataSource = dataSource;
this.accountRepository = dataSource.getRepository(ChartOfAccounts);
this.entryRepository = dataSource.getRepository(AccountingEntry);
this.lineRepository = dataSource.getRepository(AccountingEntryLine);
this.apRepository = dataSource.getRepository(AccountPayable);
this.arRepository = dataSource.getRepository(AccountReceivable);
this.movementRepository = dataSource.getRepository(BankMovement);
}
// ==================== EXPORTACIÓN SAP ====================
@ -111,7 +98,7 @@ export class ERPIntegrationService {
journalNumber?: number;
} = {}
): Promise<ExportResult> {
const { companyCode = '1000', documentType = 'SA', journalNumber = 1 } = options;
const { companyCode = '1000' } = options;
const entries = await this.entryRepository.find({
where: {
@ -132,7 +119,7 @@ export class ERPIntegrationService {
BLDAT: this.formatDateSAP(entry.entryDate),
BUDAT: this.formatDateSAP(entry.entryDate),
MONAT: entry.fiscalPeriod,
WAERS: entry.currencyCode,
WAERS: entry.currency,
KURSF: entry.exchangeRate,
BKTXT: entry.description.slice(0, 25),
lines: (entry.lines || []).map((line, idx) => ({
@ -142,7 +129,7 @@ export class ERPIntegrationService {
WRBTR: Math.abs(line.debit || line.credit),
DMBTR: Math.abs(line.debit || line.credit) * entry.exchangeRate,
SGTXT: (line.description || entry.description).slice(0, 50),
ZUONR: line.reference?.slice(0, 18),
ZUONR: line.description?.slice(0, 18),
KOSTL: line.costCenterId?.slice(0, 10),
PROJK: line.projectId?.slice(0, 24),
})),
@ -211,7 +198,7 @@ export class ERPIntegrationService {
diario?: number;
} = {}
): Promise<ExportResult> {
const { polizaTipo = 1, diario = 1 } = options; // 1=Diario
const { diario = 1 } = options; // 1=Diario
const entries = await this.entryRepository.find({
where: {
@ -236,7 +223,7 @@ export class ERPIntegrationService {
Concepto: (line.description || entry.description).slice(0, 200),
Cargo: line.debit,
Abono: line.credit,
Referencia: line.reference?.slice(0, 20),
Referencia: line.description?.slice(0, 20),
Diario: diario,
})),
}));
@ -345,7 +332,8 @@ ${entries.map((entry) => this.generatePolizaXML(entry)).join('\n')}
private generatePolizaXML(entry: AccountingEntry): string {
const fecha = new Date(entry.entryDate).toISOString().split('T')[0];
const tipoPoliza = this.mapEntryTypeToCFDI(entry.entryType);
// Map entry type for CFDI (reserved for future use)
this.mapEntryTypeToCFDI(entry.entryType);
const transacciones = (entry.lines || [])
.map((line) => {
@ -626,7 +614,7 @@ ${cuentas}
nature: 'debit' | 'credit';
level: number;
parentCode?: string;
satCode?: string;
sapCode?: string;
}[] = [];
if (format === 'json') {
@ -643,7 +631,7 @@ ${cuentas}
type: parts[2] as AccountType,
nature: parts[3] as 'debit' | 'credit',
level: parseInt(parts[4]) || 1,
satCode: parts[5],
sapCode: parts[5],
};
});
}
@ -676,7 +664,7 @@ ${cuentas}
accountType: acc.type,
nature: acc.nature,
level: acc.level,
sapCode: acc.sapCode,
satCode: acc.sapCode,
allowsDirectPosting: true,
status: 'active',
initialBalance: 0,