- 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>
472 lines
13 KiB
TypeScript
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')}`;
|
|
}
|
|
}
|