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 => ({ 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 => ({ 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); }); });