import { escapeODataString } from '../../core/sl/odata'; import type { PaymentDocType, PaymentScreen } from './payment-types'; type Row = Record; const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100; const num = (v: unknown): number => { const n = Number(v ?? 0); return Number.isFinite(n) ? n : 0; }; const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === '')); const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : ''); /** Un documento pendiente de pago del socio (factura o nota de crédito) con lo que se aplica de él. */ export interface OpenDoc { /** Único dentro del pago: `Invoices:206`. */ id: string; docEntry: number; docNum: number; kind: 'invoice' | 'credit'; /** Tipo de documento al que se aplica (`PaymentInvoices.InvoiceType`). */ type: string; label: string; date: string; dueDate: string; /** Folio o referencia que ve el usuario. */ reference: string; total: number; /** Lo que falta por pagar o aplicar. */ balance: number; /** Lo que este pago aplica; en una nota de crédito es lo que se descuenta. */ applied: number; } /** Medios de pago del borrador. */ export interface Means { cashAccount: string; cashSum: number | null; transferAccount: string; transferSum: number | null; transferDate: string; transferReference: string; /** Cuenta de cheques en cartera: SAP la exige a nivel del pago cuando hay cheques. */ checkAccount: string; } export const EMPTY_MEANS: Means = { cashAccount: '', cashSum: null, transferAccount: '', transferSum: null, transferDate: '', transferReference: '', checkAccount: '', }; export interface PaymentDraft { docType: PaymentDocType; cardCode: string; docDate: string; taxDate: string; series: number | null; remarks: string; journalRemarks: string; means: Means; checks: Row[]; /** Tarjetas de crédito (solo pagos recibidos). */ cards?: Row[]; docs: OpenDoc[]; accounts: Row[]; } // --- documentos pendientes ------------------------------------------------------------------ /** Campos que se piden de cada factura o nota de crédito pendiente. */ export const OPEN_DOC_FIELDS = 'DocEntry,DocNum,DocDate,DocDueDate,DocTotal,PaidToDate,NumAtCard,FolioPrefixString,FolioNumber,DocCurrency'; /** Filtro de los documentos pendientes de un socio: abiertos y en la moneda local. */ export function openDocsFilter(cardCode: string, localCurrency: string): string { return `CardCode eq '${escapeODataString(cardCode)}' and DocumentStatus eq 'bost_Open' and DocCurrency eq '${escapeODataString(localCurrency)}'`; } /** Una fila de `Invoices`/`CreditNotes` de SAP como documento pendiente. */ export function toOpenDoc(screen: PaymentScreen, kind: 'invoice' | 'credit', row: Row): OpenDoc { const spec = kind === 'invoice' ? screen.invoices : screen.credits; const folio = has(row['FolioNumber']) ? `${String(row['FolioPrefixString'] ?? '')}${String(row['FolioNumber'])}` : ''; return { id: `${spec.resource}:${String(row['DocEntry'])}`, docEntry: Number(row['DocEntry']), docNum: Number(row['DocNum']), kind, type: spec.type, label: spec.label, date: dateOnly(row['DocDate']), dueDate: dateOnly(row['DocDueDate']), reference: folio || String(row['NumAtCard'] ?? ''), total: round2(num(row['DocTotal'])), balance: round2(num(row['DocTotal']) - num(row['PaidToDate'])), applied: 0, }; } /** Pagar (o aplicar) todo el saldo de los documentos, o nada. */ export const applyAll = (docs: readonly OpenDoc[], on: boolean): OpenDoc[] => docs.map((d) => ({ ...d, applied: on ? d.balance : 0 })); // --- totales -------------------------------------------------------------------------------- export interface PaymentTotals { /** Facturas aplicadas. */ invoices: number; /** Notas de crédito descontadas. */ credits: number; /** Aplicado neto: facturas menos notas de crédito. */ applied: number; cash: number; transfer: number; checks: number; cards: number; /** Suma de los medios de pago. */ means: number; /** Lo que sobra de los medios sobre lo aplicado: queda a cuenta del socio. */ onAccount: number; /** Suma de las cuentas contables (pago a cuentas). */ accounts: number; } export function paymentTotals(d: Pick & { cards?: Row[] }): PaymentTotals { const invoices = round2(d.docs.filter((x) => x.kind === 'invoice').reduce((s, x) => s + num(x.applied), 0)); const credits = round2(d.docs.filter((x) => x.kind === 'credit').reduce((s, x) => s + num(x.applied), 0)); const cash = round2(num(d.means.cashSum)); const transfer = round2(num(d.means.transferSum)); const checks = round2(d.checks.reduce((s, c) => s + num(c['CheckSum']), 0)); const cards = round2((d.cards ?? []).reduce((s, c) => s + num(c['CreditSum']), 0)); const means = round2(cash + transfer + checks + cards); const applied = round2(invoices - credits); return { invoices, credits, applied, cash, transfer, checks, cards, means, onAccount: round2(Math.max(0, means - applied)), accounts: round2(d.accounts.reduce((s, a) => s + num(a['SumPaid']), 0)), }; } // --- validación ----------------------------------------------------------------------------- /** * Errores que se pueden detectar sin preguntarle a SAP. Importa uno en especial: SAP **no rechaza** un importe * aplicado mayor que el saldo de la factura, lo recorta en silencio y manda el resto a cuenta, así que aquí no * se permite. */ export function validatePayment(draft: PaymentDraft): string[] { const errors: string[] = []; const t = paymentTotals(draft); const toAccounts = draft.docType === 'rAccount'; if (!toAccounts && !has(draft.cardCode)) errors.push(draft.docType === 'rSupplier' ? 'Indique el proveedor.' : 'Indique el cliente.'); if (!has(draft.docDate)) errors.push('Indique la fecha de contabilización.'); if (t.means <= 0) errors.push('Indique al menos un medio de pago con su importe.'); if (t.cash > 0 && !has(draft.means.cashAccount)) errors.push('Indique la cuenta del efectivo.'); if (t.transfer > 0 && !has(draft.means.transferAccount)) errors.push('Indique la cuenta de la transferencia.'); if (t.checks > 0 && !has(draft.means.checkAccount)) errors.push('Indique la cuenta de cheques.'); draft.checks.forEach((c, i) => { if (!has(c['CheckSum']) && !has(c['CheckNumber']) && !has(c['BankCode'])) return; // fila vacía if (num(c['CheckSum']) <= 0) errors.push(`Cheque ${i + 1}: indique el importe.`); if (!has(c['CheckNumber'])) errors.push(`Cheque ${i + 1}: indique el número.`); if (!has(c['BankCode'])) errors.push(`Cheque ${i + 1}: indique el banco.`); if (!has(c['DueDate'])) errors.push(`Cheque ${i + 1}: indique el vencimiento.`); }); (draft.cards ?? []).forEach((c, i) => { if (!has(c['CreditSum']) && !has(c['CreditCard']) && !has(c['CreditCardNumber'])) return; // fila vacía if (num(c['CreditSum']) <= 0) errors.push(`Tarjeta ${i + 1}: indique el importe.`); if (!has(c['CreditCard'])) errors.push(`Tarjeta ${i + 1}: indique la tarjeta.`); if (!/^\d{4}$/.test(String(c['CreditCardNumber'] ?? '').trim())) errors.push(`Tarjeta ${i + 1}: indique los 4 últimos dígitos.`); if (!has(c['CardValidUntil'])) errors.push(`Tarjeta ${i + 1}: indique la fecha de validez.`); if (!has(c['VoucherNum'])) errors.push(`Tarjeta ${i + 1}: indique el número de comprobante.`); }); if (toAccounts) { const rows = draft.accounts.filter((a) => has(a['AccountCode']) || has(a['SumPaid'])); if (!rows.length) errors.push('Agregue al menos una cuenta contable.'); rows.forEach((a, i) => { if (!has(a['AccountCode'])) errors.push(`Cuenta ${i + 1}: indique la cuenta.`); if (num(a['SumPaid']) <= 0) errors.push(`Cuenta ${i + 1}: indique el importe.`); }); if (rows.length && t.means > 0 && t.accounts !== t.means) errors.push(`Los medios de pago (${t.means}) deben igualar el total de las cuentas (${t.accounts}).`); return errors; } for (const d of draft.docs) { if (num(d.applied) < 0) errors.push(`${d.label} ${d.docNum}: el importe no puede ser negativo.`); if (num(d.applied) > d.balance) errors.push(`${d.label} ${d.docNum}: no se puede aplicar más que su saldo (${d.balance}).`); } if (t.applied > t.means) errors.push(`Los medios de pago (${t.means}) no cubren lo aplicado a los documentos (${t.applied}).`); return errors; } // --- lo que se envía a SAP ------------------------------------------------------------------ const clean = (r: Row): Row => Object.fromEntries(Object.entries(r).filter(([, v]) => has(v))); /** * Cuerpo para crear el pago. Una nota de crédito se aplica con importe **negativo** (SAP rechaza el positivo); * lo que los medios de pago dejan sin aplicar a documentos queda a cuenta del socio sin indicarlo. */ export function paymentPayload(screen: PaymentScreen, d: PaymentDraft): Row { const t = paymentTotals(d); const body: Row = { DocType: d.docType, DocDate: d.docDate }; if (d.docType !== 'rAccount') body['CardCode'] = d.cardCode; if (has(d.taxDate)) body['TaxDate'] = d.taxDate; if (d.series) body['Series'] = d.series; if (has(d.remarks)) body['Remarks'] = d.remarks; if (has(d.journalRemarks)) body['JournalRemarks'] = d.journalRemarks; if (t.cash > 0) Object.assign(body, { CashAccount: d.means.cashAccount, CashSum: t.cash }); if (t.transfer > 0) { Object.assign(body, { TransferAccount: d.means.transferAccount, TransferSum: t.transfer, TransferDate: has(d.means.transferDate) ? d.means.transferDate : d.docDate }); if (has(d.means.transferReference)) body['TransferReference'] = d.means.transferReference; } const checks = d.checks.filter((c) => num(c['CheckSum']) > 0); if (checks.length) { body['CheckAccount'] = d.means.checkAccount; body['PaymentChecks'] = checks.map((c) => clean({ CheckNumber: c['CheckNumber'], BankCode: c['BankCode'], Branch: c['Branch'], AccounttNum: c['AccounttNum'], DueDate: c['DueDate'], CheckSum: round2(num(c['CheckSum'])), CountryCode: screen.countryCode, }), ); } const cards = (d.cards ?? []).filter((c) => num(c['CreditSum']) > 0); if (cards.length) { body['PaymentCreditCards'] = cards.map((c) => clean({ CreditCard: c['CreditCard'], CreditCardNumber: String(c['CreditCardNumber']).trim(), CardValidUntil: c['CardValidUntil'], VoucherNum: c['VoucherNum'], CreditSum: round2(num(c['CreditSum'])), CreditCur: screen.localCurrency, }), ); } if (d.docType === 'rAccount') { body['PaymentAccounts'] = d.accounts.filter((a) => num(a['SumPaid']) > 0).map((a) => clean({ AccountCode: a['AccountCode'], SumPaid: round2(num(a['SumPaid'])), Decription: a['Decription'] })); } else { const applied = d.docs.filter((x) => num(x.applied) > 0); if (applied.length) { body['PaymentInvoices'] = applied.map((x) => ({ DocEntry: x.docEntry, InvoiceType: x.type, SumApplied: x.kind === 'credit' ? -round2(num(x.applied)) : round2(num(x.applied)), })); } } return body; }