[TASK-2026-01-20-003] feat: Add bank reconciliation and 3-way matching

Bank Reconciliation (financial module):
- Entities: BankStatement, BankStatementLine, BankReconciliationRule
- Service: BankReconciliationService with auto-reconcile
- DTOs: CreateBankStatement, ReconcileLine

3-Way Matching (purchases module):
- Entities: PurchaseOrderMatching, PurchaseMatchingLine, MatchingException
- Service: ThreeWayMatchingService with tolerance validation
- DTOs: MatchingStatus, ResolveException

Tolerances:
- Quantity: 0.5%
- Price: 2%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 03:47:19 -06:00
parent f63c17df5c
commit af3cc5a25d
16 changed files with 2678 additions and 0 deletions

View File

@ -0,0 +1,145 @@
/**
* DTO para crear un extracto bancario con sus lineas
*/
export interface CreateBankStatementLineDto {
/** Fecha de la transaccion (YYYY-MM-DD) */
transaction_date: string;
/** Fecha valor opcional (YYYY-MM-DD) */
value_date?: string;
/** Descripcion del movimiento */
description?: string;
/** Referencia del movimiento (numero de cheque, transferencia, etc.) */
reference?: string;
/** Monto del movimiento (positivo=deposito, negativo=retiro) */
amount: number;
/** ID del partner si se conoce */
partner_id?: string;
/** Notas adicionales */
notes?: string;
}
/**
* DTO para crear un extracto bancario completo
*/
export interface CreateBankStatementDto {
/** ID de la compania */
company_id?: string;
/** ID de la cuenta bancaria (cuenta contable tipo banco) */
bank_account_id?: string;
/** Fecha del extracto (YYYY-MM-DD) */
statement_date: string;
/** Saldo de apertura */
opening_balance: number;
/** Saldo de cierre */
closing_balance: number;
/** Lineas del extracto */
lines: CreateBankStatementLineDto[];
}
/**
* DTO para actualizar un extracto bancario
*/
export interface UpdateBankStatementDto {
/** ID de la cuenta bancaria */
bank_account_id?: string;
/** Fecha del extracto */
statement_date?: string;
/** Saldo de apertura */
opening_balance?: number;
/** Saldo de cierre */
closing_balance?: number;
}
/**
* DTO para agregar lineas a un extracto existente
*/
export interface AddBankStatementLinesDto {
/** Lineas a agregar */
lines: CreateBankStatementLineDto[];
}
/**
* Filtros para buscar extractos bancarios
*/
export interface BankStatementFilters {
/** ID de la compania */
company_id?: string;
/** ID de la cuenta bancaria */
bank_account_id?: string;
/** Estado del extracto */
status?: 'draft' | 'reconciling' | 'reconciled';
/** Fecha desde (YYYY-MM-DD) */
date_from?: string;
/** Fecha hasta (YYYY-MM-DD) */
date_to?: string;
/** Pagina actual */
page?: number;
/** Limite de resultados */
limit?: number;
}
/**
* Respuesta con extracto y sus lineas
*/
export interface BankStatementWithLines {
id: string;
tenant_id: string;
company_id: string | null;
bank_account_id: string | null;
bank_account_name?: string;
statement_date: Date;
opening_balance: number;
closing_balance: number;
calculated_balance?: number;
status: 'draft' | 'reconciling' | 'reconciled';
imported_at: Date | null;
imported_by: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
created_at: Date;
reconciliation_progress?: number;
lines: BankStatementLineResponse[];
}
/**
* Respuesta de linea de extracto
*/
export interface BankStatementLineResponse {
id: string;
statement_id: string;
transaction_date: Date;
value_date: Date | null;
description: string | null;
reference: string | null;
amount: number;
is_reconciled: boolean;
reconciled_entry_id: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
partner_id: string | null;
partner_name?: string;
notes: string | null;
created_at: Date;
/** Posibles matches encontrados por auto-reconcile */
suggested_matches?: SuggestedMatch[];
}
/**
* Match sugerido para una linea
*/
export interface SuggestedMatch {
/** ID de la linea de asiento */
entry_line_id: string;
/** ID del asiento */
entry_id: string;
/** Referencia del asiento */
entry_ref: string | null;
/** Fecha del asiento */
entry_date: Date;
/** Monto de la linea */
amount: number;
/** Tipo de match */
match_type: 'exact_amount' | 'amount_date' | 'reference' | 'partner';
/** Confianza del match (0-100) */
confidence: number;
}

View File

@ -0,0 +1,6 @@
/**
* DTOs para el modulo de conciliacion bancaria
*/
export * from './create-bank-statement.dto.js';
export * from './reconcile-line.dto.js';

View File

@ -0,0 +1,171 @@
/**
* DTO para conciliar una linea de extracto con una linea de asiento
*/
export interface ReconcileLineDto {
/** ID de la linea de asiento contable a conciliar */
entry_line_id: string;
}
/**
* DTO para conciliar multiples lineas en batch
*/
export interface BatchReconcileDto {
/** Array de pares linea-extracto con linea-asiento */
reconciliations: {
/** ID de la linea de extracto */
statement_line_id: string;
/** ID de la linea de asiento */
entry_line_id: string;
}[];
}
/**
* DTO para crear un asiento y conciliar automaticamente
* Util cuando no existe asiento previo
*/
export interface CreateAndReconcileDto {
/** ID de la cuenta contable destino */
account_id: string;
/** ID del diario a usar */
journal_id: string;
/** ID del partner (opcional) */
partner_id?: string;
/** Referencia para el asiento */
ref?: string;
/** Notas adicionales */
notes?: string;
}
/**
* Resultado de operacion de conciliacion
*/
export interface ReconcileResult {
/** Exito de la operacion */
success: boolean;
/** ID de la linea de extracto */
statement_line_id: string;
/** ID de la linea de asiento conciliada */
entry_line_id: string | null;
/** Mensaje de error si fallo */
error?: string;
}
/**
* Resultado de auto-reconciliacion
*/
export interface AutoReconcileResult {
/** Total de lineas procesadas */
total_lines: number;
/** Lineas conciliadas automaticamente */
reconciled_count: number;
/** Lineas que no pudieron conciliarse */
unreconciled_count: number;
/** Detalle de conciliaciones realizadas */
reconciled_lines: {
statement_line_id: string;
entry_line_id: string;
match_type: string;
confidence: number;
}[];
/** Lineas con sugerencias pero sin match automatico */
lines_with_suggestions: {
statement_line_id: string;
suggestions: number;
}[];
}
/**
* DTO para buscar lineas de asiento candidatas a conciliar
*/
export interface FindMatchCandidatesDto {
/** Monto a buscar */
amount: number;
/** Fecha aproximada */
date?: string;
/** Referencia a buscar */
reference?: string;
/** ID del partner */
partner_id?: string;
/** Tolerancia de monto (porcentaje, ej: 0.01 = 1%) */
amount_tolerance?: number;
/** Tolerancia de dias */
date_tolerance_days?: number;
/** Limite de resultados */
limit?: number;
}
/**
* Respuesta de busqueda de candidatos
*/
export interface MatchCandidate {
/** ID de la linea de asiento */
id: string;
/** ID del asiento */
entry_id: string;
/** Nombre/numero del asiento */
entry_name: string;
/** Referencia del asiento */
entry_ref: string | null;
/** Fecha del asiento */
entry_date: Date;
/** ID de la cuenta */
account_id: string;
/** Codigo de la cuenta */
account_code: string;
/** Nombre de la cuenta */
account_name: string;
/** Monto al debe */
debit: number;
/** Monto al haber */
credit: number;
/** Monto neto (debit - credit) */
net_amount: number;
/** Descripcion de la linea */
description: string | null;
/** ID del partner */
partner_id: string | null;
/** Nombre del partner */
partner_name: string | null;
/** Tipo de match */
match_type: 'exact_amount' | 'amount_date' | 'reference' | 'partner' | 'rule';
/** Confianza del match */
confidence: number;
}
/**
* DTO para crear/actualizar regla de conciliacion
*/
export interface CreateReconciliationRuleDto {
/** Nombre de la regla */
name: string;
/** ID de la compania (opcional) */
company_id?: string;
/** Tipo de match */
match_type: 'exact_amount' | 'reference_contains' | 'partner_name';
/** Valor a matchear */
match_value: string;
/** Cuenta destino para auto-crear asiento */
auto_account_id?: string;
/** Prioridad (mayor = primero) */
priority?: number;
/** Activa o no */
is_active?: boolean;
}
/**
* DTO para actualizar regla de conciliacion
*/
export interface UpdateReconciliationRuleDto {
/** Nombre de la regla */
name?: string;
/** Tipo de match */
match_type?: 'exact_amount' | 'reference_contains' | 'partner_name';
/** Valor a matchear */
match_value?: string;
/** Cuenta destino */
auto_account_id?: string | null;
/** Prioridad */
priority?: number;
/** Activa o no */
is_active?: boolean;
}

View File

@ -0,0 +1,93 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Account } from './account.entity.js';
/**
* Tipo de regla de match para conciliacion automatica
*/
export type ReconciliationMatchType = 'exact_amount' | 'reference_contains' | 'partner_name';
/**
* Entity: BankReconciliationRule
* Reglas para conciliacion automatica de movimientos bancarios
* Schema: financial
* Table: bank_reconciliation_rules
*/
@Entity({ schema: 'financial', name: 'bank_reconciliation_rules' })
@Index('idx_bank_reconciliation_rules_tenant_id', ['tenantId'])
@Index('idx_bank_reconciliation_rules_company_id', ['companyId'])
@Index('idx_bank_reconciliation_rules_is_active', ['isActive'])
@Index('idx_bank_reconciliation_rules_match_type', ['matchType'])
@Index('idx_bank_reconciliation_rules_priority', ['priority'])
export class BankReconciliationRule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({
type: 'varchar',
length: 50,
nullable: false,
name: 'match_type',
})
matchType: ReconciliationMatchType;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'match_value' })
matchValue: string;
@Column({ type: 'uuid', nullable: true, name: 'auto_account_id' })
autoAccountId: string | null;
@Column({
type: 'boolean',
default: true,
nullable: false,
name: 'is_active',
})
isActive: boolean;
@Column({
type: 'integer',
default: 0,
nullable: false,
})
priority: number;
// Relations
@ManyToOne(() => Account, { nullable: true })
@JoinColumn({ name: 'auto_account_id' })
autoAccount: Account | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp with time zone',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -0,0 +1,93 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { BankStatement } from './bank-statement.entity.js';
import { JournalEntryLine } from './journal-entry-line.entity.js';
/**
* Entity: BankStatementLine
* Lineas/movimientos del extracto bancario
* Schema: financial
* Table: bank_statement_lines
*/
@Entity({ schema: 'financial', name: 'bank_statement_lines' })
@Index('idx_bank_statement_lines_statement_id', ['statementId'])
@Index('idx_bank_statement_lines_tenant_id', ['tenantId'])
@Index('idx_bank_statement_lines_transaction_date', ['transactionDate'])
@Index('idx_bank_statement_lines_is_reconciled', ['isReconciled'])
@Index('idx_bank_statement_lines_reconciled_entry_id', ['reconciledEntryId'])
@Index('idx_bank_statement_lines_partner_id', ['partnerId'])
export class BankStatementLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'statement_id' })
statementId: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'date', nullable: false, name: 'transaction_date' })
transactionDate: Date;
@Column({ type: 'date', nullable: true, name: 'value_date' })
valueDate: Date | null;
@Column({ type: 'varchar', length: 500, nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
reference: string | null;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
nullable: false,
})
amount: number;
@Column({
type: 'boolean',
default: false,
nullable: false,
name: 'is_reconciled',
})
isReconciled: boolean;
@Column({ type: 'uuid', nullable: true, name: 'reconciled_entry_id' })
reconciledEntryId: string | null;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'reconciled_at' })
reconciledAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'reconciled_by' })
reconciledBy: string | null;
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
partnerId: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
// Relations
@ManyToOne(() => BankStatement, (statement) => statement.lines, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'statement_id' })
statement: BankStatement;
@ManyToOne(() => JournalEntryLine, { nullable: true })
@JoinColumn({ name: 'reconciled_entry_id' })
reconciledEntry: JournalEntryLine | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
}

View File

@ -0,0 +1,111 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { Account } from './account.entity.js';
/**
* Estado del extracto bancario
*/
export type BankStatementStatus = 'draft' | 'reconciling' | 'reconciled';
/**
* Entity: BankStatement
* Extractos bancarios importados para conciliacion
* Schema: financial
* Table: bank_statements
*/
@Entity({ schema: 'financial', name: 'bank_statements' })
@Index('idx_bank_statements_tenant_id', ['tenantId'])
@Index('idx_bank_statements_company_id', ['companyId'])
@Index('idx_bank_statements_bank_account_id', ['bankAccountId'])
@Index('idx_bank_statements_statement_date', ['statementDate'])
@Index('idx_bank_statements_status', ['status'])
export class BankStatement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'bank_account_id' })
bankAccountId: string | null;
@Column({ type: 'date', nullable: false, name: 'statement_date' })
statementDate: Date;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
default: 0,
nullable: false,
name: 'opening_balance',
})
openingBalance: number;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
default: 0,
nullable: false,
name: 'closing_balance',
})
closingBalance: number;
@Column({
type: 'varchar',
length: 20,
default: 'draft',
nullable: false,
})
status: BankStatementStatus;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'imported_at' })
importedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'imported_by' })
importedBy: string | null;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'reconciled_at' })
reconciledAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'reconciled_by' })
reconciledBy: string | null;
// Relations
@ManyToOne(() => Account)
@JoinColumn({ name: 'bank_account_id' })
bankAccount: Account | null;
@OneToMany('BankStatementLine', 'statement')
lines: import('./bank-statement-line.entity.js').BankStatementLine[];
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp with time zone',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -0,0 +1,810 @@
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import {
CreateBankStatementDto,
BankStatementFilters,
BankStatementWithLines,
BankStatementLineResponse,
SuggestedMatch,
} from '../dto/create-bank-statement.dto.js';
import {
ReconcileResult,
AutoReconcileResult,
MatchCandidate,
FindMatchCandidatesDto,
CreateReconciliationRuleDto,
UpdateReconciliationRuleDto,
} from '../dto/reconcile-line.dto.js';
/**
* Representacion de extracto bancario
*/
export interface BankStatement {
id: string;
tenant_id: string;
company_id: string | null;
bank_account_id: string | null;
bank_account_name?: string;
statement_date: Date;
opening_balance: number;
closing_balance: number;
status: 'draft' | 'reconciling' | 'reconciled';
imported_at: Date | null;
imported_by: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
created_at: Date;
}
/**
* Representacion de regla de conciliacion
*/
export interface ReconciliationRule {
id: string;
tenant_id: string;
company_id: string | null;
name: string;
match_type: 'exact_amount' | 'reference_contains' | 'partner_name';
match_value: string;
auto_account_id: string | null;
auto_account_name?: string;
is_active: boolean;
priority: number;
created_at: Date;
}
/**
* Servicio para conciliacion bancaria
*/
class BankReconciliationService {
// ==========================================
// EXTRACTOS BANCARIOS
// ==========================================
/**
* Importar un extracto bancario con sus lineas
*/
async importStatement(
dto: CreateBankStatementDto,
tenantId: string,
userId: string
): Promise<BankStatement> {
// Validaciones
if (!dto.lines || dto.lines.length === 0) {
throw new ValidationError('El extracto debe tener al menos una linea');
}
// Validar que el balance cuadre
const calculatedClosing = dto.opening_balance + dto.lines.reduce((sum, line) => sum + line.amount, 0);
if (Math.abs(calculatedClosing - dto.closing_balance) > 0.01) {
throw new ValidationError(
`El balance no cuadra. Apertura (${dto.opening_balance}) + Movimientos (${dto.lines.reduce((s, l) => s + l.amount, 0)}) = ${calculatedClosing}, pero cierre declarado es ${dto.closing_balance}`
);
}
const client = await getClient();
try {
await client.query('BEGIN');
// Crear el extracto
const statementResult = await client.query<BankStatement>(
`INSERT INTO financial.bank_statements (
tenant_id, company_id, bank_account_id, statement_date,
opening_balance, closing_balance, status,
imported_at, imported_by, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', CURRENT_TIMESTAMP, $7, $7)
RETURNING *`,
[
tenantId,
dto.company_id || null,
dto.bank_account_id || null,
dto.statement_date,
dto.opening_balance,
dto.closing_balance,
userId,
]
);
const statement = statementResult.rows[0];
// Insertar las lineas
for (const line of dto.lines) {
await client.query(
`INSERT INTO financial.bank_statement_lines (
statement_id, tenant_id, transaction_date, value_date,
description, reference, amount, partner_id, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
statement.id,
tenantId,
line.transaction_date,
line.value_date || null,
line.description || null,
line.reference || null,
line.amount,
line.partner_id || null,
line.notes || null,
]
);
}
await client.query('COMMIT');
return statement;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Obtener lista de extractos con filtros
*/
async findAll(
tenantId: string,
filters: BankStatementFilters = {}
): Promise<{ data: BankStatement[]; total: number }> {
const { company_id, bank_account_id, status, date_from, date_to, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE bs.tenant_id = $1';
const params: unknown[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND bs.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (bank_account_id) {
whereClause += ` AND bs.bank_account_id = $${paramIndex++}`;
params.push(bank_account_id);
}
if (status) {
whereClause += ` AND bs.status = $${paramIndex++}`;
params.push(status);
}
if (date_from) {
whereClause += ` AND bs.statement_date >= $${paramIndex++}`;
params.push(date_from);
}
if (date_to) {
whereClause += ` AND bs.statement_date <= $${paramIndex++}`;
params.push(date_to);
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM financial.bank_statements bs ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<BankStatement>(
`SELECT bs.*,
a.name as bank_account_name,
financial.get_reconciliation_progress(bs.id) as reconciliation_progress
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
${whereClause}
ORDER BY bs.statement_date DESC, bs.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
/**
* Obtener un extracto con todas sus lineas
*/
async getStatementWithLines(id: string, tenantId: string): Promise<BankStatementWithLines> {
const statement = await queryOne<BankStatementWithLines>(
`SELECT bs.*,
a.name as bank_account_name,
financial.get_reconciliation_progress(bs.id) as reconciliation_progress
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
WHERE bs.id = $1 AND bs.tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
// Obtener lineas
const lines = await query<BankStatementLineResponse>(
`SELECT bsl.*,
p.name as partner_name
FROM financial.bank_statement_lines bsl
LEFT JOIN core.partners p ON bsl.partner_id = p.id
WHERE bsl.statement_id = $1
ORDER BY bsl.transaction_date, bsl.created_at`,
[id]
);
// Calcular balance calculado
const calculatedBalance =
Number(statement.opening_balance) + lines.reduce((sum, line) => sum + Number(line.amount), 0);
statement.lines = lines;
statement.calculated_balance = calculatedBalance;
return statement;
}
/**
* Eliminar un extracto (solo en estado draft)
*/
async deleteStatement(id: string, tenantId: string): Promise<void> {
const statement = await queryOne<BankStatement>(
`SELECT * FROM financial.bank_statements WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
if (statement.status !== 'draft') {
throw new ValidationError('Solo se pueden eliminar extractos en estado borrador');
}
await query(`DELETE FROM financial.bank_statements WHERE id = $1`, [id]);
}
// ==========================================
// CONCILIACION
// ==========================================
/**
* Ejecutar auto-conciliacion de un extracto
* Busca matches automaticos por monto, fecha y referencia
*/
async autoReconcile(statementId: string, tenantId: string, userId: string): Promise<AutoReconcileResult> {
const statement = await this.getStatementWithLines(statementId, tenantId);
if (statement.status === 'reconciled') {
throw new ValidationError('El extracto ya esta completamente conciliado');
}
// Cambiar estado a reconciling si esta en draft
if (statement.status === 'draft') {
await query(
`UPDATE financial.bank_statements SET status = 'reconciling', updated_by = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[userId, statementId]
);
}
const result: AutoReconcileResult = {
total_lines: statement.lines.length,
reconciled_count: 0,
unreconciled_count: 0,
reconciled_lines: [],
lines_with_suggestions: [],
};
// Obtener reglas activas
const rules = await query<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules
WHERE tenant_id = $1 AND is_active = true
ORDER BY priority DESC`,
[tenantId]
);
// Procesar cada linea no conciliada
for (const line of statement.lines) {
if (line.is_reconciled) {
continue;
}
// Buscar candidatos a match
const candidates = await this.findMatchCandidates(
{
amount: Math.abs(Number(line.amount)),
date: line.transaction_date.toString(),
reference: line.reference || undefined,
partner_id: line.partner_id || undefined,
amount_tolerance: 0,
date_tolerance_days: 3,
limit: 5,
},
tenantId,
statement.bank_account_id || undefined
);
// Aplicar reglas personalizadas
for (const rule of rules) {
const ruleMatch = this.applyRule(rule, line);
if (ruleMatch) {
// Si la regla tiene cuenta auto, se podria crear asiento automatico
// Por ahora solo marcamos como sugerencia
}
}
// Si hay un match con confianza >= 90%, conciliar automaticamente
const exactMatch = candidates.find((c) => c.confidence >= 90);
if (exactMatch) {
try {
await this.reconcileLine(line.id, exactMatch.id, tenantId, userId);
result.reconciled_count++;
result.reconciled_lines.push({
statement_line_id: line.id,
entry_line_id: exactMatch.id,
match_type: exactMatch.match_type,
confidence: exactMatch.confidence,
});
} catch {
// Si falla, agregar a sugerencias
result.lines_with_suggestions.push({
statement_line_id: line.id,
suggestions: candidates.length,
});
result.unreconciled_count++;
}
} else if (candidates.length > 0) {
result.lines_with_suggestions.push({
statement_line_id: line.id,
suggestions: candidates.length,
});
result.unreconciled_count++;
} else {
result.unreconciled_count++;
}
}
return result;
}
/**
* Buscar lineas de asiento candidatas a conciliar
*/
async findMatchCandidates(
dto: FindMatchCandidatesDto,
tenantId: string,
bankAccountId?: string
): Promise<MatchCandidate[]> {
const { amount, date, reference, partner_id, amount_tolerance = 0, date_tolerance_days = 3, limit = 10 } = dto;
let whereClause = `
WHERE jel.tenant_id = $1
AND je.status = 'posted'
AND NOT EXISTS (
SELECT 1 FROM financial.bank_statement_lines bsl
WHERE bsl.reconciled_entry_id = jel.id
)
`;
const params: unknown[] = [tenantId];
let paramIndex = 2;
// Filtrar por cuenta bancaria si se especifica
if (bankAccountId) {
whereClause += ` AND jel.account_id = $${paramIndex++}`;
params.push(bankAccountId);
}
// Filtrar por monto con tolerancia
const amountMin = amount * (1 - amount_tolerance);
const amountMax = amount * (1 + amount_tolerance);
whereClause += ` AND (
(jel.debit BETWEEN $${paramIndex} AND $${paramIndex + 1})
OR (jel.credit BETWEEN $${paramIndex} AND $${paramIndex + 1})
)`;
params.push(amountMin, amountMax);
paramIndex += 2;
// Filtrar por fecha con tolerancia
if (date) {
whereClause += ` AND je.date BETWEEN ($${paramIndex}::date - interval '${date_tolerance_days} days') AND ($${paramIndex}::date + interval '${date_tolerance_days} days')`;
params.push(date);
paramIndex++;
}
// Filtrar por partner si se especifica
if (partner_id) {
whereClause += ` AND jel.partner_id = $${paramIndex++}`;
params.push(partner_id);
}
params.push(limit);
const candidates = await query<MatchCandidate>(
`SELECT
jel.id,
jel.entry_id,
je.name as entry_name,
je.ref as entry_ref,
je.date as entry_date,
jel.account_id,
a.code as account_code,
a.name as account_name,
jel.debit,
jel.credit,
(jel.debit - jel.credit) as net_amount,
jel.description,
jel.partner_id,
p.name as partner_name
FROM financial.journal_entry_lines jel
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
INNER JOIN financial.accounts a ON jel.account_id = a.id
LEFT JOIN core.partners p ON jel.partner_id = p.id
${whereClause}
ORDER BY je.date DESC
LIMIT $${paramIndex}`,
params
);
// Calcular confianza y tipo de match para cada candidato
return candidates.map((c) => {
let confidence = 50; // Base
let matchType: MatchCandidate['match_type'] = 'exact_amount';
const candidateAmount = Math.abs(Number(c.debit) - Number(c.credit));
// Match exacto de monto
if (Math.abs(candidateAmount - amount) < 0.01) {
confidence += 30;
matchType = 'exact_amount';
}
// Match de fecha exacta
if (date && c.entry_date.toString().substring(0, 10) === date.substring(0, 10)) {
confidence += 15;
matchType = 'amount_date';
}
// Match de referencia
if (reference && c.entry_ref && c.entry_ref.toLowerCase().includes(reference.toLowerCase())) {
confidence += 20;
matchType = 'reference';
}
// Match de partner
if (partner_id && c.partner_id === partner_id) {
confidence += 15;
matchType = 'partner';
}
return {
...c,
match_type: matchType,
confidence: Math.min(100, confidence),
};
});
}
/**
* Conciliar manualmente una linea de extracto con una linea de asiento
*/
async reconcileLine(
lineId: string,
entryLineId: string,
tenantId: string,
userId: string
): Promise<ReconcileResult> {
// Verificar que la linea de extracto existe y no esta conciliada
const line = await queryOne<BankStatementLineResponse>(
`SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`,
[lineId, tenantId]
);
if (!line) {
throw new NotFoundError('Linea de extracto no encontrada');
}
if (line.is_reconciled) {
throw new ValidationError('La linea ya esta conciliada');
}
// Verificar que la linea de asiento existe y no esta conciliada con otra linea
const entryLine = await queryOne<{ id: string; debit: number; credit: number }>(
`SELECT jel.* FROM financial.journal_entry_lines jel
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
WHERE jel.id = $1 AND jel.tenant_id = $2 AND je.status = 'posted'`,
[entryLineId, tenantId]
);
if (!entryLine) {
throw new NotFoundError('Linea de asiento no encontrada o no publicada');
}
// Verificar que no este ya conciliada
const alreadyReconciled = await queryOne<{ id: string }>(
`SELECT id FROM financial.bank_statement_lines WHERE reconciled_entry_id = $1`,
[entryLineId]
);
if (alreadyReconciled) {
throw new ValidationError('La linea de asiento ya esta conciliada con otra linea de extracto');
}
// Actualizar la linea de extracto
await query(
`UPDATE financial.bank_statement_lines SET
is_reconciled = true,
reconciled_entry_id = $1,
reconciled_at = CURRENT_TIMESTAMP,
reconciled_by = $2
WHERE id = $3`,
[entryLineId, userId, lineId]
);
return {
success: true,
statement_line_id: lineId,
entry_line_id: entryLineId,
};
}
/**
* Deshacer la conciliacion de una linea
*/
async unreconcileLine(lineId: string, tenantId: string): Promise<void> {
const line = await queryOne<BankStatementLineResponse>(
`SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`,
[lineId, tenantId]
);
if (!line) {
throw new NotFoundError('Linea de extracto no encontrada');
}
if (!line.is_reconciled) {
throw new ValidationError('La linea no esta conciliada');
}
await query(
`UPDATE financial.bank_statement_lines SET
is_reconciled = false,
reconciled_entry_id = NULL,
reconciled_at = NULL,
reconciled_by = NULL
WHERE id = $1`,
[lineId]
);
}
/**
* Cerrar un extracto completamente conciliado
*/
async closeStatement(statementId: string, tenantId: string, userId: string): Promise<BankStatement> {
const statement = await this.getStatementWithLines(statementId, tenantId);
if (statement.status === 'reconciled') {
throw new ValidationError('El extracto ya esta cerrado');
}
// Verificar que todas las lineas esten conciliadas
const unreconciledLines = statement.lines.filter((l) => !l.is_reconciled);
if (unreconciledLines.length > 0) {
throw new ValidationError(
`No se puede cerrar el extracto. Hay ${unreconciledLines.length} linea(s) sin conciliar`
);
}
await query(
`UPDATE financial.bank_statements SET
status = 'reconciled',
reconciled_at = CURRENT_TIMESTAMP,
reconciled_by = $1,
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[userId, statementId]
);
return this.findById(statementId, tenantId);
}
/**
* Obtener un extracto por ID
*/
async findById(id: string, tenantId: string): Promise<BankStatement> {
const statement = await queryOne<BankStatement>(
`SELECT bs.*, a.name as bank_account_name
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
WHERE bs.id = $1 AND bs.tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
return statement;
}
// ==========================================
// REGLAS DE CONCILIACION
// ==========================================
/**
* Crear una regla de conciliacion
*/
async createRule(dto: CreateReconciliationRuleDto, tenantId: string, userId: string): Promise<ReconciliationRule> {
const result = await queryOne<ReconciliationRule>(
`INSERT INTO financial.bank_reconciliation_rules (
tenant_id, company_id, name, match_type, match_value,
auto_account_id, priority, is_active, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
tenantId,
dto.company_id || null,
dto.name,
dto.match_type,
dto.match_value,
dto.auto_account_id || null,
dto.priority || 0,
dto.is_active !== false,
userId,
]
);
return result!;
}
/**
* Obtener reglas de conciliacion
*/
async findRules(tenantId: string, companyId?: string): Promise<ReconciliationRule[]> {
let whereClause = 'WHERE r.tenant_id = $1';
const params: unknown[] = [tenantId];
if (companyId) {
whereClause += ' AND (r.company_id = $2 OR r.company_id IS NULL)';
params.push(companyId);
}
return query<ReconciliationRule>(
`SELECT r.*, a.name as auto_account_name
FROM financial.bank_reconciliation_rules r
LEFT JOIN financial.accounts a ON r.auto_account_id = a.id
${whereClause}
ORDER BY r.priority DESC, r.name`,
params
);
}
/**
* Actualizar una regla de conciliacion
*/
async updateRule(
id: string,
dto: UpdateReconciliationRuleDto,
tenantId: string,
userId: string
): Promise<ReconciliationRule> {
const existing = await queryOne<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
const updateFields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(dto.name);
}
if (dto.match_type !== undefined) {
updateFields.push(`match_type = $${paramIndex++}`);
values.push(dto.match_type);
}
if (dto.match_value !== undefined) {
updateFields.push(`match_value = $${paramIndex++}`);
values.push(dto.match_value);
}
if (dto.auto_account_id !== undefined) {
updateFields.push(`auto_account_id = $${paramIndex++}`);
values.push(dto.auto_account_id);
}
if (dto.priority !== undefined) {
updateFields.push(`priority = $${paramIndex++}`);
values.push(dto.priority);
}
if (dto.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(dto.is_active);
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id, tenantId);
await query(
`UPDATE financial.bank_reconciliation_rules SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findRuleById(id, tenantId);
}
/**
* Obtener regla por ID
*/
async findRuleById(id: string, tenantId: string): Promise<ReconciliationRule> {
const rule = await queryOne<ReconciliationRule>(
`SELECT r.*, a.name as auto_account_name
FROM financial.bank_reconciliation_rules r
LEFT JOIN financial.accounts a ON r.auto_account_id = a.id
WHERE r.id = $1 AND r.tenant_id = $2`,
[id, tenantId]
);
if (!rule) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
return rule;
}
/**
* Eliminar una regla
*/
async deleteRule(id: string, tenantId: string): Promise<void> {
const existing = await queryOne<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
await query(`DELETE FROM financial.bank_reconciliation_rules WHERE id = $1`, [id]);
}
// ==========================================
// HELPERS
// ==========================================
/**
* Aplicar una regla a una linea de extracto
*/
private applyRule(
rule: ReconciliationRule,
line: BankStatementLineResponse
): boolean {
switch (rule.match_type) {
case 'exact_amount':
return Math.abs(Number(line.amount)) === parseFloat(rule.match_value);
case 'reference_contains':
return line.reference?.toLowerCase().includes(rule.match_value.toLowerCase()) || false;
case 'partner_name':
// Esto requeriria el nombre del partner, que ya esta en partner_name
return line.partner_name?.toLowerCase().includes(rule.match_value.toLowerCase()) || false;
default:
return false;
}
}
}
export const bankReconciliationService = new BankReconciliationService();

View File

@ -37,3 +37,7 @@ export class UpdatePurchaseOrderDto {
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsEnum(['draft', 'sent', 'confirmed', 'partial', 'received', 'cancelled']) status?: string;
}
// 3-Way Matching DTOs
export * from './matching-status.dto';
export * from './resolve-exception.dto';

View File

@ -0,0 +1,180 @@
import { IsString, IsOptional, IsNumber, IsUUID, IsArray, ValidateNested, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { MatchingStatus } from '../entities/purchase-order-matching.entity';
import { MatchingLineStatus } from '../entities/purchase-matching-line.entity';
import { ExceptionType, ExceptionStatus } from '../entities/matching-exception.entity';
/**
* DTO for matching line status information
*/
export class MatchingLineStatusDto {
@IsUUID()
id: string;
@IsUUID()
orderItemId: string;
@IsString()
productName: string;
@IsNumber()
qtyOrdered: number;
@IsNumber()
qtyReceived: number;
@IsNumber()
qtyInvoiced: number;
@IsNumber()
priceOrdered: number;
@IsNumber()
priceInvoiced: number;
@IsNumber()
qtyVariance: number;
@IsNumber()
invoiceQtyVariance: number;
@IsNumber()
priceVariance: number;
@IsString()
status: MatchingLineStatus;
}
/**
* DTO for exception information
*/
export class MatchingExceptionDto {
@IsUUID()
id: string;
@IsEnum(['over_receipt', 'short_receipt', 'over_invoice', 'short_invoice', 'price_variance'])
exceptionType: ExceptionType;
@IsOptional()
@IsNumber()
expectedValue?: number;
@IsOptional()
@IsNumber()
actualValue?: number;
@IsOptional()
@IsNumber()
varianceValue?: number;
@IsOptional()
@IsNumber()
variancePercent?: number;
@IsEnum(['pending', 'approved', 'rejected'])
status: ExceptionStatus;
@IsOptional()
@IsString()
resolvedAt?: string;
@IsOptional()
@IsString()
resolutionNotes?: string;
}
/**
* DTO for complete matching status response
*/
export class MatchingStatusDto {
@IsUUID()
id: string;
@IsUUID()
purchaseOrderId: string;
@IsString()
orderNumber: string;
@IsEnum(['pending', 'partial_receipt', 'received', 'partial_invoice', 'matched', 'mismatch'])
status: MatchingStatus;
@IsNumber()
totalOrdered: number;
@IsNumber()
totalReceived: number;
@IsNumber()
totalInvoiced: number;
@IsNumber()
receiptVariance: number;
@IsNumber()
invoiceVariance: number;
@IsOptional()
@IsUUID()
lastReceiptId?: string;
@IsOptional()
@IsUUID()
lastInvoiceId?: string;
@IsOptional()
@IsString()
matchedAt?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => MatchingLineStatusDto)
lines: MatchingLineStatusDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => MatchingExceptionDto)
exceptions: MatchingExceptionDto[];
@IsNumber()
pendingExceptionsCount: number;
}
/**
* DTO for matching summary (list view)
*/
export class MatchingSummaryDto {
@IsUUID()
id: string;
@IsUUID()
purchaseOrderId: string;
@IsString()
orderNumber: string;
@IsString()
supplierName: string;
@IsEnum(['pending', 'partial_receipt', 'received', 'partial_invoice', 'matched', 'mismatch'])
status: MatchingStatus;
@IsNumber()
totalOrdered: number;
@IsNumber()
totalReceived: number;
@IsNumber()
totalInvoiced: number;
@IsNumber()
pendingExceptionsCount: number;
@IsString()
createdAt: string;
@IsOptional()
@IsString()
matchedAt?: string;
}

View File

@ -0,0 +1,31 @@
import { IsString, IsOptional, IsEnum, MaxLength } from 'class-validator';
import { ExceptionStatus } from '../entities/matching-exception.entity';
/**
* DTO for resolving a matching exception
*/
export class ResolveExceptionDto {
@IsEnum(['approved', 'rejected'])
resolution: Exclude<ExceptionStatus, 'pending'>;
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}
/**
* DTO for bulk resolving exceptions
*/
export class BulkResolveExceptionsDto {
@IsString({ each: true })
exceptionIds: string[];
@IsEnum(['approved', 'rejected'])
resolution: Exclude<ExceptionStatus, 'pending'>;
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}

View File

@ -2,3 +2,6 @@ export { PurchaseOrder } from './purchase-order.entity';
export { PurchaseOrderItem } from './purchase-order-item.entity';
export { PurchaseReceipt } from './purchase-receipt.entity';
export { PurchaseReceiptItem } from './purchase-receipt-item.entity';
export { PurchaseOrderMatching, MatchingStatus } from './purchase-order-matching.entity';
export { PurchaseMatchingLine, MatchingLineStatus } from './purchase-matching-line.entity';
export { MatchingException, ExceptionType, ExceptionStatus } from './matching-exception.entity';

View File

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { PurchaseOrderMatching } from './purchase-order-matching.entity';
import { PurchaseMatchingLine } from './purchase-matching-line.entity';
export type ExceptionType =
| 'over_receipt'
| 'short_receipt'
| 'over_invoice'
| 'short_invoice'
| 'price_variance';
export type ExceptionStatus = 'pending' | 'approved' | 'rejected';
@Entity({ name: 'matching_exceptions', schema: 'purchases' })
export class MatchingException {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'matching_id', type: 'uuid', nullable: true })
matchingId?: string;
@ManyToOne(() => PurchaseOrderMatching, (matching) => matching.exceptions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'matching_id' })
matching?: PurchaseOrderMatching;
@Index()
@Column({ name: 'matching_line_id', type: 'uuid', nullable: true })
matchingLineId?: string;
@ManyToOne(() => PurchaseMatchingLine, (line) => line.exceptions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'matching_line_id' })
matchingLine?: PurchaseMatchingLine;
@Index()
@Column({ name: 'exception_type', type: 'varchar', length: 50 })
exceptionType: ExceptionType;
@Column({ name: 'expected_value', type: 'decimal', precision: 15, scale: 4, nullable: true })
expectedValue?: number;
@Column({ name: 'actual_value', type: 'decimal', precision: 15, scale: 4, nullable: true })
actualValue?: number;
@Column({ name: 'variance_value', type: 'decimal', precision: 15, scale: 4, nullable: true })
varianceValue?: number;
@Column({ name: 'variance_percent', type: 'decimal', precision: 5, scale: 2, nullable: true })
variancePercent?: number;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: ExceptionStatus;
@Column({ name: 'resolved_at', type: 'timestamptz', nullable: true })
resolvedAt?: Date;
@Column({ name: 'resolved_by', type: 'uuid', nullable: true })
resolvedBy?: string;
@Column({ name: 'resolution_notes', type: 'text', nullable: true })
resolutionNotes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,103 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { PurchaseOrderMatching } from './purchase-order-matching.entity';
import { PurchaseOrderItem } from './purchase-order-item.entity';
import { MatchingException } from './matching-exception.entity';
export type MatchingLineStatus = 'pending' | 'partial' | 'matched' | 'mismatch';
@Entity({ name: 'purchase_matching_lines', schema: 'purchases' })
export class PurchaseMatchingLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'matching_id', type: 'uuid' })
matchingId: string;
@ManyToOne(() => PurchaseOrderMatching, (matching) => matching.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'matching_id' })
matching: PurchaseOrderMatching;
@Index()
@Column({ name: 'order_item_id', type: 'uuid' })
orderItemId: string;
@ManyToOne(() => PurchaseOrderItem, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'order_item_id' })
orderItem: PurchaseOrderItem;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Quantities
@Column({ name: 'qty_ordered', type: 'decimal', precision: 15, scale: 4 })
qtyOrdered: number;
@Column({ name: 'qty_received', type: 'decimal', precision: 15, scale: 4, default: 0 })
qtyReceived: number;
@Column({ name: 'qty_invoiced', type: 'decimal', precision: 15, scale: 4, default: 0 })
qtyInvoiced: number;
// Prices
@Column({ name: 'price_ordered', type: 'decimal', precision: 15, scale: 2 })
priceOrdered: number;
@Column({ name: 'price_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 })
priceInvoiced: number;
// Generated columns (read-only in TypeORM)
@Column({
name: 'qty_variance',
type: 'decimal',
precision: 15,
scale: 4,
insert: false,
update: false,
nullable: true,
})
qtyVariance: number;
@Column({
name: 'invoice_qty_variance',
type: 'decimal',
precision: 15,
scale: 4,
insert: false,
update: false,
nullable: true,
})
invoiceQtyVariance: number;
@Column({
name: 'price_variance',
type: 'decimal',
precision: 15,
scale: 2,
insert: false,
update: false,
nullable: true,
})
priceVariance: number;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: MatchingLineStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@OneToMany(() => MatchingException, (exception) => exception.matchingLine)
exceptions: MatchingException[];
}

View File

@ -0,0 +1,107 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { PurchaseOrder } from './purchase-order.entity';
import { PurchaseReceipt } from './purchase-receipt.entity';
import { PurchaseMatchingLine } from './purchase-matching-line.entity';
import { MatchingException } from './matching-exception.entity';
export type MatchingStatus =
| 'pending'
| 'partial_receipt'
| 'received'
| 'partial_invoice'
| 'matched'
| 'mismatch';
@Entity({ name: 'purchase_order_matching', schema: 'purchases' })
export class PurchaseOrderMatching {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'purchase_order_id', type: 'uuid' })
purchaseOrderId: string;
@ManyToOne(() => PurchaseOrder, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'purchase_order_id' })
purchaseOrder: PurchaseOrder;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: MatchingStatus;
@Column({ name: 'total_ordered', type: 'decimal', precision: 15, scale: 2 })
totalOrdered: number;
@Column({ name: 'total_received', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalReceived: number;
@Column({ name: 'total_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalInvoiced: number;
// Generated columns (read-only in TypeORM)
@Column({
name: 'receipt_variance',
type: 'decimal',
precision: 15,
scale: 2,
insert: false,
update: false,
nullable: true,
})
receiptVariance: number;
@Column({
name: 'invoice_variance',
type: 'decimal',
precision: 15,
scale: 2,
insert: false,
update: false,
nullable: true,
})
invoiceVariance: number;
@Index()
@Column({ name: 'last_receipt_id', type: 'uuid', nullable: true })
lastReceiptId?: string;
@ManyToOne(() => PurchaseReceipt, { nullable: true })
@JoinColumn({ name: 'last_receipt_id' })
lastReceipt?: PurchaseReceipt;
@Column({ name: 'last_invoice_id', type: 'uuid', nullable: true })
lastInvoiceId?: string;
@Column({ name: 'matched_at', type: 'timestamptz', nullable: true })
matchedAt?: Date;
@Column({ name: 'matched_by', type: 'uuid', nullable: true })
matchedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@OneToMany(() => PurchaseMatchingLine, (line) => line.matching)
lines: PurchaseMatchingLine[];
@OneToMany(() => MatchingException, (exception) => exception.matching)
exceptions: MatchingException[];
}

View File

@ -2,6 +2,9 @@ import { Repository, FindOptionsWhere } from 'typeorm';
import { PurchaseOrder } from '../entities';
import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto';
// Export 3-Way Matching Service
export { ThreeWayMatchingService, MatchingSearchParams, InvoiceData, InvoiceLineData } from './three-way-matching.service';
export interface PurchaseSearchParams {
tenantId: string;
supplierId?: string;

View File

@ -0,0 +1,740 @@
import { Repository, DataSource } from 'typeorm';
import {
PurchaseOrder,
PurchaseOrderItem,
PurchaseReceipt,
PurchaseReceiptItem,
PurchaseOrderMatching,
PurchaseMatchingLine,
MatchingException,
MatchingStatus,
MatchingLineStatus,
ExceptionType,
} from '../entities';
import {
MatchingStatusDto,
MatchingLineStatusDto,
MatchingExceptionDto,
MatchingSummaryDto,
ResolveExceptionDto,
} from '../dto';
/**
* Tolerance configuration for matching validation
*/
const TOLERANCES = {
QUANTITY_PERCENT: 0.5, // 0.5% tolerance for quantity variance
PRICE_PERCENT: 2.0, // 2% tolerance for price variance
};
export interface MatchingSearchParams {
tenantId: string;
status?: MatchingStatus;
supplierId?: string;
hasExceptions?: boolean;
limit?: number;
offset?: number;
}
/**
* Interface for invoice data (simplified for matching purposes)
*/
export interface InvoiceLineData {
orderItemId: string;
quantityInvoiced: number;
priceInvoiced: number;
}
export interface InvoiceData {
invoiceId: string;
purchaseOrderId: string;
totalInvoiced: number;
lines: InvoiceLineData[];
}
export class ThreeWayMatchingService {
constructor(
private readonly matchingRepository: Repository<PurchaseOrderMatching>,
private readonly matchingLineRepository: Repository<PurchaseMatchingLine>,
private readonly exceptionRepository: Repository<MatchingException>,
private readonly orderRepository: Repository<PurchaseOrder>,
private readonly orderItemRepository: Repository<PurchaseOrderItem>,
private readonly receiptRepository: Repository<PurchaseReceipt>,
private readonly receiptItemRepository: Repository<PurchaseReceiptItem>,
private readonly dataSource: DataSource
) {}
// ==================== Initialization ====================
/**
* Initialize matching record when a purchase order is confirmed
* Called when PO status changes to 'confirmed'
*/
async initializeMatching(purchaseOrderId: string, tenantId: string): Promise<PurchaseOrderMatching> {
// Check if matching already exists
const existing = await this.matchingRepository.findOne({
where: { purchaseOrderId, tenantId },
});
if (existing) {
return existing;
}
// Get order with items
const order = await this.orderRepository.findOne({
where: { id: purchaseOrderId, tenantId },
});
if (!order) {
throw new Error(`Purchase order ${purchaseOrderId} not found`);
}
const orderItems = await this.orderItemRepository.find({
where: { orderId: purchaseOrderId },
});
if (orderItems.length === 0) {
throw new Error(`Purchase order ${purchaseOrderId} has no items`);
}
// Create matching record within transaction
return this.dataSource.transaction(async (manager) => {
const matchingRepo = manager.getRepository(PurchaseOrderMatching);
const matchingLineRepo = manager.getRepository(PurchaseMatchingLine);
const matching = matchingRepo.create({
tenantId,
purchaseOrderId,
status: 'pending',
totalOrdered: Number(order.total),
totalReceived: 0,
totalInvoiced: 0,
});
const savedMatching = await matchingRepo.save(matching);
// Create matching lines for each order item
const lines = orderItems.map((item) =>
matchingLineRepo.create({
matchingId: savedMatching.id,
orderItemId: item.id,
tenantId,
qtyOrdered: Number(item.quantity),
qtyReceived: 0,
qtyInvoiced: 0,
priceOrdered: Number(item.unitPrice),
priceInvoiced: 0,
status: 'pending',
})
);
await matchingLineRepo.save(lines);
return savedMatching;
});
}
// ==================== Receipt Updates ====================
/**
* Update matching quantities from a confirmed receipt
*/
async updateFromReceipt(receiptId: string, tenantId: string): Promise<PurchaseOrderMatching> {
const receipt = await this.receiptRepository.findOne({
where: { id: receiptId, tenantId },
});
if (!receipt) {
throw new Error(`Receipt ${receiptId} not found`);
}
const receiptItems = await this.receiptItemRepository.find({
where: { receiptId },
});
// Get or create matching record
let matching = await this.matchingRepository.findOne({
where: { purchaseOrderId: receipt.orderId, tenantId },
});
if (!matching) {
matching = await this.initializeMatching(receipt.orderId, tenantId);
}
return this.dataSource.transaction(async (manager) => {
const matchingRepo = manager.getRepository(PurchaseOrderMatching);
const matchingLineRepo = manager.getRepository(PurchaseMatchingLine);
const exceptionRepo = manager.getRepository(MatchingException);
// Update matching lines from receipt items
for (const receiptItem of receiptItems) {
if (!receiptItem.orderItemId) continue;
const matchingLine = await matchingLineRepo.findOne({
where: { matchingId: matching!.id, orderItemId: receiptItem.orderItemId },
});
if (matchingLine) {
// Accumulate received quantity
matchingLine.qtyReceived = Number(matchingLine.qtyReceived) + Number(receiptItem.quantityReceived);
await matchingLineRepo.save(matchingLine);
// Check for receipt quantity exceptions
await this.checkReceiptQuantityException(
matchingLine,
exceptionRepo,
tenantId
);
}
}
// Recalculate totals
const lines = await matchingLineRepo.find({
where: { matchingId: matching!.id },
});
const orderItems = await this.orderItemRepository.find({
where: { orderId: matching!.purchaseOrderId },
});
// Create a map for quick lookup
const orderItemMap = new Map(orderItems.map((item) => [item.id, item]));
let totalReceived = 0;
for (const line of lines) {
const orderItem = orderItemMap.get(line.orderItemId);
if (orderItem) {
totalReceived += Number(line.qtyReceived) * Number(orderItem.unitPrice);
}
}
matching!.totalReceived = totalReceived;
matching!.lastReceiptId = receiptId;
// Update status
matching!.status = this.calculateMatchingStatus(matching!, lines);
return matchingRepo.save(matching!);
});
}
// ==================== Invoice Updates ====================
/**
* Update matching from an invoice
*/
async updateFromInvoice(invoiceData: InvoiceData, tenantId: string): Promise<PurchaseOrderMatching> {
// Get or create matching record
let matching = await this.matchingRepository.findOne({
where: { purchaseOrderId: invoiceData.purchaseOrderId, tenantId },
});
if (!matching) {
matching = await this.initializeMatching(invoiceData.purchaseOrderId, tenantId);
}
return this.dataSource.transaction(async (manager) => {
const matchingRepo = manager.getRepository(PurchaseOrderMatching);
const matchingLineRepo = manager.getRepository(PurchaseMatchingLine);
const exceptionRepo = manager.getRepository(MatchingException);
// Update matching lines from invoice lines
for (const invoiceLine of invoiceData.lines) {
const matchingLine = await matchingLineRepo.findOne({
where: { matchingId: matching!.id, orderItemId: invoiceLine.orderItemId },
});
if (matchingLine) {
// Accumulate invoiced quantity and update price
matchingLine.qtyInvoiced = Number(matchingLine.qtyInvoiced) + invoiceLine.quantityInvoiced;
matchingLine.priceInvoiced = invoiceLine.priceInvoiced;
await matchingLineRepo.save(matchingLine);
// Check for invoice exceptions
await this.checkInvoiceExceptions(
matchingLine,
exceptionRepo,
tenantId
);
}
}
// Update totals
matching!.totalInvoiced = Number(matching!.totalInvoiced) + invoiceData.totalInvoiced;
matching!.lastInvoiceId = invoiceData.invoiceId;
// Recalculate status
const lines = await matchingLineRepo.find({
where: { matchingId: matching!.id },
});
matching!.status = this.calculateMatchingStatus(matching!, lines);
return matchingRepo.save(matching!);
});
}
// ==================== Matching Validation ====================
/**
* Perform complete matching validation for an order
*/
async performMatching(matchingId: string, tenantId: string, userId?: string): Promise<PurchaseOrderMatching> {
const matching = await this.matchingRepository.findOne({
where: { id: matchingId, tenantId },
});
if (!matching) {
throw new Error(`Matching record ${matchingId} not found`);
}
return this.dataSource.transaction(async (manager) => {
const matchingRepo = manager.getRepository(PurchaseOrderMatching);
const matchingLineRepo = manager.getRepository(PurchaseMatchingLine);
const exceptionRepo = manager.getRepository(MatchingException);
const lines = await matchingLineRepo.find({
where: { matchingId },
});
// Validate each line
for (const line of lines) {
// Check receipt quantity variance
await this.checkReceiptQuantityException(line, exceptionRepo, tenantId);
// Check invoice quantity variance
await this.checkInvoiceExceptions(line, exceptionRepo, tenantId);
// Update line status
line.status = this.calculateLineStatus(line);
await matchingLineRepo.save(line);
}
// Check if all lines are matched and no pending exceptions
const pendingExceptions = await exceptionRepo.count({
where: { matchingId, status: 'pending' },
});
const allLinesMatched = lines.every((line) => line.status === 'matched');
if (allLinesMatched && pendingExceptions === 0) {
matching.status = 'matched';
matching.matchedAt = new Date();
matching.matchedBy = userId;
} else if (lines.some((line) => line.status === 'mismatch') || pendingExceptions > 0) {
matching.status = 'mismatch';
}
return matchingRepo.save(matching);
});
}
// ==================== Status Queries ====================
/**
* Get complete matching status for a purchase order
*/
async getMatchingStatus(purchaseOrderId: string, tenantId: string): Promise<MatchingStatusDto | null> {
const matching = await this.matchingRepository.findOne({
where: { purchaseOrderId, tenantId },
relations: ['purchaseOrder'],
});
if (!matching) {
return null;
}
const lines = await this.matchingLineRepository.find({
where: { matchingId: matching.id },
});
const orderItems = await this.orderItemRepository.find({
where: { orderId: purchaseOrderId },
});
const orderItemMap = new Map(orderItems.map((item) => [item.id, item]));
const exceptions = await this.exceptionRepository.find({
where: { matchingId: matching.id },
order: { createdAt: 'DESC' },
});
const pendingExceptionsCount = exceptions.filter((e) => e.status === 'pending').length;
const lineStatuses: MatchingLineStatusDto[] = lines.map((line) => {
const orderItem = orderItemMap.get(line.orderItemId);
return {
id: line.id,
orderItemId: line.orderItemId,
productName: orderItem?.productName || 'Unknown',
qtyOrdered: Number(line.qtyOrdered),
qtyReceived: Number(line.qtyReceived),
qtyInvoiced: Number(line.qtyInvoiced),
priceOrdered: Number(line.priceOrdered),
priceInvoiced: Number(line.priceInvoiced),
qtyVariance: Number(line.qtyVariance) || 0,
invoiceQtyVariance: Number(line.invoiceQtyVariance) || 0,
priceVariance: Number(line.priceVariance) || 0,
status: line.status,
};
});
const exceptionDtos: MatchingExceptionDto[] = exceptions.map((exc) => ({
id: exc.id,
exceptionType: exc.exceptionType,
expectedValue: exc.expectedValue ? Number(exc.expectedValue) : undefined,
actualValue: exc.actualValue ? Number(exc.actualValue) : undefined,
varianceValue: exc.varianceValue ? Number(exc.varianceValue) : undefined,
variancePercent: exc.variancePercent ? Number(exc.variancePercent) : undefined,
status: exc.status,
resolvedAt: exc.resolvedAt?.toISOString(),
resolutionNotes: exc.resolutionNotes,
}));
return {
id: matching.id,
purchaseOrderId: matching.purchaseOrderId,
orderNumber: matching.purchaseOrder?.orderNumber || '',
status: matching.status,
totalOrdered: Number(matching.totalOrdered),
totalReceived: Number(matching.totalReceived),
totalInvoiced: Number(matching.totalInvoiced),
receiptVariance: Number(matching.receiptVariance) || 0,
invoiceVariance: Number(matching.invoiceVariance) || 0,
lastReceiptId: matching.lastReceiptId,
lastInvoiceId: matching.lastInvoiceId,
matchedAt: matching.matchedAt?.toISOString(),
lines: lineStatuses,
exceptions: exceptionDtos,
pendingExceptionsCount,
};
}
/**
* Get list of matchings with summary
*/
async getMatchingList(params: MatchingSearchParams): Promise<{ data: MatchingSummaryDto[]; total: number }> {
const { tenantId, status, supplierId, hasExceptions, limit = 50, offset = 0 } = params;
const qb = this.matchingRepository
.createQueryBuilder('matching')
.leftJoinAndSelect('matching.purchaseOrder', 'po')
.where('matching.tenant_id = :tenantId', { tenantId });
if (status) {
qb.andWhere('matching.status = :status', { status });
}
if (supplierId) {
qb.andWhere('po.supplier_id = :supplierId', { supplierId });
}
const [matchings, total] = await qb
.orderBy('matching.created_at', 'DESC')
.take(limit)
.skip(offset)
.getManyAndCount();
// Get exception counts
const matchingIds = matchings.map((m) => m.id);
let exceptionCounts: Map<string, number> = new Map();
if (matchingIds.length > 0) {
const countResults = await this.exceptionRepository
.createQueryBuilder('exc')
.select('exc.matching_id', 'matchingId')
.addSelect('COUNT(*)', 'count')
.where('exc.matching_id IN (:...matchingIds)', { matchingIds })
.andWhere('exc.status = :status', { status: 'pending' })
.groupBy('exc.matching_id')
.getRawMany();
exceptionCounts = new Map(countResults.map((r) => [r.matchingId, parseInt(r.count)]));
}
const data: MatchingSummaryDto[] = matchings
.filter((matching) => {
if (hasExceptions === true) {
return (exceptionCounts.get(matching.id) || 0) > 0;
}
if (hasExceptions === false) {
return (exceptionCounts.get(matching.id) || 0) === 0;
}
return true;
})
.map((matching) => ({
id: matching.id,
purchaseOrderId: matching.purchaseOrderId,
orderNumber: matching.purchaseOrder?.orderNumber || '',
supplierName: matching.purchaseOrder?.supplierName || '',
status: matching.status,
totalOrdered: Number(matching.totalOrdered),
totalReceived: Number(matching.totalReceived),
totalInvoiced: Number(matching.totalInvoiced),
pendingExceptionsCount: exceptionCounts.get(matching.id) || 0,
createdAt: matching.createdAt.toISOString(),
matchedAt: matching.matchedAt?.toISOString(),
}));
return { data, total };
}
// ==================== Exception Management ====================
/**
* Resolve an exception
*/
async resolveException(
exceptionId: string,
tenantId: string,
dto: ResolveExceptionDto,
userId: string
): Promise<MatchingException> {
const exception = await this.exceptionRepository.findOne({
where: { id: exceptionId, tenantId },
});
if (!exception) {
throw new Error(`Exception ${exceptionId} not found`);
}
if (exception.status !== 'pending') {
throw new Error(`Exception ${exceptionId} is already resolved`);
}
exception.status = dto.resolution;
exception.resolvedAt = new Date();
exception.resolvedBy = userId;
exception.resolutionNotes = dto.notes;
const saved = await this.exceptionRepository.save(exception);
// Re-evaluate matching status after resolving exception
if (exception.matchingId) {
await this.performMatching(exception.matchingId, tenantId, userId);
}
return saved;
}
/**
* Get pending exceptions for a tenant
*/
async getPendingExceptions(
tenantId: string,
limit = 50,
offset = 0
): Promise<{ data: MatchingException[]; total: number }> {
const [data, total] = await this.exceptionRepository.findAndCount({
where: { tenantId, status: 'pending' },
order: { createdAt: 'DESC' },
take: limit,
skip: offset,
});
return { data, total };
}
// ==================== Private Helper Methods ====================
/**
* Check and create receipt quantity exceptions
*/
private async checkReceiptQuantityException(
line: PurchaseMatchingLine,
exceptionRepo: Repository<MatchingException>,
tenantId: string
): Promise<void> {
const qtyOrdered = Number(line.qtyOrdered);
const qtyReceived = Number(line.qtyReceived);
const variance = qtyOrdered - qtyReceived;
const variancePercent = qtyOrdered > 0 ? Math.abs(variance / qtyOrdered) * 100 : 0;
// Check if variance exceeds tolerance
if (variancePercent > TOLERANCES.QUANTITY_PERCENT) {
const exceptionType: ExceptionType = variance > 0 ? 'short_receipt' : 'over_receipt';
// Check if exception already exists
const existing = await exceptionRepo.findOne({
where: {
matchingLineId: line.id,
exceptionType,
status: 'pending',
},
});
if (!existing) {
const exception = exceptionRepo.create({
tenantId,
matchingId: line.matchingId,
matchingLineId: line.id,
exceptionType,
expectedValue: qtyOrdered,
actualValue: qtyReceived,
varianceValue: Math.abs(variance),
variancePercent,
status: 'pending',
});
await exceptionRepo.save(exception);
}
}
}
/**
* Check and create invoice exceptions (quantity and price)
*/
private async checkInvoiceExceptions(
line: PurchaseMatchingLine,
exceptionRepo: Repository<MatchingException>,
tenantId: string
): Promise<void> {
const qtyReceived = Number(line.qtyReceived);
const qtyInvoiced = Number(line.qtyInvoiced);
const priceOrdered = Number(line.priceOrdered);
const priceInvoiced = Number(line.priceInvoiced);
// Check invoice quantity variance (invoice vs receipt)
if (qtyInvoiced > qtyReceived) {
const variance = qtyInvoiced - qtyReceived;
const variancePercent = qtyReceived > 0 ? (variance / qtyReceived) * 100 : 100;
if (variancePercent > TOLERANCES.QUANTITY_PERCENT) {
const existing = await exceptionRepo.findOne({
where: {
matchingLineId: line.id,
exceptionType: 'over_invoice',
status: 'pending',
},
});
if (!existing) {
const exception = exceptionRepo.create({
tenantId,
matchingId: line.matchingId,
matchingLineId: line.id,
exceptionType: 'over_invoice',
expectedValue: qtyReceived,
actualValue: qtyInvoiced,
varianceValue: variance,
variancePercent,
status: 'pending',
});
await exceptionRepo.save(exception);
}
}
}
// Check price variance
if (priceInvoiced > 0 && priceOrdered > 0) {
const priceVarianceAmount = priceInvoiced - priceOrdered;
const priceVariancePercent = (priceVarianceAmount / priceOrdered) * 100;
// Only create exception if invoiced price exceeds ordered price by more than tolerance
if (priceVariancePercent > TOLERANCES.PRICE_PERCENT) {
const existing = await exceptionRepo.findOne({
where: {
matchingLineId: line.id,
exceptionType: 'price_variance',
status: 'pending',
},
});
if (!existing) {
const exception = exceptionRepo.create({
tenantId,
matchingId: line.matchingId,
matchingLineId: line.id,
exceptionType: 'price_variance',
expectedValue: priceOrdered,
actualValue: priceInvoiced,
varianceValue: priceVarianceAmount,
variancePercent: priceVariancePercent,
status: 'pending',
});
await exceptionRepo.save(exception);
}
}
}
}
/**
* Calculate matching status based on current state
*/
private calculateMatchingStatus(
matching: PurchaseOrderMatching,
lines: PurchaseMatchingLine[]
): MatchingStatus {
const totalOrdered = Number(matching.totalOrdered);
const totalReceived = Number(matching.totalReceived);
const totalInvoiced = Number(matching.totalInvoiced);
// No receipts yet
if (totalReceived === 0) {
return 'pending';
}
// Partial receipt
if (totalReceived < totalOrdered * 0.995) {
return 'partial_receipt';
}
// Fully received, no invoice
if (totalInvoiced === 0) {
return 'received';
}
// Partial invoice
if (totalInvoiced < totalReceived * 0.995) {
return 'partial_invoice';
}
// Check all lines
const allMatched = lines.every((line) => this.calculateLineStatus(line) === 'matched');
return allMatched ? 'matched' : 'mismatch';
}
/**
* Calculate line status based on quantities and prices
*/
private calculateLineStatus(line: PurchaseMatchingLine): MatchingLineStatus {
const qtyOrdered = Number(line.qtyOrdered);
const qtyReceived = Number(line.qtyReceived);
const qtyInvoiced = Number(line.qtyInvoiced);
const priceOrdered = Number(line.priceOrdered);
const priceInvoiced = Number(line.priceInvoiced);
// No activity yet
if (qtyReceived === 0 && qtyInvoiced === 0) {
return 'pending';
}
// Check quantity tolerances
const qtyReceivedVariance = Math.abs(qtyOrdered - qtyReceived) / qtyOrdered * 100;
const qtyInvoicedVariance = qtyReceived > 0 ? Math.abs(qtyReceived - qtyInvoiced) / qtyReceived * 100 : 0;
// Check price tolerance (only if invoiced)
let priceVariance = 0;
if (priceInvoiced > 0 && priceOrdered > 0) {
priceVariance = Math.abs(priceInvoiced - priceOrdered) / priceOrdered * 100;
}
// Partial if not fully received or invoiced
if (qtyReceived < qtyOrdered * 0.995 || qtyInvoiced < qtyReceived * 0.995) {
return 'partial';
}
// Mismatch if any variance exceeds tolerance
if (
qtyReceivedVariance > TOLERANCES.QUANTITY_PERCENT ||
qtyInvoicedVariance > TOLERANCES.QUANTITY_PERCENT ||
priceVariance > TOLERANCES.PRICE_PERCENT
) {
return 'mismatch';
}
return 'matched';
}
}