271 lines
11 KiB
TypeScript
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;
|
|
}
|