From d9778eb63249eff2dc633ba643b923acf4efc869 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 02:36:06 -0600 Subject: [PATCH] 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 --- src/modules/billing-usage/entities/index.ts | 4 +- .../billing-usage/entities/invoice.entity.ts | 134 ++------------ .../services/invoices.service.ts | 5 +- src/modules/invoices/entities/index.ts | 2 +- .../invoices/entities/invoice.entity.ts | 169 +++++++++++++++--- src/modules/invoices/services/index.ts | 2 +- 6 files changed, 163 insertions(+), 153 deletions(-) diff --git a/src/modules/billing-usage/entities/index.ts b/src/modules/billing-usage/entities/index.ts index 7dc4e22..39d73c3 100644 --- a/src/modules/billing-usage/entities/index.ts +++ b/src/modules/billing-usage/entities/index.ts @@ -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'; diff --git a/src/modules/billing-usage/entities/invoice.entity.ts b/src/modules/billing-usage/entities/invoice.entity.ts index 557dadf..4c4c0b7 100644 --- a/src/modules/billing-usage/entities/invoice.entity.ts +++ b/src/modules/billing-usage/entities/invoice.entity.ts @@ -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; - - @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'; diff --git a/src/modules/billing-usage/services/invoices.service.ts b/src/modules/billing-usage/services/invoices.service.ts index ac56c9d..3a1b642 100644 --- a/src/modules/billing-usage/services/invoices.service.ts +++ b/src/modules/billing-usage/services/invoices.service.ts @@ -405,12 +405,15 @@ export class InvoicesService { const byStatus: Record = { 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; } } diff --git a/src/modules/invoices/entities/index.ts b/src/modules/invoices/entities/index.ts index 4e9cd0d..0474bbb 100644 --- a/src/modules/invoices/entities/index.ts +++ b/src/modules/invoices/entities/index.ts @@ -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'; diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 13ee790..139e766 100644 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -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 | 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[]; } diff --git a/src/modules/invoices/services/index.ts b/src/modules/invoices/services/index.ts index f41b370..c2e8ce4 100644 --- a/src/modules/invoices/services/index.ts +++ b/src/modules/invoices/services/index.ts @@ -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); }