erp-core-backend/src/modules/billing-usage/services/invoices.service.ts
rckrdmrd ca07b4268d feat: Add complete module structure for ERP backend
- Add modules: ai, audit, billing-usage, biometrics, branches, dashboard,
  feature-flags, invoices, mcp, mobile, notifications, partners,
  payment-terminals, products, profiles, purchases, reports, sales,
  storage, warehouses, webhooks, whatsapp
- Add controllers, DTOs, entities, and services for each module
- Add shared services and utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:40:54 -06:00

472 lines
13 KiB
TypeScript

/**
* Invoices Service
*
* Service for managing invoices
*/
import { Repository, DataSource, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { Invoice, InvoiceItem, InvoiceStatus, TenantSubscription, UsageTracking } from '../entities';
import {
CreateInvoiceDto,
UpdateInvoiceDto,
RecordPaymentDto,
VoidInvoiceDto,
RefundInvoiceDto,
GenerateInvoiceDto,
InvoiceFilterDto,
} from '../dto';
export class InvoicesService {
private invoiceRepository: Repository<Invoice>;
private itemRepository: Repository<InvoiceItem>;
private subscriptionRepository: Repository<TenantSubscription>;
private usageRepository: Repository<UsageTracking>;
constructor(private dataSource: DataSource) {
this.invoiceRepository = dataSource.getRepository(Invoice);
this.itemRepository = dataSource.getRepository(InvoiceItem);
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
this.usageRepository = dataSource.getRepository(UsageTracking);
}
/**
* Create invoice manually
*/
async create(dto: CreateInvoiceDto): Promise<Invoice> {
const invoiceNumber = await this.generateInvoiceNumber();
// Calculate totals
let subtotal = 0;
for (const item of dto.items) {
const itemTotal = item.quantity * item.unitPrice;
const discount = itemTotal * ((item.discountPercent || 0) / 100);
subtotal += itemTotal - discount;
}
const taxAmount = subtotal * 0.16; // 16% IVA for Mexico
const total = subtotal + taxAmount;
const invoice = this.invoiceRepository.create({
tenantId: dto.tenantId,
subscriptionId: dto.subscriptionId,
invoiceNumber,
invoiceDate: dto.invoiceDate || new Date(),
periodStart: dto.periodStart,
periodEnd: dto.periodEnd,
billingName: dto.billingName,
billingEmail: dto.billingEmail,
billingAddress: dto.billingAddress || {},
taxId: dto.taxId,
subtotal,
taxAmount,
discountAmount: 0,
total,
currency: dto.currency || 'MXN',
status: 'draft',
dueDate: dto.dueDate,
notes: dto.notes,
internalNotes: dto.internalNotes,
});
const savedInvoice = await this.invoiceRepository.save(invoice);
// Create items
for (const itemDto of dto.items) {
const itemTotal = itemDto.quantity * itemDto.unitPrice;
const discount = itemTotal * ((itemDto.discountPercent || 0) / 100);
const item = this.itemRepository.create({
invoiceId: savedInvoice.id,
itemType: itemDto.itemType,
description: itemDto.description,
quantity: itemDto.quantity,
unitPrice: itemDto.unitPrice,
discountPercent: itemDto.discountPercent || 0,
subtotal: itemTotal - discount,
metadata: itemDto.metadata || {},
});
await this.itemRepository.save(item);
}
return this.findById(savedInvoice.id) as Promise<Invoice>;
}
/**
* Generate invoice automatically from subscription
*/
async generateFromSubscription(dto: GenerateInvoiceDto): Promise<Invoice> {
const subscription = await this.subscriptionRepository.findOne({
where: { id: dto.subscriptionId },
relations: ['plan'],
});
if (!subscription) {
throw new Error('Subscription not found');
}
const items: CreateInvoiceDto['items'] = [];
// Base subscription fee
items.push({
itemType: 'subscription',
description: `Suscripcion ${subscription.plan.name} - ${subscription.billingCycle === 'annual' ? 'Anual' : 'Mensual'}`,
quantity: 1,
unitPrice: Number(subscription.currentPrice),
});
// Include usage charges if requested
if (dto.includeUsageCharges) {
const usage = await this.usageRepository.findOne({
where: {
tenantId: dto.tenantId,
periodStart: dto.periodStart,
},
});
if (usage) {
// Extra users
const extraUsers = Math.max(
0,
usage.activeUsers - (subscription.contractedUsers || subscription.plan.maxUsers)
);
if (extraUsers > 0) {
items.push({
itemType: 'overage',
description: `Usuarios adicionales (${extraUsers})`,
quantity: extraUsers,
unitPrice: 10, // $10 per extra user
metadata: { metric: 'extra_users' },
});
}
// Extra branches
const extraBranches = Math.max(
0,
usage.activeBranches - (subscription.contractedBranches || subscription.plan.maxBranches)
);
if (extraBranches > 0) {
items.push({
itemType: 'overage',
description: `Sucursales adicionales (${extraBranches})`,
quantity: extraBranches,
unitPrice: 20, // $20 per extra branch
metadata: { metric: 'extra_branches' },
});
}
// Extra storage
const extraStorageGb = Math.max(
0,
Number(usage.storageUsedGb) - subscription.plan.storageGb
);
if (extraStorageGb > 0) {
items.push({
itemType: 'overage',
description: `Almacenamiento adicional (${extraStorageGb} GB)`,
quantity: Math.ceil(extraStorageGb),
unitPrice: 0.5, // $0.50 per GB
metadata: { metric: 'extra_storage' },
});
}
}
}
// Calculate due date (15 days from invoice date)
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + 15);
return this.create({
tenantId: dto.tenantId,
subscriptionId: dto.subscriptionId,
periodStart: dto.periodStart,
periodEnd: dto.periodEnd,
billingName: subscription.billingName,
billingEmail: subscription.billingEmail,
billingAddress: subscription.billingAddress,
taxId: subscription.taxId,
dueDate,
items,
});
}
/**
* Find invoice by ID
*/
async findById(id: string): Promise<Invoice | null> {
return this.invoiceRepository.findOne({
where: { id },
relations: ['items'],
});
}
/**
* Find invoice by number
*/
async findByNumber(invoiceNumber: string): Promise<Invoice | null> {
return this.invoiceRepository.findOne({
where: { invoiceNumber },
relations: ['items'],
});
}
/**
* Find invoices with filters
*/
async findAll(filter: InvoiceFilterDto): Promise<{ data: Invoice[]; total: number }> {
const query = this.invoiceRepository
.createQueryBuilder('invoice')
.leftJoinAndSelect('invoice.items', 'items');
if (filter.tenantId) {
query.andWhere('invoice.tenantId = :tenantId', { tenantId: filter.tenantId });
}
if (filter.status) {
query.andWhere('invoice.status = :status', { status: filter.status });
}
if (filter.dateFrom) {
query.andWhere('invoice.invoiceDate >= :dateFrom', { dateFrom: filter.dateFrom });
}
if (filter.dateTo) {
query.andWhere('invoice.invoiceDate <= :dateTo', { dateTo: filter.dateTo });
}
if (filter.overdue) {
query.andWhere('invoice.dueDate < :now', { now: new Date() });
query.andWhere("invoice.status IN ('sent', 'partial')");
}
const total = await query.getCount();
query.orderBy('invoice.invoiceDate', 'DESC');
if (filter.limit) {
query.take(filter.limit);
}
if (filter.offset) {
query.skip(filter.offset);
}
const data = await query.getMany();
return { data, total };
}
/**
* Update invoice
*/
async update(id: string, dto: UpdateInvoiceDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status !== 'draft') {
throw new Error('Only draft invoices can be updated');
}
Object.assign(invoice, dto);
return this.invoiceRepository.save(invoice);
}
/**
* Send invoice
*/
async send(id: string): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status !== 'draft') {
throw new Error('Only draft invoices can be sent');
}
invoice.status = 'sent';
// TODO: Send email notification to billing email
return this.invoiceRepository.save(invoice);
}
/**
* Record payment
*/
async recordPayment(id: string, dto: RecordPaymentDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status === 'void' || invoice.status === 'refunded') {
throw new Error('Cannot record payment for voided or refunded invoice');
}
const newPaidAmount = Number(invoice.paidAmount) + dto.amount;
const total = Number(invoice.total);
invoice.paidAmount = newPaidAmount;
invoice.paymentMethod = dto.paymentMethod;
invoice.paymentReference = dto.paymentReference;
if (newPaidAmount >= total) {
invoice.status = 'paid';
invoice.paidAt = dto.paymentDate || new Date();
} else if (newPaidAmount > 0) {
invoice.status = 'partial';
}
return this.invoiceRepository.save(invoice);
}
/**
* Void invoice
*/
async void(id: string, dto: VoidInvoiceDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status === 'paid' || invoice.status === 'refunded') {
throw new Error('Cannot void paid or refunded invoice');
}
invoice.status = 'void';
invoice.internalNotes = `${invoice.internalNotes || ''}\n\nVoided: ${dto.reason}`.trim();
return this.invoiceRepository.save(invoice);
}
/**
* Refund invoice
*/
async refund(id: string, dto: RefundInvoiceDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status !== 'paid' && invoice.status !== 'partial') {
throw new Error('Only paid invoices can be refunded');
}
const refundAmount = dto.amount || Number(invoice.paidAmount);
if (refundAmount > Number(invoice.paidAmount)) {
throw new Error('Refund amount cannot exceed paid amount');
}
invoice.status = 'refunded';
invoice.internalNotes =
`${invoice.internalNotes || ''}\n\nRefunded: ${refundAmount} - ${dto.reason}`.trim();
// TODO: Process actual refund through payment provider
return this.invoiceRepository.save(invoice);
}
/**
* Mark overdue invoices
*/
async markOverdueInvoices(): Promise<number> {
const now = new Date();
const result = await this.invoiceRepository
.createQueryBuilder()
.update(Invoice)
.set({ status: 'overdue' })
.where("status IN ('sent', 'partial')")
.andWhere('dueDate < :now', { now })
.execute();
return result.affected || 0;
}
/**
* Get invoice statistics
*/
async getStats(tenantId?: string): Promise<{
total: number;
byStatus: Record<InvoiceStatus, number>;
totalRevenue: number;
pendingAmount: number;
overdueAmount: number;
}> {
const query = this.invoiceRepository.createQueryBuilder('invoice');
if (tenantId) {
query.where('invoice.tenantId = :tenantId', { tenantId });
}
const invoices = await query.getMany();
const byStatus: Record<InvoiceStatus, number> = {
draft: 0,
sent: 0,
paid: 0,
partial: 0,
overdue: 0,
void: 0,
refunded: 0,
};
let totalRevenue = 0;
let pendingAmount = 0;
let overdueAmount = 0;
const now = new Date();
for (const invoice of invoices) {
byStatus[invoice.status]++;
if (invoice.status === 'paid') {
totalRevenue += Number(invoice.paidAmount);
}
if (invoice.status === 'sent' || invoice.status === 'partial') {
const pending = Number(invoice.total) - Number(invoice.paidAmount);
pendingAmount += pending;
if (invoice.dueDate < now) {
overdueAmount += pending;
}
}
}
return {
total: invoices.length,
byStatus,
totalRevenue,
pendingAmount,
overdueAmount,
};
}
/**
* Generate unique invoice number
*/
private async generateInvoiceNumber(): Promise<string> {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
// Get last invoice number for this month
const lastInvoice = await this.invoiceRepository
.createQueryBuilder('invoice')
.where('invoice.invoiceNumber LIKE :pattern', { pattern: `INV-${year}${month}%` })
.orderBy('invoice.invoiceNumber', 'DESC')
.getOne();
let sequence = 1;
if (lastInvoice) {
const lastSequence = parseInt(lastInvoice.invoiceNumber.slice(-4), 10);
sequence = lastSequence + 1;
}
return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`;
}
}