EPIC-001: Complete Core Module - Add PaymentTerm entity with multi-line support (30/60/90 days, early payment discounts) - Add PaymentTerms service with calculateDueDate() functionality - Add DiscountRule entity with volume/time-based conditions - Add DiscountRules service with applyDiscounts() and rule combination logic - Add REST endpoints for payment-terms and discount-rules in core module - Register new entities in TypeORM configuration EPIC-002: Entity Consolidation - Add inventoryProductId FK to products.products for linking to inventory module - Consolidate Warehouse entity in warehouses module as canonical source - Add companyId and Location relation to canonical Warehouse - Update inventory module to re-export Warehouse from warehouses module - Remove deprecated warehouse.entity.ts from inventory module - Update inventory/warehouses.service.ts to use canonical Warehouse Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
import { Repository } from 'typeorm';
|
|
import { AppDataSource } from '../../config/typeorm.js';
|
|
import {
|
|
PaymentTerm,
|
|
PaymentTermLine,
|
|
PaymentTermLineType,
|
|
} from './entities/payment-term.entity.js';
|
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
|
import { logger } from '../../shared/utils/logger.js';
|
|
|
|
// ============================================================================
|
|
// TYPES
|
|
// ============================================================================
|
|
|
|
export interface CreatePaymentTermLineDto {
|
|
sequence?: number;
|
|
line_type?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
|
|
lineType?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
|
|
value_percent?: number;
|
|
valuePercent?: number;
|
|
value_amount?: number;
|
|
valueAmount?: number;
|
|
days?: number;
|
|
day_of_month?: number;
|
|
dayOfMonth?: number;
|
|
end_of_month?: boolean;
|
|
endOfMonth?: boolean;
|
|
}
|
|
|
|
export interface CreatePaymentTermDto {
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
due_days?: number;
|
|
dueDays?: number;
|
|
discount_percent?: number;
|
|
discountPercent?: number;
|
|
discount_days?: number;
|
|
discountDays?: number;
|
|
is_immediate?: boolean;
|
|
isImmediate?: boolean;
|
|
lines?: CreatePaymentTermLineDto[];
|
|
}
|
|
|
|
export interface UpdatePaymentTermDto {
|
|
name?: string;
|
|
description?: string | null;
|
|
due_days?: number;
|
|
dueDays?: number;
|
|
discount_percent?: number | null;
|
|
discountPercent?: number | null;
|
|
discount_days?: number | null;
|
|
discountDays?: number | null;
|
|
is_immediate?: boolean;
|
|
isImmediate?: boolean;
|
|
is_active?: boolean;
|
|
isActive?: boolean;
|
|
lines?: CreatePaymentTermLineDto[];
|
|
}
|
|
|
|
export interface DueDateResult {
|
|
dueDate: Date;
|
|
discountDate: Date | null;
|
|
discountAmount: number;
|
|
lines: Array<{
|
|
dueDate: Date;
|
|
amount: number;
|
|
percent: number;
|
|
}>;
|
|
}
|
|
|
|
// ============================================================================
|
|
// SERVICE
|
|
// ============================================================================
|
|
|
|
class PaymentTermsService {
|
|
private repository: Repository<PaymentTerm>;
|
|
private lineRepository: Repository<PaymentTermLine>;
|
|
|
|
constructor() {
|
|
this.repository = AppDataSource.getRepository(PaymentTerm);
|
|
this.lineRepository = AppDataSource.getRepository(PaymentTermLine);
|
|
}
|
|
|
|
/**
|
|
* Calculate due date(s) based on payment term
|
|
*/
|
|
calculateDueDate(
|
|
paymentTerm: PaymentTerm,
|
|
invoiceDate: Date,
|
|
totalAmount: number
|
|
): DueDateResult {
|
|
logger.debug('Calculating due date', {
|
|
termCode: paymentTerm.code,
|
|
invoiceDate,
|
|
totalAmount,
|
|
});
|
|
|
|
const baseDate = new Date(invoiceDate);
|
|
const lines: DueDateResult['lines'] = [];
|
|
|
|
// If immediate payment
|
|
if (paymentTerm.isImmediate) {
|
|
return {
|
|
dueDate: baseDate,
|
|
discountDate: null,
|
|
discountAmount: 0,
|
|
lines: [{ dueDate: baseDate, amount: totalAmount, percent: 100 }],
|
|
};
|
|
}
|
|
|
|
// If payment term has lines, use them
|
|
if (paymentTerm.lines && paymentTerm.lines.length > 0) {
|
|
let remainingAmount = totalAmount;
|
|
let lastDueDate = baseDate;
|
|
|
|
for (const line of paymentTerm.lines.sort((a, b) => a.sequence - b.sequence)) {
|
|
let lineAmount = 0;
|
|
let linePercent = 0;
|
|
|
|
if (line.lineType === PaymentTermLineType.BALANCE) {
|
|
lineAmount = remainingAmount;
|
|
linePercent = (lineAmount / totalAmount) * 100;
|
|
} else if (line.lineType === PaymentTermLineType.PERCENT && line.valuePercent) {
|
|
linePercent = Number(line.valuePercent);
|
|
lineAmount = (totalAmount * linePercent) / 100;
|
|
} else if (line.lineType === PaymentTermLineType.FIXED && line.valueAmount) {
|
|
lineAmount = Math.min(Number(line.valueAmount), remainingAmount);
|
|
linePercent = (lineAmount / totalAmount) * 100;
|
|
}
|
|
|
|
const lineDueDate = this.calculateLineDueDate(baseDate, line);
|
|
lastDueDate = lineDueDate;
|
|
|
|
lines.push({
|
|
dueDate: lineDueDate,
|
|
amount: lineAmount,
|
|
percent: linePercent,
|
|
});
|
|
|
|
remainingAmount -= lineAmount;
|
|
}
|
|
|
|
// Calculate discount date if applicable
|
|
let discountDate: Date | null = null;
|
|
let discountAmount = 0;
|
|
|
|
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
|
|
discountDate = new Date(baseDate);
|
|
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
|
|
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
|
|
}
|
|
|
|
return {
|
|
dueDate: lastDueDate,
|
|
discountDate,
|
|
discountAmount,
|
|
lines,
|
|
};
|
|
}
|
|
|
|
// Simple due days calculation
|
|
const dueDate = new Date(baseDate);
|
|
dueDate.setDate(dueDate.getDate() + paymentTerm.dueDays);
|
|
|
|
let discountDate: Date | null = null;
|
|
let discountAmount = 0;
|
|
|
|
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
|
|
discountDate = new Date(baseDate);
|
|
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
|
|
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
|
|
}
|
|
|
|
return {
|
|
dueDate,
|
|
discountDate,
|
|
discountAmount,
|
|
lines: [{ dueDate, amount: totalAmount, percent: 100 }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate due date for a specific line
|
|
*/
|
|
private calculateLineDueDate(baseDate: Date, line: PaymentTermLine): Date {
|
|
const result = new Date(baseDate);
|
|
result.setDate(result.getDate() + line.days);
|
|
|
|
// If specific day of month
|
|
if (line.dayOfMonth) {
|
|
result.setDate(line.dayOfMonth);
|
|
// If the calculated date is before base + days, move to next month
|
|
const minDate = new Date(baseDate);
|
|
minDate.setDate(minDate.getDate() + line.days);
|
|
if (result < minDate) {
|
|
result.setMonth(result.getMonth() + 1);
|
|
}
|
|
}
|
|
|
|
// If end of month
|
|
if (line.endOfMonth) {
|
|
result.setMonth(result.getMonth() + 1);
|
|
result.setDate(0); // Last day of previous month
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get all payment terms for a tenant
|
|
*/
|
|
async findAll(tenantId: string, activeOnly: boolean = false): Promise<PaymentTerm[]> {
|
|
logger.debug('Finding all payment terms', { tenantId, activeOnly });
|
|
|
|
const query = this.repository
|
|
.createQueryBuilder('pt')
|
|
.leftJoinAndSelect('pt.lines', 'lines')
|
|
.where('pt.tenant_id = :tenantId', { tenantId })
|
|
.orderBy('pt.sequence', 'ASC')
|
|
.addOrderBy('pt.name', 'ASC');
|
|
|
|
if (activeOnly) {
|
|
query.andWhere('pt.is_active = :isActive', { isActive: true });
|
|
}
|
|
|
|
return query.getMany();
|
|
}
|
|
|
|
/**
|
|
* Get a specific payment term by ID
|
|
*/
|
|
async findById(id: string, tenantId: string): Promise<PaymentTerm> {
|
|
logger.debug('Finding payment term by id', { id, tenantId });
|
|
|
|
const paymentTerm = await this.repository.findOne({
|
|
where: { id, tenantId },
|
|
relations: ['lines'],
|
|
});
|
|
|
|
if (!paymentTerm) {
|
|
throw new NotFoundError('Término de pago no encontrado');
|
|
}
|
|
|
|
return paymentTerm;
|
|
}
|
|
|
|
/**
|
|
* Get a specific payment term by code
|
|
*/
|
|
async findByCode(code: string, tenantId: string): Promise<PaymentTerm | null> {
|
|
logger.debug('Finding payment term by code', { code, tenantId });
|
|
|
|
return this.repository.findOne({
|
|
where: { code, tenantId },
|
|
relations: ['lines'],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new payment term
|
|
*/
|
|
async create(
|
|
dto: CreatePaymentTermDto,
|
|
tenantId: string,
|
|
userId?: string
|
|
): Promise<PaymentTerm> {
|
|
logger.debug('Creating payment term', { dto, tenantId });
|
|
|
|
// Check for existing
|
|
const existing = await this.findByCode(dto.code, tenantId);
|
|
if (existing) {
|
|
throw new ConflictError(`Ya existe un término de pago con código ${dto.code}`);
|
|
}
|
|
|
|
// Normalize inputs (accept both snake_case and camelCase)
|
|
const dueDays = dto.due_days ?? dto.dueDays ?? 0;
|
|
const discountPercent = dto.discount_percent ?? dto.discountPercent ?? null;
|
|
const discountDays = dto.discount_days ?? dto.discountDays ?? null;
|
|
const isImmediate = dto.is_immediate ?? dto.isImmediate ?? false;
|
|
|
|
const paymentTerm = this.repository.create({
|
|
tenantId,
|
|
code: dto.code,
|
|
name: dto.name,
|
|
description: dto.description || null,
|
|
dueDays,
|
|
discountPercent,
|
|
discountDays,
|
|
isImmediate,
|
|
createdBy: userId || null,
|
|
});
|
|
|
|
const saved = await this.repository.save(paymentTerm);
|
|
|
|
// Create lines if provided
|
|
if (dto.lines && dto.lines.length > 0) {
|
|
await this.createLines(saved.id, dto.lines);
|
|
// Reload with lines
|
|
return this.findById(saved.id, tenantId);
|
|
}
|
|
|
|
logger.info('Payment term created', { id: saved.id, code: dto.code, tenantId });
|
|
|
|
return saved;
|
|
}
|
|
|
|
/**
|
|
* Create payment term lines
|
|
*/
|
|
private async createLines(
|
|
paymentTermId: string,
|
|
lines: CreatePaymentTermLineDto[]
|
|
): Promise<void> {
|
|
for (let index = 0; index < lines.length; index++) {
|
|
const line = lines[index];
|
|
const lineTypeRaw = line.line_type ?? line.lineType ?? 'balance';
|
|
const lineType = lineTypeRaw as PaymentTermLineType;
|
|
const valuePercent = line.value_percent ?? line.valuePercent ?? null;
|
|
const valueAmount = line.value_amount ?? line.valueAmount ?? null;
|
|
const dayOfMonth = line.day_of_month ?? line.dayOfMonth ?? null;
|
|
const endOfMonth = line.end_of_month ?? line.endOfMonth ?? false;
|
|
|
|
const lineEntity = this.lineRepository.create({
|
|
paymentTermId,
|
|
sequence: line.sequence ?? index + 1,
|
|
lineType,
|
|
valuePercent,
|
|
valueAmount,
|
|
days: line.days ?? 0,
|
|
dayOfMonth,
|
|
endOfMonth,
|
|
});
|
|
await this.lineRepository.save(lineEntity);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a payment term
|
|
*/
|
|
async update(
|
|
id: string,
|
|
dto: UpdatePaymentTermDto,
|
|
tenantId: string,
|
|
userId?: string
|
|
): Promise<PaymentTerm> {
|
|
logger.debug('Updating payment term', { id, dto, tenantId });
|
|
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
// Normalize inputs
|
|
const dueDays = dto.due_days ?? dto.dueDays;
|
|
const discountPercent = dto.discount_percent ?? dto.discountPercent;
|
|
const discountDays = dto.discount_days ?? dto.discountDays;
|
|
const isImmediate = dto.is_immediate ?? dto.isImmediate;
|
|
const isActive = dto.is_active ?? dto.isActive;
|
|
|
|
if (dto.name !== undefined) {
|
|
existing.name = dto.name;
|
|
}
|
|
if (dto.description !== undefined) {
|
|
existing.description = dto.description;
|
|
}
|
|
if (dueDays !== undefined) {
|
|
existing.dueDays = dueDays;
|
|
}
|
|
if (discountPercent !== undefined) {
|
|
existing.discountPercent = discountPercent;
|
|
}
|
|
if (discountDays !== undefined) {
|
|
existing.discountDays = discountDays;
|
|
}
|
|
if (isImmediate !== undefined) {
|
|
existing.isImmediate = isImmediate;
|
|
}
|
|
if (isActive !== undefined) {
|
|
existing.isActive = isActive;
|
|
}
|
|
|
|
existing.updatedBy = userId || null;
|
|
|
|
const updated = await this.repository.save(existing);
|
|
|
|
// Update lines if provided
|
|
if (dto.lines !== undefined) {
|
|
// Remove existing lines
|
|
await this.lineRepository.delete({ paymentTermId: id });
|
|
// Create new lines
|
|
if (dto.lines.length > 0) {
|
|
await this.createLines(id, dto.lines);
|
|
}
|
|
}
|
|
|
|
logger.info('Payment term updated', { id, tenantId });
|
|
|
|
return this.findById(id, tenantId);
|
|
}
|
|
|
|
/**
|
|
* Soft delete a payment term
|
|
*/
|
|
async delete(id: string, tenantId: string, userId?: string): Promise<void> {
|
|
logger.debug('Deleting payment term', { id, tenantId });
|
|
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
existing.deletedAt = new Date();
|
|
existing.deletedBy = userId || null;
|
|
|
|
await this.repository.save(existing);
|
|
|
|
logger.info('Payment term deleted', { id, tenantId });
|
|
}
|
|
|
|
/**
|
|
* Get common/standard payment terms
|
|
*/
|
|
getStandardTerms(): Array<{ code: string; name: string; dueDays: number; discountPercent?: number; discountDays?: number }> {
|
|
return [
|
|
{ code: 'IMMEDIATE', name: 'Pago Inmediato', dueDays: 0 },
|
|
{ code: 'NET15', name: 'Neto 15 días', dueDays: 15 },
|
|
{ code: 'NET30', name: 'Neto 30 días', dueDays: 30 },
|
|
{ code: 'NET45', name: 'Neto 45 días', dueDays: 45 },
|
|
{ code: 'NET60', name: 'Neto 60 días', dueDays: 60 },
|
|
{ code: 'NET90', name: 'Neto 90 días', dueDays: 90 },
|
|
{ code: '2/10NET30', name: '2% 10 días, Neto 30', dueDays: 30, discountPercent: 2, discountDays: 10 },
|
|
{ code: '1/10NET30', name: '1% 10 días, Neto 30', dueDays: 30, discountPercent: 1, discountDays: 10 },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Initialize standard payment terms for a tenant
|
|
*/
|
|
async initializeForTenant(tenantId: string, userId?: string): Promise<void> {
|
|
logger.debug('Initializing payment terms for tenant', { tenantId });
|
|
|
|
const standardTerms = this.getStandardTerms();
|
|
|
|
for (const term of standardTerms) {
|
|
const existing = await this.findByCode(term.code, tenantId);
|
|
if (!existing) {
|
|
await this.create(
|
|
{
|
|
code: term.code,
|
|
name: term.name,
|
|
dueDays: term.dueDays,
|
|
discountPercent: term.discountPercent,
|
|
discountDays: term.discountDays,
|
|
isImmediate: term.dueDays === 0,
|
|
},
|
|
tenantId,
|
|
userId
|
|
);
|
|
}
|
|
}
|
|
|
|
logger.info('Payment terms initialized for tenant', { tenantId, count: standardTerms.length });
|
|
}
|
|
}
|
|
|
|
export const paymentTermsService = new PaymentTermsService();
|