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:
parent
ffd5ffe56a
commit
d9778eb632
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user