Some checks failed
ERP Core CI / Backend Lint (push) Has been cancelled
ERP Core CI / Backend Unit Tests (push) Has been cancelled
ERP Core CI / Backend Integration Tests (push) Has been cancelled
ERP Core CI / Frontend Lint (push) Has been cancelled
ERP Core CI / Frontend Unit Tests (push) Has been cancelled
ERP Core CI / Frontend E2E Tests (push) Has been cancelled
ERP Core CI / Database DDL Validation (push) Has been cancelled
ERP Core CI / Backend Build (push) Has been cancelled
ERP Core CI / Frontend Build (push) Has been cancelled
ERP Core CI / CI Success (push) Has been cancelled
Performance Tests / Lighthouse CI (push) Has been cancelled
Performance Tests / Bundle Size Analysis (push) Has been cancelled
Performance Tests / k6 Load Tests (push) Has been cancelled
Performance Tests / Performance Summary (push) Has been cancelled
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8 - Actualizaciones en modulos CRM y OpenAPI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
90 KiB
90 KiB
ET-FIN-BACKEND: Servicios y API REST
Identificacion
| Campo | Valor |
|---|---|
| ID | ET-FIN-BACKEND |
| Modulo | MGN-010 Financial |
| Version | 2.0 |
| Estado | En Diseno |
| Framework | NestJS |
| Autor | Requirements-Analyst |
| Fecha | 2026-01-10 |
Estructura de Archivos
apps/backend/src/modules/financial/
├── financial.module.ts
├── controllers/
│ ├── charts.controller.ts
│ ├── accounts.controller.ts
│ ├── currencies.controller.ts
│ ├── fiscal-years.controller.ts
│ ├── fiscal-periods.controller.ts
│ ├── journal.controller.ts
│ └── cost-centers.controller.ts
├── services/
│ ├── charts.service.ts
│ ├── accounts.service.ts
│ ├── currencies.service.ts
│ ├── exchange-rates.service.ts
│ ├── fiscal-years.service.ts
│ ├── fiscal-periods.service.ts
│ ├── journal.service.ts
│ ├── cost-centers.service.ts
│ ├── invoices.service.ts
│ ├── payments.service.ts
│ ├── payment-methods.service.ts
│ ├── payment-terms.service.ts
│ ├── journal-entries.service.ts
│ ├── reconcile-models.service.ts
│ ├── taxes.service.ts
│ └── incoterms.service.ts
├── entities/
│ ├── account-type.entity.ts
│ ├── chart-of-accounts.entity.ts
│ ├── account.entity.ts
│ ├── tenant-currency.entity.ts
│ ├── exchange-rate.entity.ts
│ ├── fiscal-year.entity.ts
│ ├── fiscal-period.entity.ts
│ ├── journal-entry.entity.ts
│ ├── journal-entry-line.entity.ts
│ ├── journal-line.entity.ts
│ ├── cost-center.entity.ts
│ ├── invoice.entity.ts
│ ├── invoice-line.entity.ts
│ ├── payment.entity.ts
│ ├── payment-invoice.entity.ts
│ ├── payment-method.entity.ts
│ ├── payment-term.entity.ts
│ ├── payment-term-line.entity.ts
│ ├── reconcile-model.entity.ts
│ ├── reconcile-model-line.entity.ts
│ ├── tax.entity.ts
│ └── incoterm.entity.ts
├── dto/
│ ├── create-account.dto.ts
│ ├── create-journal-entry.dto.ts
│ ├── convert-currency.dto.ts
│ └── close-period.dto.ts
└── interfaces/
├── account-balance.interface.ts
└── trial-balance.interface.ts
Entidades
Account Entity
@Entity('accounts', { schema: 'core_financial' })
@Tree('materialized-path')
export class Account {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'chart_id', type: 'uuid' })
chartId: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId: string;
@Column({ name: 'account_type_id', type: 'uuid' })
accountTypeId: string;
@Column({ length: 20 })
code: string;
@Column({ length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ default: 1 })
level: number;
@Column({ name: 'is_detail', default: true })
isDetail: boolean;
@Column({ name: 'is_bank', default: false })
isBank: boolean;
@Column({ name: 'is_cash', default: false })
isCash: boolean;
@Column({ name: 'currency_code', length: 3, nullable: true })
currencyCode: string;
@Column({ name: 'opening_balance', type: 'decimal', precision: 18, scale: 4, default: 0 })
openingBalance: number;
@Column({ name: 'current_balance', type: 'decimal', precision: 18, scale: 4, default: 0 })
currentBalance: number;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => ChartOfAccounts)
@JoinColumn({ name: 'chart_id' })
chart: ChartOfAccounts;
@ManyToOne(() => Account)
@JoinColumn({ name: 'parent_id' })
parent: Account;
@OneToMany(() => Account, a => a.parent)
children: Account[];
@ManyToOne(() => AccountType)
@JoinColumn({ name: 'account_type_id' })
accountType: AccountType;
}
JournalEntry Entity
@Entity('journal_entries', { schema: 'core_financial' })
export class JournalEntry {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'fiscal_period_id', type: 'uuid' })
fiscalPeriodId: string;
@Column({ name: 'entry_number', length: 20 })
entryNumber: string;
@Column({ name: 'entry_date', type: 'date' })
entryDate: Date;
@Column({ name: 'currency_code', length: 3 })
currencyCode: string;
@Column({ name: 'exchange_rate', type: 'decimal', precision: 18, scale: 8, default: 1 })
exchangeRate: number;
@Column({ length: 100, nullable: true })
reference: string;
@Column({ type: 'text' })
description: string;
@Column({ name: 'source_module', length: 50, nullable: true })
sourceModule: string;
@Column({ name: 'source_document_id', type: 'uuid', nullable: true })
sourceDocumentId: string;
@Column({ length: 20, default: 'draft' })
status: JournalStatus;
@Column({ name: 'total_debit', type: 'decimal', precision: 18, scale: 4, default: 0 })
totalDebit: number;
@Column({ name: 'total_credit', type: 'decimal', precision: 18, scale: 4, default: 0 })
totalCredit: number;
@Column({ name: 'posted_at', type: 'timestamptz', nullable: true })
postedAt: Date;
@Column({ name: 'posted_by', type: 'uuid', nullable: true })
postedBy: string;
@Column({ name: 'reversed_by', type: 'uuid', nullable: true })
reversedBy: string;
@Column({ name: 'created_by', type: 'uuid' })
createdBy: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => FiscalPeriod)
@JoinColumn({ name: 'fiscal_period_id' })
fiscalPeriod: FiscalPeriod;
@OneToMany(() => JournalLine, l => l.journalEntry, { cascade: true })
lines: JournalLine[];
}
export type JournalStatus = 'draft' | 'posted' | 'reversed';
JournalLine Entity
@Entity('journal_lines', { schema: 'core_financial' })
export class JournalLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'journal_entry_id', type: 'uuid' })
journalEntryId: string;
@Column({ name: 'line_number' })
lineNumber: number;
@Column({ name: 'account_id', type: 'uuid' })
accountId: string;
@Column({ name: 'cost_center_id', type: 'uuid', nullable: true })
costCenterId: string;
@Column({ type: 'decimal', precision: 18, scale: 4, default: 0 })
debit: number;
@Column({ type: 'decimal', precision: 18, scale: 4, default: 0 })
credit: number;
@Column({ name: 'debit_base', type: 'decimal', precision: 18, scale: 4, default: 0 })
debitBase: number;
@Column({ name: 'credit_base', type: 'decimal', precision: 18, scale: 4, default: 0 })
creditBase: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 100, nullable: true })
reference: string;
@ManyToOne(() => JournalEntry, e => e.lines)
@JoinColumn({ name: 'journal_entry_id' })
journalEntry: JournalEntry;
@ManyToOne(() => Account)
@JoinColumn({ name: 'account_id' })
account: Account;
@ManyToOne(() => CostCenter)
@JoinColumn({ name: 'cost_center_id' })
costCenter: CostCenter;
}
Invoice Entity
export enum InvoiceType {
CUSTOMER = 'customer',
SUPPLIER = 'supplier',
}
export enum InvoiceStatus {
DRAFT = 'draft',
OPEN = 'open',
PAID = 'paid',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'financial', name: 'invoices' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', name: 'company_id' })
companyId: string;
@Column({ type: 'uuid', name: 'partner_id' })
partnerId: string;
@Column({ type: 'enum', enum: InvoiceType, name: 'invoice_type' })
invoiceType: InvoiceType;
@Column({ type: 'varchar', length: 100, nullable: true })
number: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
ref: string | null;
@Column({ type: 'date', name: 'invoice_date' })
invoiceDate: Date;
@Column({ type: 'date', nullable: true, name: 'due_date' })
dueDate: Date | null;
@Column({ type: 'uuid', name: 'currency_id' })
currencyId: string;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_untaxed' })
amountUntaxed: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_tax' })
amountTax: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_total' })
amountTotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_paid' })
amountPaid: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_residual' })
amountResidual: number;
@Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.DRAFT })
status: InvoiceStatus;
@Column({ type: 'uuid', nullable: true, name: 'payment_term_id' })
paymentTermId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'journal_id' })
journalId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
journalEntryId: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
@OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
lines: InvoiceLine[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt: Date | null;
}
InvoiceLine Entity
@Entity({ schema: 'financial', name: 'invoice_lines' })
export class InvoiceLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'invoice_id' })
invoiceId: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'product_id' })
productId: string | null;
@Column({ type: 'text' })
description: string;
@Column({ type: 'decimal', precision: 15, scale: 4 })
quantity: number;
@Column({ type: 'uuid', nullable: true, name: 'uom_id' })
uomId: string | null;
@Column({ type: 'decimal', precision: 15, scale: 2, name: 'price_unit' })
priceUnit: number;
@Column({ type: 'uuid', array: true, default: '{}', name: 'tax_ids' })
taxIds: string[];
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_untaxed' })
amountUntaxed: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_tax' })
amountTax: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'amount_total' })
amountTotal: number;
@Column({ type: 'uuid', nullable: true, name: 'account_id' })
accountId: string | null;
@ManyToOne(() => Invoice, (invoice) => invoice.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice: Invoice;
@ManyToOne(() => Account)
@JoinColumn({ name: 'account_id' })
account: Account | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Payment Entity
export enum PaymentType {
INBOUND = 'inbound',
OUTBOUND = 'outbound',
}
export enum PaymentMethod {
CASH = 'cash',
BANK_TRANSFER = 'bank_transfer',
CHECK = 'check',
CARD = 'card',
OTHER = 'other',
}
export enum PaymentStatus {
DRAFT = 'draft',
POSTED = 'posted',
RECONCILED = 'reconciled',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'financial', name: 'payments' })
export class Payment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', name: 'company_id' })
companyId: string;
@Column({ type: 'uuid', name: 'partner_id' })
partnerId: string;
@Column({ type: 'enum', enum: PaymentType, name: 'payment_type' })
paymentType: PaymentType;
@Column({ type: 'enum', enum: PaymentMethod, name: 'payment_method' })
paymentMethod: PaymentMethod;
@Column({ type: 'decimal', precision: 15, scale: 2 })
amount: number;
@Column({ type: 'uuid', name: 'currency_id' })
currencyId: string;
@Column({ type: 'date', name: 'payment_date' })
paymentDate: Date;
@Column({ type: 'varchar', length: 255, nullable: true })
ref: string | null;
@Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.DRAFT })
status: PaymentStatus;
@Column({ type: 'uuid', name: 'journal_id' })
journalId: string;
@Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
journalEntryId: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
postedAt: Date | null;
}
PaymentInvoice Entity
@Entity({ schema: 'financial', name: 'payment_invoice' })
export class PaymentInvoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'payment_id' })
paymentId: string;
@Column({ type: 'uuid', name: 'invoice_id' })
invoiceId: string;
@Column({ type: 'decimal', precision: 15, scale: 2 })
amount: number;
@ManyToOne(() => Payment)
@JoinColumn({ name: 'payment_id' })
payment: Payment;
@ManyToOne(() => Invoice)
@JoinColumn({ name: 'invoice_id' })
invoice: Invoice;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
PaymentMethodCatalog Entity
@Entity({ schema: 'financial', name: 'payment_methods' })
export class PaymentMethodCatalog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ type: 'varchar', length: 20, name: 'payment_type' })
paymentType: string; // 'inbound' | 'outbound'
@Column({ type: 'boolean', default: true, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
PaymentTerm Entity
@Entity({ schema: 'financial', name: 'payment_terms' })
export class PaymentTerm {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', name: 'company_id' })
companyId: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'jsonb', default: '[]' })
terms: object;
@Column({ type: 'boolean', default: true })
active: boolean;
@OneToMany(() => PaymentTermLine, (line) => line.paymentTerm, { cascade: true })
lines: PaymentTermLine[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt: Date | null;
}
PaymentTermLine Entity
export type PaymentTermValue = 'balance' | 'percent' | 'fixed';
@Entity({ schema: 'financial', name: 'payment_term_lines' })
export class PaymentTermLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'payment_term_id' })
paymentTermId: string;
@Column({ type: 'varchar', length: 20, default: 'balance' })
value: PaymentTermValue;
@Column({ type: 'decimal', precision: 20, scale: 6, name: 'value_amount', default: 0 })
valueAmount: number;
@Column({ type: 'int', name: 'nb_days', default: 0 })
nbDays: number;
@Column({ type: 'varchar', length: 20, name: 'delay_type', default: 'days_after' })
delayType: string;
@Column({ type: 'int', name: 'day_of_the_month', nullable: true })
dayOfTheMonth: number | null;
@Column({ type: 'int', default: 10 })
sequence: number;
@ManyToOne(() => PaymentTerm, (term) => term.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'payment_term_id' })
paymentTerm: PaymentTerm;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
JournalEntryV2 Entity
export enum EntryStatus {
DRAFT = 'draft',
POSTED = 'posted',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'financial', name: 'journal_entries' })
export class JournalEntryV2 {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', name: 'company_id' })
companyId: string;
@Column({ type: 'uuid', name: 'journal_id' })
journalId: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 255, nullable: true })
ref: string | null;
@Column({ type: 'date' })
date: Date;
@Column({ type: 'enum', enum: EntryStatus, default: EntryStatus.DRAFT })
status: EntryStatus;
@Column({ type: 'text', nullable: true })
notes: string | null;
@Column({ type: 'uuid', nullable: true, name: 'fiscal_period_id' })
fiscalPeriodId: string | null;
@OneToMany(() => JournalEntryLine, (line) => line.entry, { cascade: true })
lines: JournalEntryLine[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
postedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
postedBy: string | null;
}
JournalEntryLine Entity
@Entity({ schema: 'financial', name: 'journal_entry_lines' })
export class JournalEntryLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'entry_id' })
entryId: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', name: 'account_id' })
accountId: string;
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
partnerId: string | null;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
debit: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
credit: number;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
ref: string | null;
@ManyToOne(() => JournalEntryV2, (entry) => entry.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'entry_id' })
entry: JournalEntryV2;
@ManyToOne(() => Account)
@JoinColumn({ name: 'account_id' })
account: Account;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
ReconcileModel Entity
export type ReconcileModelType = 'writeoff_button' | 'writeoff_suggestion' | 'invoice_matching';
@Entity({ schema: 'financial', name: 'reconcile_models' })
export class ReconcileModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'int', default: 10 })
sequence: number;
@Column({ type: 'varchar', length: 30, name: 'rule_type', default: 'writeoff_button' })
ruleType: ReconcileModelType;
@Column({ type: 'boolean', name: 'auto_reconcile', default: false })
autoReconcile: boolean;
@Column({ type: 'varchar', length: 20, name: 'match_nature', default: 'both' })
matchNature: string;
@Column({ type: 'varchar', length: 20, name: 'match_amount', default: 'any' })
matchAmount: string;
@Column({ type: 'decimal', precision: 20, scale: 6, name: 'match_amount_min', nullable: true })
matchAmountMin: number | null;
@Column({ type: 'decimal', precision: 20, scale: 6, name: 'match_amount_max', nullable: true })
matchAmountMax: number | null;
@Column({ type: 'varchar', length: 50, name: 'match_label', nullable: true })
matchLabel: string | null;
@Column({ type: 'varchar', length: 255, name: 'match_label_param', nullable: true })
matchLabelParam: string | null;
@Column({ type: 'boolean', name: 'match_partner', default: false })
matchPartner: boolean;
@Column({ type: 'uuid', array: true, name: 'match_partner_ids', nullable: true })
matchPartnerIds: string[] | null;
@Column({ type: 'boolean', name: 'is_active', default: true })
isActive: boolean;
@Column({ type: 'uuid', name: 'company_id', nullable: true })
companyId: string | null;
@OneToMany(() => ReconcileModelLine, (line) => line.reconcileModel, { cascade: true })
lines: ReconcileModelLine[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt: Date | null;
}
ReconcileModelLine Entity
@Entity({ schema: 'financial', name: 'reconcile_model_lines' })
export class ReconcileModelLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'model_id' })
modelId: string;
@Column({ type: 'int', default: 10 })
sequence: number;
@Column({ type: 'uuid', name: 'account_id' })
accountId: string;
@Column({ type: 'uuid', name: 'journal_id', nullable: true })
journalId: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
label: string | null;
@Column({ type: 'varchar', length: 20, name: 'amount_type', default: 'percentage' })
amountType: string;
@Column({ type: 'decimal', precision: 20, scale: 6, name: 'amount_value', default: 100 })
amountValue: number;
@Column({ type: 'uuid', array: true, name: 'tax_ids', nullable: true })
taxIds: string[] | null;
@Column({ type: 'uuid', name: 'analytic_account_id', nullable: true })
analyticAccountId: string | null;
@ManyToOne(() => ReconcileModel, (model) => model.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'model_id' })
reconcileModel: ReconcileModel;
@ManyToOne(() => Account)
@JoinColumn({ name: 'account_id' })
account: Account;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Tax Entity
export enum TaxType {
SALES = 'sales',
PURCHASE = 'purchase',
ALL = 'all',
}
@Entity({ schema: 'financial', name: 'taxes' })
export class Tax {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', name: 'company_id' })
companyId: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'enum', enum: TaxType, name: 'tax_type' })
taxType: TaxType;
@Column({ type: 'decimal', precision: 5, scale: 2 })
amount: number;
@Column({ type: 'boolean', default: false, name: 'included_in_price' })
includedInPrice: boolean;
@Column({ type: 'boolean', default: true })
active: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt: Date | null;
}
Incoterm Entity
@Entity({ schema: 'financial', name: 'incoterms' })
export class Incoterm {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 10, unique: true })
code: string;
@Column({ type: 'boolean', default: true, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Servicios
AccountsService
@Injectable()
export class AccountsService {
constructor(
@InjectRepository(Account)
private readonly repo: Repository<Account>,
@InjectRepository(AccountType)
private readonly typeRepo: Repository<AccountType>,
) {}
async findByChart(chartId: string): Promise<Account[]> {
return this.repo.find({
where: { chartId },
relations: ['accountType', 'parent'],
order: { code: 'ASC' },
});
}
async findTreeByChart(chartId: string): Promise<Account[]> {
const accounts = await this.findByChart(chartId);
return this.buildTree(accounts);
}
async create(chartId: string, dto: CreateAccountDto): Promise<Account> {
// Validate parent
if (dto.parentId) {
const parent = await this.repo.findOne({ where: { id: dto.parentId } });
if (!parent) {
throw new NotFoundException('Parent account not found');
}
if (parent.isDetail) {
throw new BadRequestException('Cannot add child to detail account');
}
}
// Validate code uniqueness
const existing = await this.repo.findOne({
where: { chartId, code: dto.code },
});
if (existing) {
throw new ConflictException('Account code already exists');
}
// Calculate level
const level = dto.parentId
? (await this.repo.findOne({ where: { id: dto.parentId } })).level + 1
: 1;
const account = this.repo.create({
...dto,
chartId,
tenantId: dto.tenantId,
level,
});
return this.repo.save(account);
}
async update(id: string, dto: UpdateAccountDto): Promise<Account> {
const account = await this.repo.findOneOrFail({ where: { id } });
// Cannot change code if has movements
if (dto.code && dto.code !== account.code) {
const hasMovements = await this.hasMovements(id);
if (hasMovements) {
throw new BadRequestException('Cannot change code of account with movements');
}
}
Object.assign(account, dto);
return this.repo.save(account);
}
async getBalance(accountId: string, asOfDate?: Date): Promise<AccountBalance> {
const account = await this.repo.findOne({
where: { id: accountId },
relations: ['accountType'],
});
const qb = this.repo.manager
.createQueryBuilder(JournalLine, 'jl')
.select('COALESCE(SUM(jl.debit_base), 0)', 'debitTotal')
.addSelect('COALESCE(SUM(jl.credit_base), 0)', 'creditTotal')
.innerJoin('jl.journalEntry', 'je')
.where('jl.account_id = :accountId', { accountId })
.andWhere('je.status = :status', { status: 'posted' });
if (asOfDate) {
qb.andWhere('je.entry_date <= :asOfDate', { asOfDate });
}
const result = await qb.getRawOne();
const debitTotal = parseFloat(result.debitTotal);
const creditTotal = parseFloat(result.creditTotal);
const balance = account.accountType.normalBalance === 'debit'
? account.openingBalance + debitTotal - creditTotal
: account.openingBalance + creditTotal - debitTotal;
return {
accountId,
openingBalance: account.openingBalance,
debitTotal,
creditTotal,
balance,
};
}
async getTrialBalance(
chartId: string,
periodId: string
): Promise<TrialBalanceItem[]> {
const accounts = await this.repo.find({
where: { chartId, isDetail: true },
relations: ['accountType'],
order: { code: 'ASC' },
});
const period = await this.repo.manager.findOne(FiscalPeriod, {
where: { id: periodId },
});
const result: TrialBalanceItem[] = [];
for (const account of accounts) {
const balance = await this.getBalance(account.id, period.endDate);
if (balance.debitTotal > 0 || balance.creditTotal > 0 || balance.balance !== 0) {
result.push({
accountCode: account.code,
accountName: account.name,
openingBalance: account.openingBalance,
debit: balance.debitTotal,
credit: balance.creditTotal,
balance: balance.balance,
});
}
}
return result;
}
private buildTree(accounts: Account[]): Account[] {
const map = new Map<string, Account>();
const roots: Account[] = [];
accounts.forEach(a => map.set(a.id, { ...a, children: [] }));
map.forEach(account => {
if (account.parentId) {
const parent = map.get(account.parentId);
if (parent) {
parent.children.push(account);
}
} else {
roots.push(account);
}
});
return roots;
}
private async hasMovements(accountId: string): Promise<boolean> {
const count = await this.repo.manager.count(JournalLine, {
where: { accountId },
});
return count > 0;
}
}
JournalService
@Injectable()
export class JournalService {
constructor(
@InjectRepository(JournalEntry)
private readonly entryRepo: Repository<JournalEntry>,
@InjectRepository(JournalLine)
private readonly lineRepo: Repository<JournalLine>,
private readonly periodsService: FiscalPeriodsService,
private readonly currenciesService: CurrenciesService,
private readonly accountsService: AccountsService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
async findAll(
tenantId: string,
query: QueryJournalDto
): Promise<PaginatedResult<JournalEntry>> {
const qb = this.entryRepo.createQueryBuilder('je')
.where('je.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('je.lines', 'lines')
.leftJoinAndSelect('lines.account', 'account');
if (query.periodId) {
qb.andWhere('je.fiscal_period_id = :periodId', { periodId: query.periodId });
}
if (query.status) {
qb.andWhere('je.status = :status', { status: query.status });
}
if (query.dateFrom) {
qb.andWhere('je.entry_date >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('je.entry_date <= :dateTo', { dateTo: query.dateTo });
}
qb.orderBy('je.entry_date', 'DESC')
.addOrderBy('je.entry_number', 'DESC');
return paginate(qb, query);
}
async findById(id: string): Promise<JournalEntry> {
return this.entryRepo.findOne({
where: { id },
relations: ['lines', 'lines.account', 'lines.costCenter', 'fiscalPeriod'],
});
}
async create(
tenantId: string,
userId: string,
dto: CreateJournalEntryDto
): Promise<JournalEntry> {
// Validate period
const period = await this.periodsService.findOpenForDate(tenantId, dto.entryDate);
if (!period) {
throw new BadRequestException('No open period for the entry date');
}
// Get exchange rate
const baseCurrency = await this.currenciesService.getBaseCurrency(tenantId);
const exchangeRate = dto.currencyCode === baseCurrency.code
? 1
: await this.currenciesService.getRate(tenantId, dto.currencyCode, baseCurrency.code, dto.entryDate);
// Generate entry number
const entryNumber = await this.generateEntryNumber(tenantId);
// Calculate totals and base amounts
let totalDebit = 0;
let totalCredit = 0;
const lines = dto.lines.map((line, index) => {
totalDebit += line.debit || 0;
totalCredit += line.credit || 0;
return {
...line,
lineNumber: index + 1,
debitBase: (line.debit || 0) * exchangeRate,
creditBase: (line.credit || 0) * exchangeRate,
};
});
const entry = this.entryRepo.create({
tenantId,
fiscalPeriodId: period.id,
entryNumber,
entryDate: dto.entryDate,
currencyCode: dto.currencyCode,
exchangeRate,
reference: dto.reference,
description: dto.description,
sourceModule: dto.sourceModule,
sourceDocumentId: dto.sourceDocumentId,
totalDebit,
totalCredit,
createdBy: userId,
lines,
});
return this.entryRepo.save(entry);
}
async post(id: string, userId: string): Promise<JournalEntry> {
return this.dataSource.transaction(async manager => {
const entry = await manager.findOne(JournalEntry, {
where: { id },
relations: ['lines', 'lines.account', 'lines.account.accountType'],
lock: { mode: 'pessimistic_write' },
});
if (!entry) {
throw new NotFoundException('Journal entry not found');
}
if (entry.status !== 'draft') {
throw new BadRequestException('Entry is not in draft status');
}
// Verify period is open
const period = await manager.findOne(FiscalPeriod, {
where: { id: entry.fiscalPeriodId },
});
if (period.status !== 'open') {
throw new BadRequestException('Fiscal period is not open');
}
// Verify balance
if (Math.abs(entry.totalDebit - entry.totalCredit) > 0.001) {
throw new BadRequestException(
`Entry is not balanced: debit=${entry.totalDebit} credit=${entry.totalCredit}`
);
}
// Update account balances
for (const line of entry.lines) {
const amount = line.account.accountType.normalBalance === 'debit'
? line.debitBase - line.creditBase
: line.creditBase - line.debitBase;
await manager.update(Account, line.accountId, {
currentBalance: () => `current_balance + ${amount}`,
updatedAt: new Date(),
});
}
// Mark as posted
entry.status = 'posted';
entry.postedAt = new Date();
entry.postedBy = userId;
return manager.save(entry);
});
}
async reverse(id: string, userId: string, description: string): Promise<JournalEntry> {
const original = await this.findById(id);
if (original.status !== 'posted') {
throw new BadRequestException('Only posted entries can be reversed');
}
// Create reversal entry
const reversalDto: CreateJournalEntryDto = {
entryDate: new Date(),
currencyCode: original.currencyCode,
description: description || `Reversal of ${original.entryNumber}`,
reference: original.entryNumber,
lines: original.lines.map(line => ({
accountId: line.accountId,
costCenterId: line.costCenterId,
debit: line.credit, // Swap debit/credit
credit: line.debit,
description: `Reversal: ${line.description || ''}`,
})),
};
const reversal = await this.create(original.tenantId, userId, reversalDto);
// Post the reversal
await this.post(reversal.id, userId);
// Mark original as reversed
await this.entryRepo.update(id, { status: 'reversed', reversedBy: reversal.id });
return this.findById(reversal.id);
}
async getLedger(
accountId: string,
query: QueryLedgerDto
): Promise<LedgerEntry[]> {
const qb = this.lineRepo.createQueryBuilder('jl')
.innerJoinAndSelect('jl.journalEntry', 'je')
.where('jl.account_id = :accountId', { accountId })
.andWhere('je.status = :status', { status: 'posted' });
if (query.dateFrom) {
qb.andWhere('je.entry_date >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('je.entry_date <= :dateTo', { dateTo: query.dateTo });
}
qb.orderBy('je.entry_date', 'ASC')
.addOrderBy('je.entry_number', 'ASC')
.addOrderBy('jl.line_number', 'ASC');
const lines = await qb.getMany();
let runningBalance = 0;
return lines.map(line => {
runningBalance += line.debitBase - line.creditBase;
return {
date: line.journalEntry.entryDate,
entryNumber: line.journalEntry.entryNumber,
description: line.description || line.journalEntry.description,
reference: line.reference,
debit: line.debitBase,
credit: line.creditBase,
balance: runningBalance,
};
});
}
private async generateEntryNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `JE-${year}-`;
const lastEntry = await this.entryRepo.findOne({
where: { tenantId, entryNumber: Like(`${prefix}%`) },
order: { entryNumber: 'DESC' },
});
let sequence = 1;
if (lastEntry) {
const lastNum = parseInt(lastEntry.entryNumber.replace(prefix, ''));
sequence = lastNum + 1;
}
return `${prefix}${sequence.toString().padStart(6, '0')}`;
}
}
CurrenciesService
@Injectable()
export class CurrenciesService {
constructor(
@InjectRepository(TenantCurrency)
private readonly currencyRepo: Repository<TenantCurrency>,
@InjectRepository(ExchangeRate)
private readonly rateRepo: Repository<ExchangeRate>,
) {}
async findByTenant(tenantId: string): Promise<TenantCurrency[]> {
return this.currencyRepo.find({
where: { tenantId, isActive: true },
relations: ['currency'],
});
}
async getBaseCurrency(tenantId: string): Promise<TenantCurrency> {
const base = await this.currencyRepo.findOne({
where: { tenantId, isBase: true },
});
if (!base) {
throw new NotFoundException('Base currency not configured');
}
return base;
}
async getRate(
tenantId: string,
fromCurrency: string,
toCurrency: string,
date: Date = new Date()
): Promise<number> {
if (fromCurrency === toCurrency) {
return 1;
}
// Try direct rate
const directRate = await this.rateRepo.findOne({
where: {
tenantId,
fromCurrency,
toCurrency,
effectiveDate: LessThanOrEqual(date),
},
order: { effectiveDate: 'DESC' },
});
if (directRate) {
return directRate.rate;
}
// Try inverse rate
const inverseRate = await this.rateRepo.findOne({
where: {
tenantId,
fromCurrency: toCurrency,
toCurrency: fromCurrency,
effectiveDate: LessThanOrEqual(date),
},
order: { effectiveDate: 'DESC' },
});
if (inverseRate) {
return 1 / inverseRate.rate;
}
throw new NotFoundException(
`Exchange rate not found for ${fromCurrency} to ${toCurrency}`
);
}
async convert(
tenantId: string,
amount: number,
fromCurrency: string,
toCurrency: string,
date?: Date
): Promise<ConversionResult> {
const rate = await this.getRate(tenantId, fromCurrency, toCurrency, date);
const convertedAmount = amount * rate;
return {
originalAmount: amount,
originalCurrency: fromCurrency,
convertedAmount,
targetCurrency: toCurrency,
rate,
date: date || new Date(),
};
}
async setRate(
tenantId: string,
userId: string,
dto: CreateExchangeRateDto
): Promise<ExchangeRate> {
const rate = this.rateRepo.create({
tenantId,
fromCurrency: dto.fromCurrency,
toCurrency: dto.toCurrency,
rate: dto.rate,
effectiveDate: dto.effectiveDate,
source: dto.source,
createdBy: userId,
});
return this.rateRepo.save(rate);
}
}
FiscalPeriodsService
@Injectable()
export class FiscalPeriodsService {
constructor(
@InjectRepository(FiscalYear)
private readonly yearRepo: Repository<FiscalYear>,
@InjectRepository(FiscalPeriod)
private readonly periodRepo: Repository<FiscalPeriod>,
) {}
async findOpenForDate(tenantId: string, date: Date): Promise<FiscalPeriod> {
return this.periodRepo.findOne({
where: {
tenantId,
startDate: LessThanOrEqual(date),
endDate: MoreThanOrEqual(date),
status: 'open',
},
});
}
async createYear(
tenantId: string,
dto: CreateFiscalYearDto
): Promise<FiscalYear> {
const year = this.yearRepo.create({
tenantId,
name: dto.name,
startDate: dto.startDate,
endDate: dto.endDate,
});
const savedYear = await this.yearRepo.save(year);
// Create monthly periods
if (dto.createMonthlyPeriods) {
await this.createMonthlyPeriods(savedYear);
}
return this.yearRepo.findOne({
where: { id: savedYear.id },
relations: ['periods'],
});
}
async closePeriod(
periodId: string,
userId: string
): Promise<FiscalPeriod> {
const period = await this.periodRepo.findOneOrFail({
where: { id: periodId },
});
if (period.status !== 'open') {
throw new BadRequestException('Period is not open');
}
// Check for draft entries
const draftCount = await this.periodRepo.manager.count(JournalEntry, {
where: { fiscalPeriodId: periodId, status: 'draft' },
});
if (draftCount > 0) {
throw new BadRequestException(
`Cannot close period with ${draftCount} draft entries`
);
}
period.status = 'closed';
period.closedAt = new Date();
period.closedBy = userId;
return this.periodRepo.save(period);
}
async reopenPeriod(
periodId: string,
userId: string
): Promise<FiscalPeriod> {
const period = await this.periodRepo.findOneOrFail({
where: { id: periodId },
relations: ['fiscalYear'],
});
if (period.status !== 'closed') {
throw new BadRequestException('Period is not closed');
}
if (period.fiscalYear.status === 'closed') {
throw new BadRequestException('Cannot reopen period of closed fiscal year');
}
period.status = 'open';
period.closedAt = null;
period.closedBy = null;
return this.periodRepo.save(period);
}
private async createMonthlyPeriods(year: FiscalYear): Promise<void> {
const periods: Partial<FiscalPeriod>[] = [];
let currentDate = new Date(year.startDate);
let periodNumber = 1;
while (currentDate < year.endDate) {
const startOfMonth = startOfMonth(currentDate);
const endOfMonth = endOfMonth(currentDate);
periods.push({
fiscalYearId: year.id,
tenantId: year.tenantId,
name: format(currentDate, 'MMMM yyyy'),
periodNumber,
startDate: startOfMonth > year.startDate ? startOfMonth : year.startDate,
endDate: endOfMonth < year.endDate ? endOfMonth : year.endDate,
});
currentDate = addMonths(currentDate, 1);
periodNumber++;
}
await this.periodRepo.save(periods);
}
}
InvoicesService
@Injectable()
export class InvoicesService {
constructor(
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>,
@InjectRepository(InvoiceLine)
private readonly lineRepo: Repository<InvoiceLine>,
private readonly taxesService: TaxesService,
private readonly journalEntriesService: JournalEntriesService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
async findAll(
tenantId: string,
query: QueryInvoiceDto
): Promise<PaginatedResult<Invoice>> {
const qb = this.invoiceRepo.createQueryBuilder('inv')
.where('inv.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('inv.lines', 'lines');
if (query.invoiceType) {
qb.andWhere('inv.invoice_type = :type', { type: query.invoiceType });
}
if (query.status) {
qb.andWhere('inv.status = :status', { status: query.status });
}
if (query.partnerId) {
qb.andWhere('inv.partner_id = :partnerId', { partnerId: query.partnerId });
}
if (query.dateFrom) {
qb.andWhere('inv.invoice_date >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('inv.invoice_date <= :dateTo', { dateTo: query.dateTo });
}
qb.orderBy('inv.invoice_date', 'DESC')
.addOrderBy('inv.number', 'DESC');
return paginate(qb, query);
}
async findById(id: string): Promise<Invoice> {
return this.invoiceRepo.findOne({
where: { id },
relations: ['lines', 'lines.account', 'journal', 'journalEntry'],
});
}
async create(
tenantId: string,
companyId: string,
userId: string,
dto: CreateInvoiceDto
): Promise<Invoice> {
const lines = await this.calculateLineAmounts(dto.lines);
const totals = this.calculateInvoiceTotals(lines);
const invoice = this.invoiceRepo.create({
tenantId,
companyId,
partnerId: dto.partnerId,
invoiceType: dto.invoiceType,
invoiceDate: dto.invoiceDate,
dueDate: dto.dueDate,
currencyId: dto.currencyId,
paymentTermId: dto.paymentTermId,
journalId: dto.journalId,
notes: dto.notes,
ref: dto.ref,
...totals,
amountResidual: totals.amountTotal,
createdBy: userId,
lines: lines.map((line, index) => ({
...line,
tenantId,
sequence: index + 1,
})),
});
return this.invoiceRepo.save(invoice);
}
async update(
id: string,
userId: string,
dto: UpdateInvoiceDto
): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOneOrFail({
where: { id },
relations: ['lines'],
});
if (invoice.status !== InvoiceStatus.DRAFT) {
throw new BadRequestException('Only draft invoices can be updated');
}
if (dto.lines) {
await this.lineRepo.delete({ invoiceId: id });
const lines = await this.calculateLineAmounts(dto.lines);
const totals = this.calculateInvoiceTotals(lines);
Object.assign(invoice, dto, totals, {
amountResidual: totals.amountTotal,
updatedBy: userId,
lines: lines.map((line, index) => ({
...line,
invoiceId: id,
tenantId: invoice.tenantId,
sequence: index + 1,
})),
});
} else {
Object.assign(invoice, dto, { updatedBy: userId });
}
return this.invoiceRepo.save(invoice);
}
async validate(id: string, userId: string): Promise<Invoice> {
return this.dataSource.transaction(async manager => {
const invoice = await manager.findOne(Invoice, {
where: { id },
relations: ['lines'],
lock: { mode: 'pessimistic_write' },
});
if (!invoice) {
throw new NotFoundException('Invoice not found');
}
if (invoice.status !== InvoiceStatus.DRAFT) {
throw new BadRequestException('Invoice is not in draft status');
}
// Generate invoice number
const number = await this.generateInvoiceNumber(
manager,
invoice.tenantId,
invoice.invoiceType
);
// Create journal entry
const journalEntry = await this.createJournalEntry(manager, invoice, userId);
invoice.number = number;
invoice.status = InvoiceStatus.OPEN;
invoice.journalEntryId = journalEntry.id;
invoice.validatedAt = new Date();
invoice.validatedBy = userId;
return manager.save(invoice);
});
}
async cancel(id: string, userId: string): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOneOrFail({ where: { id } });
if (invoice.status === InvoiceStatus.CANCELLED) {
throw new BadRequestException('Invoice is already cancelled');
}
if (invoice.amountPaid > 0) {
throw new BadRequestException('Cannot cancel invoice with payments');
}
invoice.status = InvoiceStatus.CANCELLED;
invoice.cancelledAt = new Date();
invoice.cancelledBy = userId;
return this.invoiceRepo.save(invoice);
}
async registerPayment(
invoiceId: string,
amount: number
): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOneOrFail({ where: { id: invoiceId } });
invoice.amountPaid += amount;
invoice.amountResidual = invoice.amountTotal - invoice.amountPaid;
if (invoice.amountResidual <= 0) {
invoice.status = InvoiceStatus.PAID;
}
return this.invoiceRepo.save(invoice);
}
private async calculateLineAmounts(lines: CreateInvoiceLineDto[]): Promise<InvoiceLine[]> {
return Promise.all(lines.map(async line => {
const amountUntaxed = line.quantity * line.priceUnit;
let amountTax = 0;
if (line.taxIds?.length > 0) {
// Calculate taxes
for (const taxId of line.taxIds) {
const tax = await this.taxesService.findById(taxId);
amountTax += amountUntaxed * (tax.amount / 100);
}
}
return {
...line,
amountUntaxed,
amountTax,
amountTotal: amountUntaxed + amountTax,
} as InvoiceLine;
}));
}
private calculateInvoiceTotals(lines: InvoiceLine[]): {
amountUntaxed: number;
amountTax: number;
amountTotal: number;
} {
return lines.reduce(
(totals, line) => ({
amountUntaxed: totals.amountUntaxed + line.amountUntaxed,
amountTax: totals.amountTax + line.amountTax,
amountTotal: totals.amountTotal + line.amountTotal,
}),
{ amountUntaxed: 0, amountTax: 0, amountTotal: 0 }
);
}
private async generateInvoiceNumber(
manager: EntityManager,
tenantId: string,
type: InvoiceType
): Promise<string> {
const prefix = type === InvoiceType.CUSTOMER ? 'INV' : 'BILL';
const year = new Date().getFullYear();
const pattern = `${prefix}-${year}-%`;
const lastInvoice = await manager.findOne(Invoice, {
where: { tenantId, number: Like(pattern) },
order: { number: 'DESC' },
});
let sequence = 1;
if (lastInvoice) {
const lastNum = parseInt(lastInvoice.number.split('-').pop());
sequence = lastNum + 1;
}
return `${prefix}-${year}-${sequence.toString().padStart(6, '0')}`;
}
private async createJournalEntry(
manager: EntityManager,
invoice: Invoice,
userId: string
): Promise<JournalEntry> {
// Implementation creates corresponding journal entry
// for the invoice based on configured accounts
}
}
PaymentsService
/**
* PaymentsService - Gestion de pagos con reconciliacion
* Tests: 36 tests unitarios
*/
@Injectable()
export class PaymentsService {
constructor(
@InjectRepository(Payment)
private readonly paymentRepo: Repository<Payment>,
@InjectRepository(PaymentInvoice)
private readonly paymentInvoiceRepo: Repository<PaymentInvoice>,
private readonly invoicesService: InvoicesService,
private readonly journalEntriesService: JournalEntriesService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
async findAll(
tenantId: string,
query: QueryPaymentDto
): Promise<PaginatedResult<Payment>> {
const qb = this.paymentRepo.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('p.journal', 'journal');
if (query.paymentType) {
qb.andWhere('p.payment_type = :type', { type: query.paymentType });
}
if (query.status) {
qb.andWhere('p.status = :status', { status: query.status });
}
if (query.partnerId) {
qb.andWhere('p.partner_id = :partnerId', { partnerId: query.partnerId });
}
if (query.dateFrom) {
qb.andWhere('p.payment_date >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('p.payment_date <= :dateTo', { dateTo: query.dateTo });
}
qb.orderBy('p.payment_date', 'DESC');
return paginate(qb, query);
}
async findById(id: string): Promise<Payment> {
return this.paymentRepo.findOne({
where: { id },
relations: ['journal', 'journalEntry'],
});
}
async create(
tenantId: string,
companyId: string,
userId: string,
dto: CreatePaymentDto
): Promise<Payment> {
const payment = this.paymentRepo.create({
tenantId,
companyId,
partnerId: dto.partnerId,
paymentType: dto.paymentType,
paymentMethod: dto.paymentMethod,
amount: dto.amount,
currencyId: dto.currencyId,
paymentDate: dto.paymentDate,
journalId: dto.journalId,
ref: dto.ref,
notes: dto.notes,
createdBy: userId,
});
return this.paymentRepo.save(payment);
}
async update(
id: string,
userId: string,
dto: UpdatePaymentDto
): Promise<Payment> {
const payment = await this.paymentRepo.findOneOrFail({ where: { id } });
if (payment.status !== PaymentStatus.DRAFT) {
throw new BadRequestException('Only draft payments can be updated');
}
Object.assign(payment, dto, { updatedBy: userId });
return this.paymentRepo.save(payment);
}
async post(id: string, userId: string): Promise<Payment> {
return this.dataSource.transaction(async manager => {
const payment = await manager.findOne(Payment, {
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!payment) {
throw new NotFoundException('Payment not found');
}
if (payment.status !== PaymentStatus.DRAFT) {
throw new BadRequestException('Payment is not in draft status');
}
// Create journal entry for payment
const journalEntry = await this.createPaymentJournalEntry(
manager,
payment,
userId
);
payment.status = PaymentStatus.POSTED;
payment.journalEntryId = journalEntry.id;
payment.postedAt = new Date();
payment.postedBy = userId;
return manager.save(payment);
});
}
async cancel(id: string, userId: string): Promise<Payment> {
const payment = await this.paymentRepo.findOneOrFail({ where: { id } });
if (payment.status === PaymentStatus.CANCELLED) {
throw new BadRequestException('Payment is already cancelled');
}
if (payment.status === PaymentStatus.RECONCILED) {
throw new BadRequestException('Cannot cancel reconciled payment');
}
// Reverse invoice allocations
await this.reverseInvoiceAllocations(payment);
payment.status = PaymentStatus.CANCELLED;
return this.paymentRepo.save(payment);
}
/**
* Reconcilia un pago con una o mas facturas
*/
async reconcile(
paymentId: string,
invoiceAllocations: InvoiceAllocationDto[]
): Promise<Payment> {
return this.dataSource.transaction(async manager => {
const payment = await manager.findOne(Payment, {
where: { id: paymentId },
lock: { mode: 'pessimistic_write' },
});
if (!payment) {
throw new NotFoundException('Payment not found');
}
if (payment.status !== PaymentStatus.POSTED) {
throw new BadRequestException('Payment must be posted to reconcile');
}
// Validate total allocation amount
const totalAllocation = invoiceAllocations.reduce(
(sum, alloc) => sum + alloc.amount,
0
);
if (totalAllocation > payment.amount) {
throw new BadRequestException('Total allocation exceeds payment amount');
}
// Create payment-invoice records and update invoices
for (const allocation of invoiceAllocations) {
const invoice = await manager.findOne(Invoice, {
where: { id: allocation.invoiceId },
lock: { mode: 'pessimistic_write' },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${allocation.invoiceId} not found`);
}
if (allocation.amount > invoice.amountResidual) {
throw new BadRequestException(
`Allocation amount exceeds invoice residual for ${invoice.number}`
);
}
// Create payment-invoice link
const paymentInvoice = manager.create(PaymentInvoice, {
paymentId,
invoiceId: allocation.invoiceId,
amount: allocation.amount,
});
await manager.save(paymentInvoice);
// Update invoice amounts
invoice.amountPaid += allocation.amount;
invoice.amountResidual -= allocation.amount;
if (invoice.amountResidual <= 0) {
invoice.status = InvoiceStatus.PAID;
}
await manager.save(invoice);
}
// Mark payment as reconciled if fully allocated
if (totalAllocation >= payment.amount) {
payment.status = PaymentStatus.RECONCILED;
}
return manager.save(payment);
});
}
/**
* Obtiene facturas pendientes de un partner para reconciliar
*/
async getUnreconciledInvoices(
tenantId: string,
partnerId: string,
paymentType: PaymentType
): Promise<Invoice[]> {
const invoiceType = paymentType === PaymentType.INBOUND
? InvoiceType.CUSTOMER
: InvoiceType.SUPPLIER;
return this.invoicesService.findAll(tenantId, {
partnerId,
invoiceType,
status: InvoiceStatus.OPEN,
});
}
/**
* Auto-reconcilia pagos con facturas basado en reglas
*/
async autoReconcile(
tenantId: string,
paymentId: string
): Promise<Payment> {
const payment = await this.findById(paymentId);
// Find matching invoices by partner and amount
const invoices = await this.getUnreconciledInvoices(
tenantId,
payment.partnerId,
payment.paymentType
);
// Try exact match first
const exactMatch = invoices.find(inv => inv.amountResidual === payment.amount);
if (exactMatch) {
return this.reconcile(paymentId, [
{ invoiceId: exactMatch.id, amount: payment.amount }
]);
}
// Try to allocate to multiple invoices
const allocations: InvoiceAllocationDto[] = [];
let remainingAmount = payment.amount;
for (const invoice of invoices) {
if (remainingAmount <= 0) break;
const allocAmount = Math.min(remainingAmount, invoice.amountResidual);
allocations.push({ invoiceId: invoice.id, amount: allocAmount });
remainingAmount -= allocAmount;
}
if (allocations.length > 0) {
return this.reconcile(paymentId, allocations);
}
return payment;
}
private async reverseInvoiceAllocations(payment: Payment): Promise<void> {
const allocations = await this.paymentInvoiceRepo.find({
where: { paymentId: payment.id },
});
for (const allocation of allocations) {
const invoice = await this.invoicesService.findById(allocation.invoiceId);
invoice.amountPaid -= allocation.amount;
invoice.amountResidual += allocation.amount;
if (invoice.status === InvoiceStatus.PAID) {
invoice.status = InvoiceStatus.OPEN;
}
await this.invoicesService.update(invoice.id, null, invoice);
}
await this.paymentInvoiceRepo.delete({ paymentId: payment.id });
}
private async createPaymentJournalEntry(
manager: EntityManager,
payment: Payment,
userId: string
): Promise<JournalEntry> {
// Implementation creates journal entry for payment
// based on payment type and configured accounts
}
}
PaymentMethodsService
@Injectable()
export class PaymentMethodsService {
constructor(
@InjectRepository(PaymentMethodCatalog)
private readonly repo: Repository<PaymentMethodCatalog>,
) {}
async findAll(query?: QueryPaymentMethodDto): Promise<PaymentMethodCatalog[]> {
const qb = this.repo.createQueryBuilder('pm');
if (query?.paymentType) {
qb.andWhere('pm.payment_type = :type', { type: query.paymentType });
}
if (query?.isActive !== undefined) {
qb.andWhere('pm.is_active = :isActive', { isActive: query.isActive });
}
return qb.orderBy('pm.name', 'ASC').getMany();
}
async findById(id: string): Promise<PaymentMethodCatalog> {
return this.repo.findOneOrFail({ where: { id } });
}
async findByCode(code: string, paymentType: string): Promise<PaymentMethodCatalog> {
return this.repo.findOne({
where: { code, paymentType },
});
}
async create(dto: CreatePaymentMethodDto): Promise<PaymentMethodCatalog> {
const existing = await this.findByCode(dto.code, dto.paymentType);
if (existing) {
throw new ConflictException('Payment method code already exists for this type');
}
const method = this.repo.create(dto);
return this.repo.save(method);
}
async update(id: string, dto: UpdatePaymentMethodDto): Promise<PaymentMethodCatalog> {
const method = await this.repo.findOneOrFail({ where: { id } });
Object.assign(method, dto);
return this.repo.save(method);
}
async deactivate(id: string): Promise<PaymentMethodCatalog> {
const method = await this.repo.findOneOrFail({ where: { id } });
method.isActive = false;
return this.repo.save(method);
}
}
PaymentTermsService
@Injectable()
export class PaymentTermsService {
constructor(
@InjectRepository(PaymentTerm)
private readonly termRepo: Repository<PaymentTerm>,
@InjectRepository(PaymentTermLine)
private readonly lineRepo: Repository<PaymentTermLine>,
) {}
async findAll(
tenantId: string,
companyId: string
): Promise<PaymentTerm[]> {
return this.termRepo.find({
where: { tenantId, companyId, active: true },
relations: ['lines'],
order: { name: 'ASC' },
});
}
async findById(id: string): Promise<PaymentTerm> {
return this.termRepo.findOne({
where: { id },
relations: ['lines'],
});
}
async create(
tenantId: string,
companyId: string,
userId: string,
dto: CreatePaymentTermDto
): Promise<PaymentTerm> {
// Validate lines sum to 100% for percentage types
const percentageLines = dto.lines.filter(l => l.value === 'percent');
if (percentageLines.length > 0) {
const totalPercent = percentageLines.reduce((sum, l) => sum + l.valueAmount, 0);
if (Math.abs(totalPercent - 100) > 0.01) {
throw new BadRequestException('Percentage lines must sum to 100%');
}
}
const term = this.termRepo.create({
tenantId,
companyId,
name: dto.name,
code: dto.code,
createdBy: userId,
lines: dto.lines.map((line, index) => ({
...line,
sequence: index * 10 + 10,
})),
});
return this.termRepo.save(term);
}
async update(
id: string,
userId: string,
dto: UpdatePaymentTermDto
): Promise<PaymentTerm> {
const term = await this.termRepo.findOneOrFail({
where: { id },
relations: ['lines'],
});
if (dto.lines) {
await this.lineRepo.delete({ paymentTermId: id });
term.lines = dto.lines.map((line, index) => ({
...line,
paymentTermId: id,
sequence: index * 10 + 10,
} as PaymentTermLine));
}
Object.assign(term, dto, { updatedBy: userId });
return this.termRepo.save(term);
}
async deactivate(id: string): Promise<PaymentTerm> {
const term = await this.termRepo.findOneOrFail({ where: { id } });
term.active = false;
return this.termRepo.save(term);
}
/**
* Calcula fechas de vencimiento basadas en terminos de pago
*/
async calculateDueDates(
paymentTermId: string,
invoiceDate: Date,
totalAmount: number
): Promise<PaymentDueDate[]> {
const term = await this.findById(paymentTermId);
const dueDates: PaymentDueDate[] = [];
let remainingAmount = totalAmount;
for (const line of term.lines.sort((a, b) => a.sequence - b.sequence)) {
let lineAmount: number;
switch (line.value) {
case 'balance':
lineAmount = remainingAmount;
break;
case 'percent':
lineAmount = totalAmount * (line.valueAmount / 100);
break;
case 'fixed':
lineAmount = Math.min(line.valueAmount, remainingAmount);
break;
}
const dueDate = this.calculateDueDate(invoiceDate, line);
dueDates.push({
dueDate,
amount: lineAmount,
sequence: line.sequence,
});
remainingAmount -= lineAmount;
if (remainingAmount <= 0) break;
}
return dueDates;
}
private calculateDueDate(
invoiceDate: Date,
line: PaymentTermLine
): Date {
let baseDate = new Date(invoiceDate);
switch (line.delayType) {
case 'days_after':
baseDate.setDate(baseDate.getDate() + line.nbDays);
break;
case 'days_after_end_of_month':
baseDate = endOfMonth(baseDate);
baseDate.setDate(baseDate.getDate() + line.nbDays);
break;
case 'days_after_end_of_next_month':
baseDate = addMonths(baseDate, 1);
baseDate = endOfMonth(baseDate);
baseDate.setDate(baseDate.getDate() + line.nbDays);
break;
}
if (line.dayOfTheMonth) {
const targetDay = Math.min(line.dayOfTheMonth, getDaysInMonth(baseDate));
baseDate.setDate(targetDay);
}
return baseDate;
}
}
JournalEntriesService
/**
* JournalEntriesService - Servicio para asientos contables
* Incluye funcionalidad de posting de asientos
*/
@Injectable()
export class JournalEntriesService {
constructor(
@InjectRepository(JournalEntry)
private readonly entryRepo: Repository<JournalEntry>,
@InjectRepository(JournalEntryLine)
private readonly lineRepo: Repository<JournalEntryLine>,
private readonly accountsService: AccountsService,
private readonly periodsService: FiscalPeriodsService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
async findAll(
tenantId: string,
query: QueryJournalEntryDto
): Promise<PaginatedResult<JournalEntry>> {
const qb = this.entryRepo.createQueryBuilder('je')
.where('je.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('je.lines', 'lines')
.leftJoinAndSelect('lines.account', 'account')
.leftJoinAndSelect('je.journal', 'journal');
if (query.journalId) {
qb.andWhere('je.journal_id = :journalId', { journalId: query.journalId });
}
if (query.status) {
qb.andWhere('je.status = :status', { status: query.status });
}
if (query.dateFrom) {
qb.andWhere('je.date >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('je.date <= :dateTo', { dateTo: query.dateTo });
}
qb.orderBy('je.date', 'DESC')
.addOrderBy('je.name', 'DESC');
return paginate(qb, query);
}
async findById(id: string): Promise<JournalEntry> {
return this.entryRepo.findOne({
where: { id },
relations: ['lines', 'lines.account', 'journal'],
});
}
async create(
tenantId: string,
companyId: string,
userId: string,
dto: CreateJournalEntryDto
): Promise<JournalEntry> {
// Validate debit = credit
const totalDebit = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
const totalCredit = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);
if (Math.abs(totalDebit - totalCredit) > 0.001) {
throw new BadRequestException(
`Entry is not balanced: debit=${totalDebit} credit=${totalCredit}`
);
}
// Generate entry name
const name = await this.generateEntryName(tenantId, dto.journalId);
const entry = this.entryRepo.create({
tenantId,
companyId,
journalId: dto.journalId,
name,
date: dto.date,
ref: dto.ref,
notes: dto.notes,
createdBy: userId,
lines: dto.lines.map(line => ({
...line,
tenantId,
})),
});
return this.entryRepo.save(entry);
}
async update(
id: string,
userId: string,
dto: UpdateJournalEntryDto
): Promise<JournalEntry> {
const entry = await this.entryRepo.findOneOrFail({
where: { id },
relations: ['lines'],
});
if (entry.status !== EntryStatus.DRAFT) {
throw new BadRequestException('Only draft entries can be updated');
}
if (dto.lines) {
// Validate balance
const totalDebit = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
const totalCredit = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);
if (Math.abs(totalDebit - totalCredit) > 0.001) {
throw new BadRequestException('Entry is not balanced');
}
await this.lineRepo.delete({ entryId: id });
entry.lines = dto.lines.map(line => ({
...line,
entryId: id,
tenantId: entry.tenantId,
} as JournalEntryLine));
}
Object.assign(entry, dto, { updatedBy: userId });
return this.entryRepo.save(entry);
}
/**
* Postea un asiento contable, actualizando saldos de cuentas
*/
async post(id: string, userId: string): Promise<JournalEntry> {
return this.dataSource.transaction(async manager => {
const entry = await manager.findOne(JournalEntry, {
where: { id },
relations: ['lines', 'lines.account'],
lock: { mode: 'pessimistic_write' },
});
if (!entry) {
throw new NotFoundException('Journal entry not found');
}
if (entry.status !== EntryStatus.DRAFT) {
throw new BadRequestException('Entry is not in draft status');
}
// Validate fiscal period is open
if (entry.fiscalPeriodId) {
const period = await manager.findOne(FiscalPeriod, {
where: { id: entry.fiscalPeriodId },
});
if (period?.status !== 'open') {
throw new BadRequestException('Fiscal period is not open');
}
}
// Update account balances
for (const line of entry.lines) {
const account = await manager.findOne(Account, {
where: { id: line.accountId },
relations: ['accountType'],
lock: { mode: 'pessimistic_write' },
});
const balanceChange = account.accountType.normalBalance === 'debit'
? line.debit - line.credit
: line.credit - line.debit;
await manager.update(Account, line.accountId, {
currentBalance: () => `current_balance + ${balanceChange}`,
updatedAt: new Date(),
});
}
entry.status = EntryStatus.POSTED;
entry.postedAt = new Date();
entry.postedBy = userId;
return manager.save(entry);
});
}
/**
* Cancela un asiento contable, revirtiendo saldos
*/
async cancel(id: string, userId: string): Promise<JournalEntry> {
return this.dataSource.transaction(async manager => {
const entry = await manager.findOne(JournalEntry, {
where: { id },
relations: ['lines', 'lines.account'],
lock: { mode: 'pessimistic_write' },
});
if (!entry) {
throw new NotFoundException('Journal entry not found');
}
if (entry.status === EntryStatus.CANCELLED) {
throw new BadRequestException('Entry is already cancelled');
}
// Reverse account balances if entry was posted
if (entry.status === EntryStatus.POSTED) {
for (const line of entry.lines) {
const account = await manager.findOne(Account, {
where: { id: line.accountId },
relations: ['accountType'],
lock: { mode: 'pessimistic_write' },
});
const balanceChange = account.accountType.normalBalance === 'debit'
? line.credit - line.debit // Reverse
: line.debit - line.credit;
await manager.update(Account, line.accountId, {
currentBalance: () => `current_balance + ${balanceChange}`,
updatedAt: new Date(),
});
}
}
entry.status = EntryStatus.CANCELLED;
entry.cancelledAt = new Date();
entry.cancelledBy = userId;
return manager.save(entry);
});
}
private async generateEntryName(
tenantId: string,
journalId: string
): Promise<string> {
const journal = await this.dataSource.manager.findOne(Journal, {
where: { id: journalId },
});
const year = new Date().getFullYear();
const month = (new Date().getMonth() + 1).toString().padStart(2, '0');
const prefix = `${journal.code}/${year}/${month}/`;
const lastEntry = await this.entryRepo.findOne({
where: { tenantId, name: Like(`${prefix}%`) },
order: { name: 'DESC' },
});
let sequence = 1;
if (lastEntry) {
const lastNum = parseInt(lastEntry.name.split('/').pop());
sequence = lastNum + 1;
}
return `${prefix}${sequence.toString().padStart(4, '0')}`;
}
}
ReconcileModelsService
@Injectable()
export class ReconcileModelsService {
constructor(
@InjectRepository(ReconcileModel)
private readonly modelRepo: Repository<ReconcileModel>,
@InjectRepository(ReconcileModelLine)
private readonly lineRepo: Repository<ReconcileModelLine>,
) {}
async findAll(
tenantId: string,
companyId?: string
): Promise<ReconcileModel[]> {
const where: any = { tenantId, isActive: true };
if (companyId) {
where.companyId = companyId;
}
return this.modelRepo.find({
where,
relations: ['lines', 'lines.account'],
order: { sequence: 'ASC' },
});
}
async findById(id: string): Promise<ReconcileModel> {
return this.modelRepo.findOne({
where: { id },
relations: ['lines', 'lines.account', 'lines.journal'],
});
}
async create(
tenantId: string,
dto: CreateReconcileModelDto
): Promise<ReconcileModel> {
const model = this.modelRepo.create({
tenantId,
companyId: dto.companyId,
name: dto.name,
sequence: dto.sequence || 10,
ruleType: dto.ruleType,
autoReconcile: dto.autoReconcile,
matchNature: dto.matchNature,
matchAmount: dto.matchAmount,
matchAmountMin: dto.matchAmountMin,
matchAmountMax: dto.matchAmountMax,
matchLabel: dto.matchLabel,
matchLabelParam: dto.matchLabelParam,
matchPartner: dto.matchPartner,
matchPartnerIds: dto.matchPartnerIds,
lines: dto.lines?.map((line, index) => ({
...line,
sequence: index * 10 + 10,
})),
});
return this.modelRepo.save(model);
}
async update(
id: string,
dto: UpdateReconcileModelDto
): Promise<ReconcileModel> {
const model = await this.modelRepo.findOneOrFail({
where: { id },
relations: ['lines'],
});
if (dto.lines) {
await this.lineRepo.delete({ modelId: id });
model.lines = dto.lines.map((line, index) => ({
...line,
modelId: id,
sequence: index * 10 + 10,
} as ReconcileModelLine));
}
Object.assign(model, dto);
return this.modelRepo.save(model);
}
async deactivate(id: string): Promise<ReconcileModel> {
const model = await this.modelRepo.findOneOrFail({ where: { id } });
model.isActive = false;
return this.modelRepo.save(model);
}
/**
* Encuentra modelos de reconciliacion que coincidan con una transaccion
*/
async findMatchingModels(
tenantId: string,
transaction: ReconcileTransaction
): Promise<ReconcileModel[]> {
const models = await this.findAll(tenantId, transaction.companyId);
return models.filter(model => this.matchesModel(model, transaction));
}
/**
* Aplica un modelo de reconciliacion a una transaccion
*/
async applyModel(
modelId: string,
transaction: ReconcileTransaction
): Promise<ReconcileResult> {
const model = await this.findById(modelId);
if (!this.matchesModel(model, transaction)) {
throw new BadRequestException('Model does not match transaction');
}
const entries: ReconcileEntryLine[] = [];
let remainingAmount = Math.abs(transaction.amount);
for (const line of model.lines.sort((a, b) => a.sequence - b.sequence)) {
let lineAmount: number;
switch (line.amountType) {
case 'percentage':
lineAmount = remainingAmount * (line.amountValue / 100);
break;
case 'fixed':
lineAmount = Math.min(line.amountValue, remainingAmount);
break;
default:
lineAmount = remainingAmount;
}
entries.push({
accountId: line.accountId,
journalId: line.journalId,
label: line.label,
amount: lineAmount,
taxIds: line.taxIds,
analyticAccountId: line.analyticAccountId,
});
remainingAmount -= lineAmount;
if (remainingAmount <= 0) break;
}
return {
modelId,
entries,
autoReconcile: model.autoReconcile,
};
}
private matchesModel(
model: ReconcileModel,
transaction: ReconcileTransaction
): boolean {
// Match nature (inbound/outbound/both)
if (model.matchNature !== 'both') {
const isInbound = transaction.amount > 0;
if (model.matchNature === 'amount_received' && !isInbound) return false;
if (model.matchNature === 'amount_paid' && isInbound) return false;
}
// Match amount range
const amount = Math.abs(transaction.amount);
switch (model.matchAmount) {
case 'lower':
if (amount >= model.matchAmountMax) return false;
break;
case 'greater':
if (amount <= model.matchAmountMin) return false;
break;
case 'between':
if (amount < model.matchAmountMin || amount > model.matchAmountMax) return false;
break;
}
// Match label
if (model.matchLabel && transaction.label) {
switch (model.matchLabel) {
case 'contains':
if (!transaction.label.includes(model.matchLabelParam)) return false;
break;
case 'not_contains':
if (transaction.label.includes(model.matchLabelParam)) return false;
break;
case 'match_regex':
const regex = new RegExp(model.matchLabelParam);
if (!regex.test(transaction.label)) return false;
break;
}
}
// Match partner
if (model.matchPartner && model.matchPartnerIds?.length > 0) {
if (!model.matchPartnerIds.includes(transaction.partnerId)) return false;
}
return true;
}
}
TaxesService
@Injectable()
export class TaxesService {
constructor(
@InjectRepository(Tax)
private readonly repo: Repository<Tax>,
) {}
async findAll(
tenantId: string,
companyId: string,
query?: QueryTaxDto
): Promise<Tax[]> {
const qb = this.repo.createQueryBuilder('t')
.where('t.tenant_id = :tenantId', { tenantId })
.andWhere('t.company_id = :companyId', { companyId });
if (query?.taxType) {
qb.andWhere('t.tax_type IN (:...types)', {
types: query.taxType === TaxType.ALL
? [TaxType.SALES, TaxType.PURCHASE, TaxType.ALL]
: [query.taxType, TaxType.ALL],
});
}
if (query?.active !== undefined) {
qb.andWhere('t.active = :active', { active: query.active });
}
return qb.orderBy('t.name', 'ASC').getMany();
}
async findById(id: string): Promise<Tax> {
return this.repo.findOneOrFail({ where: { id } });
}
async findByCode(
tenantId: string,
code: string
): Promise<Tax> {
return this.repo.findOne({ where: { tenantId, code } });
}
async create(
tenantId: string,
companyId: string,
userId: string,
dto: CreateTaxDto
): Promise<Tax> {
const existing = await this.findByCode(tenantId, dto.code);
if (existing) {
throw new ConflictException('Tax code already exists');
}
const tax = this.repo.create({
tenantId,
companyId,
name: dto.name,
code: dto.code,
taxType: dto.taxType,
amount: dto.amount,
includedInPrice: dto.includedInPrice || false,
createdBy: userId,
});
return this.repo.save(tax);
}
async update(
id: string,
userId: string,
dto: UpdateTaxDto
): Promise<Tax> {
const tax = await this.repo.findOneOrFail({ where: { id } });
if (dto.code && dto.code !== tax.code) {
const existing = await this.findByCode(tax.tenantId, dto.code);
if (existing) {
throw new ConflictException('Tax code already exists');
}
}
Object.assign(tax, dto, { updatedBy: userId });
return this.repo.save(tax);
}
async deactivate(id: string): Promise<Tax> {
const tax = await this.repo.findOneOrFail({ where: { id } });
tax.active = false;
return this.repo.save(tax);
}
/**
* Calcula el monto de impuesto para un valor base
*/
calculateTax(
baseAmount: number,
tax: Tax
): { taxAmount: number; baseAmount: number } {
if (tax.includedInPrice) {
// Tax is included, extract it
const baseWithoutTax = baseAmount / (1 + tax.amount / 100);
return {
baseAmount: baseWithoutTax,
taxAmount: baseAmount - baseWithoutTax,
};
} else {
// Tax is added on top
return {
baseAmount,
taxAmount: baseAmount * (tax.amount / 100),
};
}
}
/**
* Calcula impuestos multiples para un valor base
*/
async calculateTaxes(
baseAmount: number,
taxIds: string[]
): Promise<TaxCalculationResult> {
let totalTax = 0;
const taxDetails: TaxDetail[] = [];
for (const taxId of taxIds) {
const tax = await this.findById(taxId);
const { taxAmount } = this.calculateTax(baseAmount, tax);
totalTax += taxAmount;
taxDetails.push({
taxId,
taxName: tax.name,
taxCode: tax.code,
rate: tax.amount,
baseAmount,
taxAmount,
});
}
return {
baseAmount,
totalTax,
totalAmount: baseAmount + totalTax,
taxes: taxDetails,
};
}
}
IncotermsService
@Injectable()
export class IncotermsService {
constructor(
@InjectRepository(Incoterm)
private readonly repo: Repository<Incoterm>,
) {}
async findAll(activeOnly: boolean = true): Promise<Incoterm[]> {
const where = activeOnly ? { isActive: true } : {};
return this.repo.find({
where,
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<Incoterm> {
return this.repo.findOneOrFail({ where: { id } });
}
async findByCode(code: string): Promise<Incoterm> {
return this.repo.findOne({ where: { code } });
}
async create(dto: CreateIncotermDto): Promise<Incoterm> {
const existing = await this.findByCode(dto.code);
if (existing) {
throw new ConflictException('Incoterm code already exists');
}
const incoterm = this.repo.create(dto);
return this.repo.save(incoterm);
}
async update(id: string, dto: UpdateIncotermDto): Promise<Incoterm> {
const incoterm = await this.repo.findOneOrFail({ where: { id } });
if (dto.code && dto.code !== incoterm.code) {
const existing = await this.findByCode(dto.code);
if (existing) {
throw new ConflictException('Incoterm code already exists');
}
}
Object.assign(incoterm, dto);
return this.repo.save(incoterm);
}
async deactivate(id: string): Promise<Incoterm> {
const incoterm = await this.repo.findOneOrFail({ where: { id } });
incoterm.isActive = false;
return this.repo.save(incoterm);
}
/**
* Obtiene los Incoterms estandar 2020
* EXW, FCA, CPT, CIP, DAP, DPU, DDP (any mode)
* FAS, FOB, CFR, CIF (sea/inland waterway)
*/
getStandardIncoterms(): IncotermInfo[] {
return [
{ code: 'EXW', name: 'Ex Works', mode: 'any' },
{ code: 'FCA', name: 'Free Carrier', mode: 'any' },
{ code: 'CPT', name: 'Carriage Paid To', mode: 'any' },
{ code: 'CIP', name: 'Carriage and Insurance Paid To', mode: 'any' },
{ code: 'DAP', name: 'Delivered at Place', mode: 'any' },
{ code: 'DPU', name: 'Delivered at Place Unloaded', mode: 'any' },
{ code: 'DDP', name: 'Delivered Duty Paid', mode: 'any' },
{ code: 'FAS', name: 'Free Alongside Ship', mode: 'sea' },
{ code: 'FOB', name: 'Free on Board', mode: 'sea' },
{ code: 'CFR', name: 'Cost and Freight', mode: 'sea' },
{ code: 'CIF', name: 'Cost, Insurance and Freight', mode: 'sea' },
];
}
}
Controladores
JournalController
@ApiTags('Journal Entries')
@Controller('financial/journal')
@UseGuards(JwtAuthGuard, RbacGuard)
export class JournalController {
constructor(private readonly service: JournalService) {}
@Get()
@Permissions('financial.journal.read')
async findAll(
@TenantId() tenantId: string,
@Query() query: QueryJournalDto
) {
return this.service.findAll(tenantId, query);
}
@Get(':id')
@Permissions('financial.journal.read')
async findById(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@Permissions('financial.journal.create')
async create(
@TenantId() tenantId: string,
@CurrentUser() user: User,
@Body() dto: CreateJournalEntryDto
) {
return this.service.create(tenantId, user.id, dto);
}
@Post(':id/post')
@Permissions('financial.journal.post')
async post(
@Param('id') id: string,
@CurrentUser() user: User
) {
return this.service.post(id, user.id);
}
@Post(':id/reverse')
@Permissions('financial.journal.reverse')
async reverse(
@Param('id') id: string,
@CurrentUser() user: User,
@Body('description') description?: string
) {
return this.service.reverse(id, user.id, description);
}
}
AccountsController
@ApiTags('Chart of Accounts')
@Controller('financial/charts/:chartId/accounts')
@UseGuards(JwtAuthGuard, RbacGuard)
export class AccountsController {
constructor(private readonly service: AccountsService) {}
@Get()
@Permissions('financial.accounts.read')
async findAll(@Param('chartId') chartId: string) {
return this.service.findByChart(chartId);
}
@Get('tree')
@Permissions('financial.accounts.read')
async findTree(@Param('chartId') chartId: string) {
return this.service.findTreeByChart(chartId);
}
@Post()
@Permissions('financial.accounts.manage')
async create(
@Param('chartId') chartId: string,
@TenantId() tenantId: string,
@Body() dto: CreateAccountDto
) {
return this.service.create(chartId, { ...dto, tenantId });
}
@Get(':id/balance')
@Permissions('financial.accounts.read')
async getBalance(
@Param('id') id: string,
@Query('asOfDate') asOfDate?: string
) {
return this.service.getBalance(id, asOfDate ? new Date(asOfDate) : undefined);
}
@Get(':id/ledger')
@Permissions('financial.accounts.read')
async getLedger(
@Param('id') id: string,
@Query() query: QueryLedgerDto
) {
return this.service.getLedger(id, query);
}
}
API Endpoints Summary
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /financial/charts | financial.accounts.read | List charts |
| POST | /financial/charts | financial.accounts.manage | Create chart |
| GET | /financial/charts/:id/accounts | financial.accounts.read | List accounts |
| GET | /financial/charts/:id/accounts/tree | financial.accounts.read | Accounts tree |
| POST | /financial/charts/:id/accounts | financial.accounts.manage | Create account |
| GET | /financial/accounts/:id/balance | financial.accounts.read | Account balance |
| GET | /financial/accounts/:id/ledger | financial.accounts.read | Account ledger |
| GET | /financial/currencies | financial.currencies.read | List currencies |
| POST | /financial/currencies/convert | financial.currencies.read | Convert amount |
| POST | /financial/currencies/rates | financial.currencies.manage | Set rate |
| GET | /financial/fiscal-years | financial.periods.read | List years |
| POST | /financial/fiscal-years | financial.periods.manage | Create year |
| GET | /financial/fiscal-periods | financial.periods.read | List periods |
| POST | /financial/fiscal-periods/:id/close | financial.periods.manage | Close period |
| POST | /financial/fiscal-periods/:id/reopen | financial.periods.manage | Reopen period |
| GET | /financial/journal | financial.journal.read | List entries |
| GET | /financial/journal/:id | financial.journal.read | Get entry |
| POST | /financial/journal | financial.journal.create | Create entry |
| POST | /financial/journal/:id/post | financial.journal.post | Post entry |
| POST | /financial/journal/:id/reverse | financial.journal.reverse | Reverse entry |
| GET | /financial/cost-centers | financial.costcenters.read | List cost centers |
| POST | /financial/cost-centers | financial.costcenters.manage | Create cost center |
| GET | /financial/reports/trial-balance | financial.reports.read | Trial balance |
| GET | /financial/invoices | financial.invoices.read | List invoices |
| GET | /financial/invoices/:id | financial.invoices.read | Get invoice |
| POST | /financial/invoices | financial.invoices.create | Create invoice |
| PUT | /financial/invoices/:id | financial.invoices.update | Update invoice |
| POST | /financial/invoices/:id/validate | financial.invoices.validate | Validate invoice |
| POST | /financial/invoices/:id/cancel | financial.invoices.cancel | Cancel invoice |
| GET | /financial/payments | financial.payments.read | List payments |
| GET | /financial/payments/:id | financial.payments.read | Get payment |
| POST | /financial/payments | financial.payments.create | Create payment |
| PUT | /financial/payments/:id | financial.payments.update | Update payment |
| POST | /financial/payments/:id/post | financial.payments.post | Post payment |
| POST | /financial/payments/:id/cancel | financial.payments.cancel | Cancel payment |
| POST | /financial/payments/:id/reconcile | financial.payments.reconcile | Reconcile with invoices |
| POST | /financial/payments/:id/auto-reconcile | financial.payments.reconcile | Auto-reconcile payment |
| GET | /financial/payment-methods | financial.paymentmethods.read | List payment methods |
| GET | /financial/payment-methods/:id | financial.paymentmethods.read | Get payment method |
| POST | /financial/payment-methods | financial.paymentmethods.manage | Create payment method |
| PUT | /financial/payment-methods/:id | financial.paymentmethods.manage | Update payment method |
| GET | /financial/payment-terms | financial.paymentterms.read | List payment terms |
| GET | /financial/payment-terms/:id | financial.paymentterms.read | Get payment term |
| POST | /financial/payment-terms | financial.paymentterms.manage | Create payment term |
| PUT | /financial/payment-terms/:id | financial.paymentterms.manage | Update payment term |
| POST | /financial/payment-terms/:id/calculate | financial.paymentterms.read | Calculate due dates |
| GET | /financial/journal-entries | financial.journalentries.read | List journal entries |
| GET | /financial/journal-entries/:id | financial.journalentries.read | Get journal entry |
| POST | /financial/journal-entries | financial.journalentries.create | Create journal entry |
| PUT | /financial/journal-entries/:id | financial.journalentries.update | Update journal entry |
| POST | /financial/journal-entries/:id/post | financial.journalentries.post | Post journal entry |
| POST | /financial/journal-entries/:id/cancel | financial.journalentries.cancel | Cancel journal entry |
| GET | /financial/reconcile-models | financial.reconcilemodels.read | List reconcile models |
| GET | /financial/reconcile-models/:id | financial.reconcilemodels.read | Get reconcile model |
| POST | /financial/reconcile-models | financial.reconcilemodels.manage | Create reconcile model |
| PUT | /financial/reconcile-models/:id | financial.reconcilemodels.manage | Update reconcile model |
| POST | /financial/reconcile-models/:id/apply | financial.reconcilemodels.apply | Apply reconcile model |
| GET | /financial/taxes | financial.taxes.read | List taxes |
| GET | /financial/taxes/:id | financial.taxes.read | Get tax |
| POST | /financial/taxes | financial.taxes.manage | Create tax |
| PUT | /financial/taxes/:id | financial.taxes.manage | Update tax |
| POST | /financial/taxes/calculate | financial.taxes.read | Calculate taxes |
| GET | /financial/incoterms | financial.incoterms.read | List incoterms |
| GET | /financial/incoterms/:id | financial.incoterms.read | Get incoterm |
| POST | /financial/incoterms | financial.incoterms.manage | Create incoterm |
| PUT | /financial/incoterms/:id | financial.incoterms.manage | Update incoterm |
Historial
| Version | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |
| 2.0 | 2026-01-10 | Requirements-Analyst | Documentacion de servicios: InvoicesService, PaymentsService (36 tests, reconciliacion), PaymentMethodsService, PaymentTermsService, JournalEntriesService (posting), ReconcileModelsService, TaxesService, IncotermsService |