2026-10-16 14:36:24 -05:00
type Row = Record < string , unknown >;
const num = ( v : unknown ) : number => ( typeof v === 'number' ? v : Number ( v ) || 0 );
const round2 = ( n : number ) : number => Math . round (( n + Number . EPSILON ) * 100 ) / 100 ;
const has = ( v : unknown ) : boolean => ! ( v === null || v === undefined || ( typeof v === 'string' && v . trim () === '' ));
/** Campos de la línea que se envían a SAP además de la cuenta y los importes. */
2026-10-16 14:44:15 -05:00
export const JOURNAL_LINE_TEXT_FIELDS = [ 'ShortName' , 'DueDate' , 'Reference1' , 'Reference2' , 'AdditionalReference' , 'LineMemo' , 'CostingCode' , 'CostingCode2' , 'CostingCode3' , 'CostingCode4' , 'CostingCode5' , 'ProjectCode' ] as const ;
2026-10-16 14:36:24 -05:00
export interface JournalDraft {
referenceDate : string ;
dueDate : string ;
taxDate : string ;
series : number | null ;
memo : string ;
reference : string ;
reference2 : string ;
2026-10-16 14:44:15 -05:00
reference3 : string ;
indicator : string ;
folioPrefix : string ;
/** Se edita como texto; SAP lo espera como número. */
folioNumber : string ;
2026-10-16 14:36:24 -05:00
lines : Row [];
}
/** Una fila sin cuenta ni importes se ignora (la que se añadió y no se llenó). */
const isBlank = ( l : Row ) : boolean => ! has ( l [ 'AccountCode' ]) && num ( l [ 'Debit' ]) === 0 && num ( l [ 'Credit' ]) === 0 ;
export interface JournalTotals {
debit : number ;
credit : number ;
/** Debe − haber: un asiento solo se contabiliza en cero. */
difference : number ;
}
export function journalTotals ( lines : readonly Row []) : JournalTotals {
const debit = round2 ( lines . reduce (( s , l ) => s + num ( l [ 'Debit' ]), 0 ));
const credit = round2 ( lines . reduce (( s , l ) => s + num ( l [ 'Credit' ]), 0 ));
return { debit , credit , difference : round2 ( debit - credit ) };
}
/** Errores que se pueden detectar sin preguntarle a SAP. */
export function validateJournal ( d : JournalDraft ) : string [] {
const errors : string [] = [];
if ( ! has ( d . referenceDate )) errors . push ( 'Indique la fecha de contabilización.' );
2026-10-16 14:44:15 -05:00
if ( ! /^\d*$/ . test ( d . folioNumber . trim ())) errors . push ( 'El número de folio debe ser numérico.' );
2026-10-16 14:36:24 -05:00
const lines = d . lines . filter (( l ) => ! isBlank ( l ));
if ( lines . length < 2 ) errors . push ( 'Un asiento necesita al menos dos líneas.' );
lines . forEach (( l , i ) => {
if ( ! has ( l [ 'AccountCode' ])) errors . push ( `Línea ${ i + 1 } : indique la cuenta.` );
const debit = num ( l [ 'Debit' ]);
const credit = num ( l [ 'Credit' ]);
if ( debit < 0 || credit < 0 ) errors . push ( `Línea ${ i + 1 } : los importes no pueden ser negativos.` );
else if ( debit > 0 && credit > 0 ) errors . push ( `Línea ${ i + 1 } : indique el debe o el haber, no los dos.` );
else if ( debit === 0 && credit === 0 ) errors . push ( `Línea ${ i + 1 } : indique el debe o el haber.` );
});
const t = journalTotals ( lines );
if ( lines . length >= 2 && t . difference !== 0 ) errors . push ( `El asiento no cuadra: el debe ( ${ t . debit } ) y el haber ( ${ t . credit } ) difieren en ${ Math . abs ( t . difference ) } .` );
return errors ;
}
/** Cuerpo para crear el asiento: solo lo que tiene valor; cada línea lleva el debe o el haber, nunca los dos. */
export function journalPayload ( d : JournalDraft ) : Row {
const body : Row = { ReferenceDate : d.referenceDate };
if ( has ( d . dueDate )) body [ 'DueDate' ] = d . dueDate ;
if ( has ( d . taxDate )) body [ 'TaxDate' ] = d . taxDate ;
if ( d . series ) body [ 'Series' ] = d . series ;
if ( has ( d . memo )) body [ 'Memo' ] = d . memo ;
if ( has ( d . reference )) body [ 'Reference' ] = d . reference ;
if ( has ( d . reference2 )) body [ 'Reference2' ] = d . reference2 ;
2026-10-16 14:44:15 -05:00
if ( has ( d . reference3 )) body [ 'Reference3' ] = d . reference3 ;
if ( has ( d . indicator )) body [ 'Indicator' ] = d . indicator ;
if ( has ( d . folioPrefix )) body [ 'FolioPrefixString' ] = d . folioPrefix . trim ();
if ( has ( d . folioNumber )) body [ 'FolioNumber' ] = Number ( d . folioNumber );
2026-10-16 14:36:24 -05:00
body [ 'JournalEntryLines' ] = d . lines
. filter (( l ) => ! isBlank ( l ))
. map (( l ) => {
const line : Row = { AccountCode : l [ 'AccountCode' ] };
if ( num ( l [ 'Debit' ]) > 0 ) line [ 'Debit' ] = round2 ( num ( l [ 'Debit' ]));
else line [ 'Credit' ] = round2 ( num ( l [ 'Credit' ]));
for ( const k of JOURNAL_LINE_TEXT_FIELDS ) if ( has ( l [ k ])) line [ k ] = l [ k ];
return line ;
});
return body ;
}
/** Solo un asiento manual se cancela desde aquí: los que generan los documentos se cancelan con el documento. */
export const isManualJournal = ( doc : Row | undefined ) : boolean => doc ? .[ 'OriginalJournal' ] === 'ttJournalEntry' ;
/** Origen del asiento (`OriginalJournal`, el tipo de transacción de SAP) en texto. */
const ORIGINS : Readonly < Record < string , string >> = {
ttJournalEntry : 'Asiento manual' ,
ttOpeningBalance : 'Saldo de apertura' ,
ttARInvoice : 'Factura de venta' ,
ttARCredItnote : 'Nota de crédito de venta' ,
ttDelivery : 'Entrega' ,
ttReturn : 'Devolución' ,
ttAPInvoice : 'Factura de proveedor' ,
ttAPCreditNote : 'Nota de crédito de compra' ,
ttPurchaseDeliveryNote : 'Recepción de compra' ,
ttPurchaseReturn : 'Devolución de compra' ,
ttReceipt : 'Pago recibido' ,
ttVendorPayment : 'Pago efectuado' ,
ttDeposit : 'Depósito' ,
ttGeneralReceiptToStock : 'Entrada de inventario' ,
ttGeneralReleaseFromStock : 'Salida de inventario' ,
ttTransferBetweenWarehouses : 'Transferencia de stock' ,
ttInventoryValuation : 'Revalorización de inventario' ,
ttInternalReconciliation : 'Conciliación interna' ,
};
export const journalOrigin = ( code : unknown ) : string => ORIGINS [ String ( code ?? '' )] ?? String ( code ?? '' );