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

271 lines
11 KiB
TypeScript

import { escapeODataString } from '../../core/sl/odata';
import type { PaymentDocType, PaymentScreen } from './payment-types';
type Row = Record<string, unknown>;
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<PaymentDraft, 'means' | 'checks' | 'docs' | 'accounts'> & { 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;
}