feat(invoices): Unify billing and commercial Invoice entities

- Create unified Invoice entity in invoices module with all fields:
  - Commercial fields: salesOrderId, purchaseOrderId, partnerId
  - SaaS fields: subscriptionId, periodStart, periodEnd
  - Context discriminator: invoiceContext ('commercial' | 'saas')
- Mark billing-usage/invoice.entity.ts as deprecated, re-export from invoices
- Update billing-usage/services/invoices.service.ts for unified entity
- Add missing InvoiceStatus values (validated, cancelled, voided)
- Export types from both modules for backward compatibility

Note: financial/invoice.entity.ts remains separate (different schema)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 02:36:06 -06:00
parent ffd5ffe56a
commit d9778eb632
6 changed files with 163 additions and 153 deletions

View File

@ -2,8 +2,8 @@ export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
export { UsageTracking } from './usage-tracking.entity';
export { UsageEvent, EventCategory } from './usage-event.entity';
export { Invoice, InvoiceStatus } from './invoice.entity';
export { InvoiceItem, InvoiceItemType } from './invoice-item.entity';
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity';
export { InvoiceItemType } from './invoice-item.entity';
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
export { PlanFeature } from './plan-feature.entity';

View File

@ -1,121 +1,17 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { InvoiceItem } from './invoice-item.entity';
/**
* @deprecated Use Invoice from 'modules/invoices/entities' instead.
*
* This entity has been unified with the commercial Invoice entity.
* Both SaaS billing and commercial invoices now use the same table.
*
* Migration guide:
* - Import from: import { Invoice, InvoiceStatus, InvoiceContext } from '../../invoices/entities/invoice.entity';
* - Set invoiceContext: 'saas' for SaaS billing invoices
* - Use subscriptionId, periodStart, periodEnd for SaaS-specific fields
*/
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded';
// Re-export from unified invoice entity
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType } from '../../invoices/entities/invoice.entity';
@Entity({ name: 'invoices', schema: 'billing' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId: string;
// Numero de factura
@Index({ unique: true })
@Column({ name: 'invoice_number', type: 'varchar', length: 30 })
invoiceNumber: string;
@Index()
@Column({ name: 'invoice_date', type: 'date' })
invoiceDate: Date;
// Periodo facturado
@Column({ name: 'period_start', type: 'date' })
periodStart: Date;
@Column({ name: 'period_end', type: 'date' })
periodEnd: Date;
// Cliente
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
billingName: string;
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
billingEmail: string;
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
billingAddress: Record<string, any>;
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
taxId: string;
// Montos
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
discountAmount: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
total: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
// Estado
@Index()
@Column({ type: 'varchar', length: 20, default: 'draft' })
status: InvoiceStatus;
// Fechas de pago
@Index()
@Column({ name: 'due_date', type: 'date' })
dueDate: Date;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ name: 'paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
paidAmount: number;
// Detalles de pago
@Column({ name: 'payment_method', type: 'varchar', length: 30, nullable: true })
paymentMethod: string;
@Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true })
paymentReference: string;
// CFDI (para Mexico)
@Column({ name: 'cfdi_uuid', type: 'varchar', length: 36, nullable: true })
cfdiUuid: string;
@Column({ name: 'cfdi_xml', type: 'text', nullable: true })
cfdiXml: string;
@Column({ name: 'cfdi_pdf_url', type: 'text', nullable: true })
cfdiPdfUrl: string;
// Metadata
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'internal_notes', type: 'text', nullable: true })
internalNotes: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true })
items: InvoiceItem[];
}
// Re-export InvoiceItem as well since it's used together
export { InvoiceItem } from './invoice-item.entity';

View File

@ -405,12 +405,15 @@ export class InvoicesService {
const byStatus: Record<InvoiceStatus, number> = {
draft: 0,
validated: 0,
sent: 0,
paid: 0,
partial: 0,
overdue: 0,
void: 0,
refunded: 0,
cancelled: 0,
voided: 0,
};
let totalRevenue = 0;
@ -429,7 +432,7 @@ export class InvoicesService {
const pending = Number(invoice.total) - Number(invoice.paidAmount);
pendingAmount += pending;
if (invoice.dueDate < now) {
if (invoice.dueDate && invoice.dueDate < now) {
overdueAmount += pending;
}
}

View File

@ -1,4 +1,4 @@
export { Invoice } from './invoice.entity';
export { Invoice, InvoiceType, InvoiceStatus, InvoiceContext } from './invoice.entity';
export { InvoiceItem } from './invoice-item.entity';
export { Payment } from './payment.entity';
export { PaymentAllocation } from './payment-allocation.entity';

View File

@ -1,4 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { InvoiceItem } from './invoice-item.entity';
/**
* Unified Invoice Entity
*
* Combines fields from commercial invoices and SaaS billing invoices.
* Schema: billing
*
* Context discriminator:
* - 'commercial': Sales/purchase invoices (salesOrderId, purchaseOrderId, partnerId)
* - 'saas': SaaS subscription invoices (subscriptionId, periodStart, periodEnd)
*/
export type InvoiceType = 'sale' | 'purchase' | 'credit_note' | 'debit_note';
export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided';
export type InvoiceContext = 'commercial' | 'saas';
@Entity({ name: 'invoices', schema: 'billing' })
export class Invoice {
@ -9,41 +34,92 @@ export class Invoice {
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
// ============================================
// IDENTIFICATION
// ============================================
@Index({ unique: true })
@Column({ name: 'invoice_number', type: 'varchar', length: 30 })
invoiceNumber: string;
@Index()
@Column({ name: 'invoice_type', type: 'varchar', length: 20, default: 'sale' })
invoiceType: 'sale' | 'purchase' | 'credit_note' | 'debit_note';
@Column({ name: 'sales_order_id', type: 'uuid', nullable: true })
salesOrderId: string;
@Column({ name: 'purchase_order_id', type: 'uuid', nullable: true })
purchaseOrderId: string;
invoiceType: InvoiceType;
@Index()
@Column({ name: 'partner_id', type: 'uuid' })
partnerId: string;
@Column({ name: 'invoice_context', type: 'varchar', length: 20, default: 'commercial' })
invoiceContext: InvoiceContext;
// ============================================
// COMMERCIAL INVOICE FIELDS
// ============================================
@Column({ name: 'sales_order_id', type: 'uuid', nullable: true })
salesOrderId: string | null;
@Column({ name: 'purchase_order_id', type: 'uuid', nullable: true })
purchaseOrderId: string | null;
@Index()
@Column({ name: 'partner_id', type: 'uuid', nullable: true })
partnerId: string | null;
@Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true })
partnerName: string;
partnerName: string | null;
@Column({ name: 'partner_tax_id', type: 'varchar', length: 50, nullable: true })
partnerTaxId: string;
partnerTaxId: string | null;
// ============================================
// SAAS BILLING FIELDS
// ============================================
@Index()
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId: string | null;
@Column({ name: 'period_start', type: 'date', nullable: true })
periodStart: Date | null;
@Column({ name: 'period_end', type: 'date', nullable: true })
periodEnd: Date | null;
// ============================================
// BILLING INFORMATION
// ============================================
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
billingName: string | null;
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
billingEmail: string | null;
@Column({ name: 'billing_address', type: 'jsonb', nullable: true })
billingAddress: object;
billingAddress: Record<string, any> | null;
@Column({ name: 'tax_id', type: 'varchar', length: 50, nullable: true })
taxId: string | null;
// ============================================
// DATES
// ============================================
@Index()
@Column({ name: 'invoice_date', type: 'date', default: () => 'CURRENT_DATE' })
invoiceDate: Date;
@Column({ name: 'due_date', type: 'date', nullable: true })
dueDate: Date;
dueDate: Date | null;
@Column({ name: 'payment_date', type: 'date', nullable: true })
paymentDate: Date;
paymentDate: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date | null;
// ============================================
// AMOUNTS
// ============================================
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
@ -69,50 +145,85 @@ export class Invoice {
@Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 })
amountPaid: number;
@Column({ name: 'amount_due', type: 'decimal', precision: 15, scale: 2, insert: false, update: false })
amountDue: number;
// Computed or handled by DB trigger
@Column({ name: 'amount_due', type: 'decimal', precision: 15, scale: 2, insert: false, update: false, nullable: true })
amountDue: number | null;
// Alias for SaaS compatibility
@Column({ name: 'paid_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
paidAmount: number;
// ============================================
// PAYMENT DETAILS
// ============================================
@Column({ name: 'payment_term_days', type: 'int', default: 0 })
paymentTermDays: number;
@Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true })
paymentMethod: string;
paymentMethod: string | null;
@Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true })
paymentReference: string | null;
// ============================================
// STATUS
// ============================================
@Index()
@Column({ type: 'varchar', length: 20, default: 'draft' })
status: 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'cancelled' | 'voided';
status: InvoiceStatus;
// ============================================
// CFDI (Mexico Electronic Invoice)
// ============================================
@Index()
@Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true })
cfdiUuid: string;
cfdiUuid: string | null;
@Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true })
cfdiStatus: string;
cfdiStatus: string | null;
@Column({ name: 'cfdi_xml', type: 'text', nullable: true })
cfdiXml: string;
cfdiXml: string | null;
@Column({ name: 'cfdi_pdf_url', type: 'varchar', length: 500, nullable: true })
cfdiPdfUrl: string;
cfdiPdfUrl: string | null;
// ============================================
// NOTES
// ============================================
@Column({ type: 'text', nullable: true })
notes: string;
notes: string | null;
@Column({ name: 'internal_notes', type: 'text', nullable: true })
internalNotes: string;
internalNotes: string | null;
// ============================================
// AUDIT
// ============================================
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
deletedAt: Date | null;
// ============================================
// RELATIONS
// ============================================
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true })
items: InvoiceItem[];
}

View File

@ -55,7 +55,7 @@ export class InvoicesService {
const invoice = await this.findInvoice(id, tenantId);
if (!invoice || invoice.status !== 'draft') return null;
invoice.status = 'validated';
invoice.updatedBy = userId;
invoice.updatedBy = userId ?? null;
return this.invoiceRepository.save(invoice);
}