Files
basic-erp/apps/web/src/app/features/banking/payment-model.spec.ts
T

254 lines
14 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from 'vitest';
import {
EMPTY_MEANS,
type OpenDoc,
type PaymentDraft,
applyAll,
openDocsFilter,
paymentPayload,
paymentTotals,
toOpenDoc,
validatePayment,
} from './payment-model';
import { PAYMENT_PATHS } from './payment-paths';
import { PAYMENT_SCREENS } from './payment-screens';
const incoming = PAYMENT_SCREENS.find((s) => s.kind === 'incoming')!;
const outgoing = PAYMENT_SCREENS.find((s) => s.kind === 'outgoing')!;
const doc = (over: Partial<OpenDoc> = {}): OpenDoc => ({
id: 'Invoices:206',
docEntry: 206,
docNum: 206,
kind: 'invoice',
type: 'it_Invoice',
label: 'Factura',
date: '2026-09-01',
dueDate: '2026-10-01',
reference: '',
total: 1000,
balance: 1000,
applied: 0,
...over,
});
const draft = (over: Partial<PaymentDraft> = {}): PaymentDraft => ({
docType: 'rCustomer',
cardCode: 'C1',
docDate: '2026-10-16',
taxDate: '',
series: null,
remarks: '',
journalRemarks: '',
means: { ...EMPTY_MEANS },
checks: [],
docs: [],
accounts: [],
...over,
});
describe('pantallas de pagos', () => {
it('son dos, con sus recursos y facturas de su lado', () => {
expect(PAYMENT_SCREENS.map((s) => [s.path, s.resource, s.invoices.resource, s.credits.resource])).toEqual([
['/bancos/pagos-recibidos', 'IncomingPayments', 'Invoices', 'CreditNotes'],
['/bancos/pagos-efectuados', 'VendorPayments', 'PurchaseInvoices', 'PurchaseCreditNotes'],
]);
expect(incoming.partner.docType).toBe('rCustomer');
expect(outgoing.partner.docType).toBe('rSupplier');
});
it('la lista de rutas del enrutador coincide con las pantallas', () => {
expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort());
});
it('los tipos de objeto de SAP son 24 (pagos recibidos) y 46 (efectuados)', () => {
expect([incoming.objectType, outgoing.objectType]).toEqual([24, 46]);
});
});
describe('documentos pendientes', () => {
it('el filtro pide los abiertos del socio en la moneda local, escapando las comillas', () => {
expect(openDocsFilter("O'N", 'CLP')).toBe(`CardCode eq 'O''N' and DocumentStatus eq 'bost_Open' and DocCurrency eq 'CLP'`);
});
it('una factura de SAP: el saldo es el total menos lo ya pagado, y el folio es la referencia', () => {
const d = toOpenDoc(incoming, 'invoice', { DocEntry: 206, DocNum: 206, DocDate: '2026-09-01T00:00:00Z', DocDueDate: '2026-10-01T00:00:00Z', DocTotal: 7363125, PaidToDate: 363125, FolioPrefixString: 'F', FolioNumber: 77 });
expect(d).toMatchObject({ id: 'Invoices:206', docEntry: 206, kind: 'invoice', type: 'it_Invoice', date: '2026-09-01', dueDate: '2026-10-01', total: 7363125, balance: 7000000, applied: 0, reference: 'F77' });
});
it('una nota de crédito usa su propio recurso y tipo; sin folio, la referencia del documento', () => {
const d = toOpenDoc(incoming, 'credit', { DocEntry: 16, DocNum: 16, DocTotal: 163625, PaidToDate: 0, NumAtCard: 'NC-1' });
expect(d).toMatchObject({ id: 'CreditNotes:16', kind: 'credit', type: 'it_CredItnote', label: 'Nota de crédito', balance: 163625, reference: 'NC-1' });
expect(toOpenDoc(outgoing, 'credit', { DocEntry: 3, DocNum: 3, DocTotal: 1, PaidToDate: 0 })).toMatchObject({ type: 'it_PurchaseCreditNote' });
});
it('«pagar todo» aplica el saldo de cada documento, y quitarlo lo deja en cero', () => {
const docs = [doc({ balance: 300 }), doc({ id: 'x', balance: 50.5 })];
expect(applyAll(docs, true).map((d) => d.applied)).toEqual([300, 50.5]);
expect(applyAll(applyAll(docs, true), false).map((d) => d.applied)).toEqual([0, 0]);
});
});
describe('totales', () => {
it('suma facturas, descuenta notas de crédito y suma los medios de pago, incluidos los cheques', () => {
const t = paymentTotals({
means: { ...EMPTY_MEANS, cashSum: 100, transferSum: 400 },
checks: [{ CheckSum: 250 }, { CheckSum: 50.5 }],
docs: [doc({ applied: 700 }), doc({ id: 'b', applied: 100 }), doc({ id: 'c', kind: 'credit', applied: 150 })],
accounts: [],
});
expect(t).toMatchObject({ invoices: 800, credits: 150, applied: 650, cash: 100, transfer: 400, checks: 300.5, means: 800.5 });
});
it('lo que sobra de los medios sobre lo aplicado queda a cuenta; nunca negativo', () => {
const base = { checks: [], accounts: [] };
expect(paymentTotals({ ...base, means: { ...EMPTY_MEANS, transferSum: 1000 }, docs: [doc({ applied: 600 })] }).onAccount).toBe(400);
expect(paymentTotals({ ...base, means: { ...EMPTY_MEANS, transferSum: 100 }, docs: [doc({ applied: 600 })] }).onAccount).toBe(0);
});
it('suma las cuentas contables y redondea a dos decimales', () => {
const t = paymentTotals({ means: { ...EMPTY_MEANS, cashSum: 0.1 + 0.2 }, checks: [], docs: [], accounts: [{ SumPaid: 10.005 }, { SumPaid: 5 }] });
expect(t.cash).toBe(0.3);
expect(t.accounts).toBe(15.01);
});
});
describe('validación', () => {
const ok = () => draft({ means: { ...EMPTY_MEANS, transferAccount: '1-1-010-20-000', transferSum: 500 }, docs: [doc({ applied: 500 })] });
it('un pago correcto no tiene errores', () => expect(validatePayment(ok())).toEqual([]));
it('exige el socio, la fecha y un medio de pago', () => {
expect(validatePayment(draft({ cardCode: '', docDate: '' }))).toEqual(['Indique el cliente.', 'Indique la fecha de contabilización.', 'Indique al menos un medio de pago con su importe.']);
expect(validatePayment(draft({ docType: 'rSupplier', cardCode: '' }))[0]).toBe('Indique el proveedor.');
});
it('cada medio con importe exige su cuenta', () => {
expect(validatePayment(draft({ means: { ...EMPTY_MEANS, cashSum: 10 } }))).toEqual(['Indique la cuenta del efectivo.']);
expect(validatePayment(draft({ means: { ...EMPTY_MEANS, transferSum: 10 } }))).toEqual(['Indique la cuenta de la transferencia.']);
});
it('los cheques exigen importe, número, banco, vencimiento y la cuenta de cheques del pago', () => {
const errors = validatePayment(draft({ checks: [{ CheckSum: 100 }] }));
expect(errors).toEqual(['Indique la cuenta de cheques.', 'Cheque 1: indique el número.', 'Cheque 1: indique el banco.', 'Cheque 1: indique el vencimiento.']);
const full = draft({ means: { ...EMPTY_MEANS, checkAccount: '1-1-010-20-000' }, checks: [{ CheckSum: 100, CheckNumber: 1, BankCode: '001', DueDate: '2026-11-30' }] });
expect(validatePayment(full)).toEqual([]);
});
it('una fila de cheque vacía se ignora', () => {
expect(validatePayment({ ...ok(), checks: [{}] })).toEqual([]);
});
it('no deja aplicar más que el saldo de la factura (SAP lo recortaría en silencio)', () => {
const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 9_999_999 }, docs: [doc({ balance: 327250, applied: 9_999_999 })] });
expect(validatePayment(d)).toEqual(['Factura 206: no se puede aplicar más que su saldo (327250).']);
});
it('los medios de pago deben cubrir lo aplicado', () => {
const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 100 }, docs: [doc({ applied: 600 })] });
expect(validatePayment(d)).toEqual(['Los medios de pago (100) no cubren lo aplicado a los documentos (600).']);
});
it('una nota de crédito aplicada reduce lo que los medios deben cubrir', () => {
const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 450 }, docs: [doc({ applied: 600 }), doc({ id: 'n', kind: 'credit', balance: 150, applied: 150 })] });
expect(validatePayment(d)).toEqual([]);
});
it('un pago sin documentos queda a cuenta y es válido', () => {
expect(validatePayment(draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 50 } }))).toEqual([]);
});
it('a cuentas contables: exige cuentas con importe y que los medios igualen su total', () => {
const base = { docType: 'rAccount' as const, cardCode: '', means: { ...EMPTY_MEANS, cashAccount: 'C', cashSum: 5000 } };
expect(validatePayment(draft({ ...base, accounts: [] }))).toEqual(['Agregue al menos una cuenta contable.']);
expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000' }] }))).toEqual(['Cuenta 1: indique el importe.', 'Los medios de pago (5000) deben igualar el total de las cuentas (0).']);
expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 4000 }] }))).toEqual(['Los medios de pago (5000) deben igualar el total de las cuentas (4000).']);
expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000 }] }))).toEqual([]);
});
});
describe('cuerpo del pago', () => {
it('transferencia aplicada a una factura: solo lo indicado, con la fecha del pago si no hay otra', () => {
const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, transferAccount: '1-1-010-20-000', transferSum: 100000, transferReference: 'REF-1' }, docs: [doc({ docEntry: 819, applied: 100000 })] }));
expect(body).toEqual({
DocType: 'rCustomer',
CardCode: 'C1',
DocDate: '2026-10-16',
TransferAccount: '1-1-010-20-000',
TransferSum: 100000,
TransferDate: '2026-10-16',
TransferReference: 'REF-1',
PaymentInvoices: [{ DocEntry: 819, InvoiceType: 'it_Invoice', SumApplied: 100000 }],
});
});
it('la nota de crédito se aplica con importe NEGATIVO y las facturas sin aplicar no se envían', () => {
const body = paymentPayload(incoming, draft({
means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 163625 },
docs: [doc({ docEntry: 819, applied: 327250 }), doc({ id: 'CreditNotes:16', docEntry: 16, kind: 'credit', type: 'it_CredItnote', applied: 163625 }), doc({ id: 'z', docEntry: 5, applied: 0 })],
}));
expect(body['PaymentInvoices']).toEqual([
{ DocEntry: 819, InvoiceType: 'it_Invoice', SumApplied: 327250 },
{ DocEntry: 16, InvoiceType: 'it_CredItnote', SumApplied: -163625 },
]);
});
it('un pago a cuenta no lleva PaymentInvoices: la diferencia la deja a cuenta SAP', () => {
const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 50000 } }));
expect(body).not.toHaveProperty('PaymentInvoices');
expect(body['TransferSum']).toBe(50000);
});
it('efectivo y transferencia a la vez; el medio sin importe no se envía', () => {
const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, cashAccount: 'C', cashSum: 20, transferAccount: 'T', transferSum: null } }));
expect(body).toMatchObject({ CashAccount: 'C', CashSum: 20 });
for (const k of ['TransferAccount', 'TransferSum', 'TransferDate', 'CheckAccount']) expect(body).not.toHaveProperty(k);
});
it('los cheques llevan la cuenta de cheques en el pago, el país del banco y sin campos vacíos', () => {
const body = paymentPayload(incoming, draft({
means: { ...EMPTY_MEANS, checkAccount: '1-1-010-20-000' },
checks: [{ CheckNumber: 8001, BankCode: '001', Branch: '', AccounttNum: '00000001', DueDate: '2026-11-30', CheckSum: 30000 }, { CheckSum: 0 }],
}));
expect(body['CheckAccount']).toBe('1-1-010-20-000');
expect(body['PaymentChecks']).toEqual([{ CheckNumber: 8001, BankCode: '001', AccounttNum: '00000001', DueDate: '2026-11-30', CheckSum: 30000, CountryCode: 'CL' }]);
});
it('a cuentas contables: sin socio y con PaymentAccounts', () => {
const body = paymentPayload(outgoing, draft({
docType: 'rAccount',
cardCode: 'ignorado',
means: { ...EMPTY_MEANS, cashAccount: '1-1-010-10-001', cashSum: 5000 },
accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000, Decription: 'Gasto' }, { AccountCode: '', SumPaid: 0 }],
}));
expect(body).toEqual({
DocType: 'rAccount',
DocDate: '2026-10-16',
CashAccount: '1-1-010-10-001',
CashSum: 5000,
PaymentAccounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000, Decription: 'Gasto' }],
});
});
it('serie, comentarios y fecha del documento solo si se indicaron', () => {
const body = paymentPayload(outgoing, draft({ docType: 'rSupplier', series: 21, taxDate: '2026-10-15', remarks: 'Pago facturas', journalRemarks: 'Pago', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1, transferDate: '2026-10-20' } }));
expect(body).toMatchObject({ DocType: 'rSupplier', Series: 21, TaxDate: '2026-10-15', Remarks: 'Pago facturas', JournalRemarks: 'Pago', TransferDate: '2026-10-20' });
const bare = paymentPayload(outgoing, draft({ docType: 'rSupplier', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1 } }));
for (const k of ['Series', 'TaxDate', 'Remarks', 'JournalRemarks']) expect(bare).not.toHaveProperty(k);
});
it('las tarjetas de crédito suman a los medios, se validan y se envían con la moneda local', () => {
const card = { CreditCard: 2, CreditCardNumber: '1234', CardValidUntil: '2030-12-31', VoucherNum: 'V-1', CreditSum: 1000.456 };
expect(paymentTotals({ means: { ...EMPTY_MEANS }, checks: [], cards: [card], docs: [], accounts: [] })).toMatchObject({ cards: 1000.46, means: 1000.46, onAccount: 1000.46 });
expect(validatePayment(draft({ cards: [card] }))).toEqual([]);
expect(validatePayment(draft({ cards: [{ ...card, CreditCardNumber: '12', VoucherNum: '' }] }))).toEqual([
'Tarjeta 1: indique los 4 últimos dígitos.',
'Tarjeta 1: indique el número de comprobante.',
]);
expect(paymentPayload(incoming, draft({ cards: [card, { CreditCard: '', CreditSum: 0 }] }))['PaymentCreditCards']).toEqual([
{ CreditCard: 2, CreditCardNumber: '1234', CardValidUntil: '2030-12-31', VoucherNum: 'V-1', CreditSum: 1000.46, CreditCur: 'CLP' },
]);
expect(incoming.cards).toBe(true);
expect(outgoing.cards).toBe(false);
});
});