feat(web): formulario de pagos recibidos y efectuados (documentos abiertos, medios de pago, cheques, cuentas, anulación)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 13:28:55 -05:00
1 parent 369be317ab
commit 3d6f76a1dd
14 files changed
+1213 -1

No files matched your search

+6
View File
@@ -17,6 +17,12 @@ Monorepo npm workspaces:
- La fila de un formulario es **etiqueta + uno o varios controles** (input, select, número, fecha, botón); la cabecera tiene **como máximo 2 columnas**. - La fila de un formulario es **etiqueta + uno o varios controles** (input, select, número, fecha, botón); la cabecera tiene **como máximo 2 columnas**.
- Impuesto de las líneas: `TaxCode` con el maestro `SalesTaxCodes` (verificado en el Service Layer; `VatGroups` no tiene `Rate`). Un cliente con `VatGroup` lo cambia en su rama. - Impuesto de las líneas: `TaxCode` con el maestro `SalesTaxCodes` (verificado en el Service Layer; `VatGroups` no tiene `Rate`). Un cliente con `VatGroup` lo cambia en su rama.
## Bancos (pagos recibidos y efectuados)
Menú `Bancos` → `/bancos/pagos-recibidos` (`IncomingPayments`) y `/bancos/pagos-efectuados` (`VendorPayments`), en `apps/web/src/app/features/banking/`. Medios: efectivo, transferencia y cheques; se aplican a facturas/notas de crédito abiertas del socio, a cuenta (lo que sobra de los medios queda como anticipo, implícito) o a cuentas contables (`rAccount`). Un pago contabilizado no se edita: se consulta y se anula (`POST Recurso(clave)/Cancel`, acción en la lista blanca de `sl-resources.ts`, auditada como `sl_action`).
Reglas de SAP verificadas: nota de crédito se aplica con `SumApplied` **negativo**; los cheques exigen `CheckAccount` en la cabecera; pagar de más trunca en silencio (se valida en el cliente); solo moneda local (`localCurrency` de la pantalla, hoy CLP). En `SBODemoCL` los pagos efectuados a facturas fallan por falta de la cuenta de "diferencias de conversión realizadas" (configuración de SAP); los de `rAccount` funcionan. Pendiente: depósitos y conciliación bancaria, moneda extranjera.
## Criterios de la interfaz ## Criterios de la interfaz
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
+13
View File
@@ -2,6 +2,8 @@ import { MENU } from '@basic-erp/sap-types';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { permissionGuard } from './core/auth/guards'; import { permissionGuard } from './core/auth/guards';
import { PAYMENT_PATHS } from './features/banking/payment-paths';
import { PAYMENT_SCREENS } from './features/banking/payment-screens';
import { DOCUMENT_PATHS } from './features/documents/document-paths'; import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { DOCUMENT_SCREENS } from './features/documents/document-screens'; import { DOCUMENT_SCREENS } from './features/documents/document-screens';
import { MASTER_PATHS } from './features/masters/master-paths'; import { MASTER_PATHS } from './features/masters/master-paths';
@@ -54,6 +56,17 @@ describe('rutas', () => {
expect([...MASTER_PATHS].sort()).toEqual(MASTER_SCREENS.map((s) => s.path).sort()); expect([...MASTER_PATHS].sort()).toEqual(MASTER_SCREENS.map((s) => s.path).sort());
}); });
it('cada pago ofrece lista, "nuevo" y "ver/anular" bajo la ruta del menú', async () => {
for (const path of PAYMENT_PATHS) {
const route = children.find((r) => r.path === path.slice(1))!;
expect(route.children?.map((c) => c.path), path).toEqual(['', 'nuevo', ':docEntry']);
expect(route.canActivate, path).toContain(permissionGuard);
const screen = await (route.resolve!['screen'] as () => Promise<{ path: string }>)();
expect(screen.path).toBe(path);
}
expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort());
});
it('la página de componentes está restringida a desarrollo y no está en el menú', () => { it('la página de componentes está restringida a desarrollo y no está en el menú', () => {
const route = children.find((r) => r.path === 'dev/componentes'); const route = children.find((r) => r.path === 'dev/componentes');
expect(route?.loadComponent).toBeDefined(); expect(route?.loadComponent).toBeDefined();
+20 -1
View File
@@ -3,6 +3,7 @@ import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types'; import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards'; import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths'; import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { PAYMENT_PATHS } from './features/banking/payment-paths';
import { MASTER_PATHS } from './features/masters/master-paths'; import { MASTER_PATHS } from './features/masters/master-paths';
const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path; const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
@@ -58,6 +59,23 @@ const masterRoutes: Routes = MASTER_PATHS.map(
}), }),
); );
/** Pagos recibidos y efectuados: lista, `nuevo` y `:docEntry` (consulta y anulación). */
const paymentRoutes: Routes = PAYMENT_PATHS.map(
(path): Route => ({
path: path.slice(1),
canActivate: [permissionGuard],
data: { path, title: menuLabel(path) },
resolve: {
screen: () => import('./features/banking/payment-screens').then((m) => m.PAYMENT_SCREENS.find((s) => s.path === path)!),
},
children: [
{ path: '', loadComponent: () => import('./features/banking/payment-list.page').then((m) => m.PaymentListPage) },
{ path: 'nuevo', loadComponent: () => import('./features/banking/payment-form.page').then((m) => m.PaymentFormPage) },
{ path: ':docEntry', loadComponent: () => import('./features/banking/payment-form.page').then((m) => m.PaymentFormPage) },
],
}),
);
/** /**
* Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar); * Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar);
* en producción cae en la ruta comodín. No aparece en el menú. * en producción cae en la ruta comodín. No aparece en el menú.
@@ -87,7 +105,7 @@ const adminRoutes: Routes = [
]; ];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ /** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, '/admin/permisos', '/admin/auditoria']); const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, '/admin/permisos', '/admin/auditoria']);
const pendingRoutes: Routes = MENU.flatMap((m) => m.items) const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !builtPaths.has(item.path)) .filter((item) => !builtPaths.has(item.path))
.map( .map(
@@ -118,6 +136,7 @@ export const routes: Routes = [
...devRoutes, ...devRoutes,
...documentRoutes, ...documentRoutes,
...masterRoutes, ...masterRoutes,
...paymentRoutes,
...adminRoutes, ...adminRoutes,
...pendingRoutes, ...pendingRoutes,
], ],
+5
View File
@@ -31,6 +31,11 @@ export class SlDocumentApi {
return typeof entry === 'number' ? entry : null; return typeof entry === 'number' ? entry : null;
} }
/** Invoca una acción de un registro (p. ej. `Cancel` de un pago): `POST Recurso(clave)/Accion`. */
action(resource: string, docEntry: number, action: string): Promise<unknown> {
return firstValueFrom(this.http.post<unknown>(`${this.url(resource, docEntry)}/${action}`, {}));
}
/** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */ /** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */
create(resource: string, body: DocRecord): Promise<DocRecord> { create(resource: string, body: DocRecord): Promise<DocRecord> {
return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body)); return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body));
@@ -0,0 +1,31 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../../core/api';
import { OPEN_DOC_FIELDS, type OpenDoc, openDocsFilter, toOpenDoc } from './payment-model';
import type { PaymentScreen } from './payment-types';
/** Como mucho estos documentos pendientes por socio (los más antiguos primero). */
const MAX_OPEN = 200;
/** Facturas y notas de crédito pendientes de un socio, para aplicarles un pago. */
@Injectable({ providedIn: 'root' })
export class OpenDocsApi {
private readonly http = inject(HttpClient);
private fetch(resource: string, filter: string): Promise<SlListResponse<Record<string, unknown>>> {
const params = new HttpParams().set('$select', OPEN_DOC_FIELDS).set('$filter', filter).set('$orderby', 'DocDueDate asc').set('$top', MAX_OPEN);
return firstValueFrom(this.http.get<SlListResponse<Record<string, unknown>>>(`${API_URL}/sl/${resource}`, { params }));
}
/** Solo los que tienen saldo: facturas primero, después las notas de crédito. */
async load(screen: PaymentScreen, cardCode: string): Promise<OpenDoc[]> {
const filter = openDocsFilter(cardCode, screen.localCurrency);
const [invoices, credits] = await Promise.all([this.fetch(screen.invoices.resource, filter), this.fetch(screen.credits.resource, filter)]);
return [
...invoices.value.map((r) => toOpenDoc(screen, 'invoice', r)),
...credits.value.map((r) => toOpenDoc(screen, 'credit', r)),
].filter((d) => d.balance > 0);
}
}
@@ -0,0 +1,8 @@
:host { display: block; }
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
.row { display: flex; gap: 8px; align-items: center; margin: 4px 0; }
.totals { margin: 12px 0 0 auto; width: 320px; border-collapse: collapse; }
.totals td { padding: 2px 6px; }
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.totals tr.strong td { font-weight: 600; border-top: 1px solid var(--field-b); }
.confirm { color: var(--danger, #b00020); margin-right: 8px; }
@@ -0,0 +1,128 @@
<sbo-toolbar bleed aria-label="Acciones del pago">
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Pago nuevo"><sbo-icon name="new" />Nuevo</a>
<span class="tb-sep"></span>
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-select-field label="Pago a" [options]="typeOptions" [value]="docType()" (valueChange)="onType($event)" [readonly]="saved()" />
@if (!toAccounts()) {
<sbo-lookup-field
[label]="screen.partner.label"
[lookup]="partnerLookup"
[value]="cardCode()"
[readonly]="saved()"
required
resolveOnLeave
(valueChange)="onPartnerText($event)"
(picked)="onPartner($event)"
/>
<sbo-text-field label="Nombre" [value]="cardName()" readonly />
}
</div>
<div class="sbo-col">
@if (isNew()) {
<sbo-select-field label="Serie" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
} @else {
<sbo-text-field label="Número" [value]="'' + (doc()?.['DocNum'] ?? '')" readonly />
}
<sbo-date-field label="Fecha de contabilización" [(value)]="docDate" [readonly]="saved()" required />
<sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" />
</div>
</div>
@if (!toAccounts()) {
<h3>Documentos pendientes</h3>
@if (saved()) {
<sbo-rows-table [columns]="appliedColumns" [rows]="docRows()" [readonly]="true" [removable]="false" emptyText="Pago a cuenta: sin documentos aplicados." />
} @else {
<sbo-rows-table
[columns]="docColumns"
[rows]="docRows()"
(rowsChange)="setDocs($event)"
[removable]="false"
[emptyText]="loadingDocs() ? 'Cargando documentos…' : cardCode() ? 'Sin documentos pendientes.' : 'Elija el socio para ver sus documentos pendientes.'"
/>
<p class="row">
<button type="button" class="btn" (click)="payAll(true)">Pagar todo</button>
<button type="button" class="btn" (click)="payAll(false)">Quitar</button>
<button type="button" class="btn" (click)="useApplied()">Transferir lo aplicado</button>
</p>
}
} @else {
<h3>Cuentas contables</h3>
<sbo-rows-table [columns]="accountColumns" [rows]="accounts()" (rowsChange)="setAccounts($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin cuentas." />
@if (!saved()) {
<p class="row"><button type="button" class="btn" (click)="addAccount()">+ Añadir cuenta</button></p>
}
}
<h3>Medios de pago</h3>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-lookup-field label="Cuenta de efectivo" [lookup]="accountLookup" [value]="means().cashAccount" (valueChange)="setMeans({ cashAccount: $event })" [readonly]="saved()" />
<sbo-number-field label="Efectivo" [decimals]="2" [value]="means().cashSum" (valueChange)="setMeans({ cashSum: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de transferencia" [lookup]="accountLookup" [value]="means().transferAccount" (valueChange)="setMeans({ transferAccount: $event })" [readonly]="saved()" />
<sbo-number-field label="Transferencia" [decimals]="2" [value]="means().transferSum" (valueChange)="setMeans({ transferSum: $event })" [readonly]="saved()" />
</div>
<div class="sbo-col">
<sbo-date-field label="Fecha de transferencia" [value]="means().transferDate" (valueChange)="setMeans({ transferDate: $event })" [readonly]="saved()" />
<sbo-text-field label="Referencia" [value]="means().transferReference" (valueChange)="setMeans({ transferReference: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de cheques" [lookup]="accountLookup" [value]="means().checkAccount" (valueChange)="setMeans({ checkAccount: $event })" [readonly]="saved()" />
</div>
</div>
<h3>Cheques</h3>
<sbo-rows-table [columns]="checkColumns" [rows]="checks()" (rowsChange)="setChecks($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin cheques." />
@if (!saved()) {
<p class="row"><button type="button" class="btn" (click)="addCheck()">+ Añadir cheque</button></p>
}
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
<sbo-textarea-field label="Comentarios" [(value)]="remarks" [readonly]="saved()" />
<sbo-text-field label="Comentarios del asiento" [(value)]="journalRemarks" [readonly]="saved()" />
</div>
<div class="sbo-col">
@if (!saved()) {
<table class="totals">
<tbody>
@if (!toAccounts()) {
<tr><td>Facturas</td><td>{{ fmt(totals().invoices) }}</td></tr>
<tr><td>Notas de crédito</td><td>{{ fmt(totals().credits) }}</td></tr>
<tr><td>Aplicado</td><td>{{ fmt(totals().applied) }}</td></tr>
} @else {
<tr><td>Cuentas</td><td>{{ fmt(totals().accounts) }}</td></tr>
}
<tr><td>Efectivo</td><td>{{ fmt(totals().cash) }}</td></tr>
<tr><td>Transferencia</td><td>{{ fmt(totals().transfer) }}</td></tr>
<tr><td>Cheques</td><td>{{ fmt(totals().checks) }}</td></tr>
<tr class="strong"><td>Total medios de pago</td><td>{{ fmt(totals().means) }}</td></tr>
@if (!toAccounts()) {
<tr><td>A cuenta del socio</td><td>{{ fmt(totals().onAccount) }}</td></tr>
}
</tbody>
</table>
}
</div>
</div>
@if (isNew()) {
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">Crear</button>
} @else if (!cancelled() && doc()) {
@if (confirmCancel()) {
<span sboActions class="confirm">¿Anular el pago?</span>
<button sboActions class="btn main" type="button" [disabled]="saving()" (click)="cancelPayment()">Sí, anular</button>
<button sboActions class="btn" type="button" (click)="confirmCancel.set(false)">No</button>
} @else {
<button sboActions class="btn" type="button" [disabled]="saving()" (click)="confirmCancel.set(true)">Anular pago</button>
}
}
<a sboActions class="btn" routerLink="..">{{ saved() ? 'Cerrar' : 'Cancelar' }}</a>
</sbo-window>
@@ -0,0 +1,364 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form';
import type { DetailColumn } from '../../shared/grid/grid.types';
import { RowsTable } from '../../shared/grid/rows-table';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { ACCOUNT_LOOKUP } from '../documents/dimension-lookups';
import type { NavTarget } from '../documents/document-model';
import { OpenDocsApi } from './open-docs';
import { EMPTY_MEANS, type Means, type OpenDoc, applyAll, paymentPayload, paymentTotals, validatePayment } from './payment-model';
import type { PaymentDocType, PaymentScreen } from './payment-types';
type Row = Record<string, unknown>;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
const today = (): string => new Date().toISOString().slice(0, 10);
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
const money = (n: number): string => formatNumber(n, 2);
const BANKS: VhLookup = {
title: 'Bancos',
resource: 'Banks',
orderby: 'BankCode',
columns: [
{ key: 'BankCode', title: 'Código', width: '100px', mono: true },
{ key: 'BankName', title: 'Nombre' },
],
};
/** Qué se muestra de un documento ya aplicado en un pago guardado. */
const APPLIED_LABELS: Readonly<Record<string, string>> = {
it_Invoice: 'Factura',
it_CredItnote: 'Nota de crédito',
it_PurchaseInvoice: 'Factura de proveedor',
it_PurchaseCreditNote: 'Nota de crédito de compra',
};
/**
* Formulario de un pago recibido o efectuado. Un pago nuevo se arma con el socio, sus **documentos pendientes**
* (cuánto se paga de cada uno), los **medios de pago** (efectivo, transferencia, cheques) y, si el pago va
* directo a cuentas contables, esas cuentas. Lo que los medios dejan sin aplicar queda a cuenta del socio. Un
* pago contabilizado no se modifica: se consulta y se puede anular.
*/
@Component({
selector: 'sbo-payment-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LookupField, NumberField, RouterLink, RowsTable, SboWindow, SelectField, TextField, TextareaField, Toolbar],
templateUrl: './payment-form.page.html',
styleUrl: './payment-form.page.css',
})
export class PaymentFormPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(SlDocumentApi);
private readonly openDocs = inject(OpenDocsApi);
protected readonly screen = this.route.snapshot.data['screen'] as PaymentScreen;
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly docEntry = toSignal(this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))), {
initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null,
});
protected readonly isNew = computed(() => this.docEntry() === null);
// --- datos ---------------------------------------------------------------------------------
private readonly resource = httpResource<Row>(() => {
const id = this.docEntry();
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
});
protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined));
protected readonly loading = computed(() => this.resource.isLoading());
protected readonly loadError = computed(() => {
const err = this.resource.error();
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El pago no existe.' : apiErrorMessage(err)) : '';
});
/** Un pago contabilizado no se modifica; se anula. */
protected readonly saved = computed(() => !this.isNew());
protected readonly cancelled = computed(() => this.doc()?.['Cancelled'] === 'tYES');
// --- el borrador (o el pago guardado, mostrado) --------------------------------------------
protected readonly docType = linkedSignal<PaymentDocType>(() => (this.doc()?.['DocType'] as PaymentDocType | undefined) ?? this.screen.partner.docType);
protected readonly cardCode = linkedSignal(() => text(this.doc()?.['CardCode']));
protected readonly cardName = linkedSignal(() => text(this.doc()?.['CardName']));
protected readonly docDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['DocDate']) : today()));
protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate']));
protected readonly series = linkedSignal<number | null>(() => (this.doc() ? Number(this.doc()!['Series']) || null : null));
protected readonly remarks = linkedSignal(() => text(this.doc()?.['Remarks']));
protected readonly journalRemarks = linkedSignal(() => text(this.doc()?.['JournalRemarks']));
protected readonly means = linkedSignal<Means>(() => {
const d = this.doc();
if (!d) return { ...EMPTY_MEANS };
return {
cashAccount: text(d['CashAccount']),
cashSum: Number(d['CashSum']) || null,
transferAccount: text(d['TransferAccount']),
transferSum: Number(d['TransferSum']) || null,
transferDate: dateOnly(d['TransferDate']),
transferReference: text(d['TransferReference']),
checkAccount: text(d['CheckAccount']),
};
});
protected readonly checks = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentChecks'] as Row[] | undefined) ?? []).map((c) => ({ ...c, DueDate: dateOnly(c['DueDate']) })));
protected readonly accounts = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentAccounts'] as Row[] | undefined) ?? []).map((a) => ({ ...a })));
protected readonly docs = linkedSignal<OpenDoc[]>(() =>
((this.doc()?.['PaymentInvoices'] as Row[] | undefined) ?? []).map((i) => ({
id: `${text(i['InvoiceType'])}:${text(i['DocEntry'])}`,
docEntry: Number(i['DocEntry']),
docNum: Number(i['DocNum']),
kind: text(i['InvoiceType']).includes('Cred') || text(i['InvoiceType']).includes('Credit') ? 'credit' : 'invoice',
type: text(i['InvoiceType']),
label: APPLIED_LABELS[text(i['InvoiceType'])] ?? text(i['InvoiceType']),
date: '',
dueDate: '',
reference: '',
total: 0,
balance: 0,
applied: Math.abs(Number(i['SumApplied']) || 0),
})),
);
protected readonly loadingDocs = signal(false);
protected readonly saving = signal(false);
protected readonly confirmCancel = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
return created ? { kind: 'ok', text: `${this.screen.singular} ${created} creado.` } : null;
})(),
);
protected readonly totals = computed(() => paymentTotals({ means: this.means(), checks: this.checks(), docs: this.docs(), accounts: this.accounts() }));
protected readonly toAccounts = computed(() => this.docType() === 'rAccount');
protected readonly fmt = money;
// --- series de numeración --------------------------------------------------------------------
private readonly seriesResource = httpResource<SlListResponse<Row>>(() =>
this.isNew()
? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(this.screen.objectType) } } }
: undefined,
);
protected readonly seriesOptions = computed(() => {
const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : [];
const options = rows.filter((r) => r['DocumentSubType'] === '--' && r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) }));
const current = Number(this.doc()?.['Series']);
if (current && !options.some((o) => o.value === current)) options.unshift({ value: current, label: `Serie ${current}` });
return options;
});
// --- configuración de la vista ---------------------------------------------------------------
protected readonly typeOptions = [
{ value: this.screen.partner.docType as string, label: this.screen.partner.label },
{ value: 'rAccount', label: 'Cuentas contables' },
];
protected readonly partnerLookup: VhLookup = {
title: this.screen.partner.lookupTitle,
resource: 'BusinessPartners',
baseFilter: `CardType eq '${this.screen.partner.type}'`,
orderby: 'CardCode',
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
};
protected readonly accountLookup = ACCOUNT_LOOKUP;
/** Documentos pendientes: lo único editable es lo que se paga de cada uno. */
protected readonly docColumns: readonly DetailColumn[] = [
{ key: 'label', title: 'Tipo', type: 'text', readonly: true, width: '170px' },
{ key: 'docNum', title: 'Número', type: 'text', readonly: true, width: '90px' },
{ key: 'reference', title: 'Referencia', type: 'text', readonly: true, width: '110px' },
{ key: 'date', title: 'Fecha', type: 'date', readonly: true, width: '120px' },
{ key: 'dueDate', title: 'Vencimiento', type: 'date', readonly: true, width: '120px' },
{ key: 'total', title: 'Total', type: 'number', decimals: 2, readonly: true, width: '120px' },
{ key: 'balance', title: 'Saldo', type: 'number', decimals: 2, readonly: true, width: '120px' },
{ key: 'applied', title: 'A pagar', type: 'number', decimals: 2, width: '120px' },
];
/** Documentos de un pago guardado: solo lo aplicado. */
protected readonly appliedColumns: readonly DetailColumn[] = [
{ key: 'label', title: 'Tipo', type: 'text', readonly: true, width: '200px' },
{ key: 'docNum', title: 'Número', type: 'text', readonly: true, width: '110px' },
{ key: 'applied', title: 'Importe aplicado', type: 'number', decimals: 2, readonly: true, width: '150px' },
];
protected readonly checkColumns: readonly DetailColumn[] = [
{ key: 'CheckNumber', title: 'Nº de cheque', type: 'text', required: true, width: '120px' },
{ key: 'BankCode', title: 'Banco', type: 'lookup', required: true, lookup: BANKS, width: '110px' },
{ key: 'Branch', title: 'Sucursal', type: 'text', width: '100px' },
{ key: 'AccounttNum', title: 'Cuenta', type: 'text', width: '140px' },
{ key: 'DueDate', title: 'Vencimiento', type: 'date', required: true, width: '130px' },
{ key: 'CheckSum', title: 'Importe', type: 'number', decimals: 2, required: true, width: '130px' },
];
protected readonly accountColumns: readonly DetailColumn[] = [
{ key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' },
{ key: 'Decription', title: 'Descripción', type: 'text', width: '260px' },
{ key: 'SumPaid', title: 'Importe', type: 'number', decimals: 2, required: true, width: '130px' },
];
/** Las filas de documentos como las muestra la tabla. */
protected readonly docRows = computed<Row[]>(() => this.docs() as unknown as Row[]);
// --- estado --------------------------------------------------------------------------------
protected readonly status = computed<StatusMessage>(() => {
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
if (this.loadError()) return { kind: 'err', text: this.loadError() };
const message = this.message();
if (message) return message;
if (this.cancelled()) return { kind: 'info', text: 'Pago anulado: solo lectura.' };
if (this.saved()) return { kind: 'info', text: 'Pago contabilizado: no se modifica; se puede anular.' };
return { kind: 'info', text: this.toAccounts() ? 'Indique las cuentas y los medios de pago.' : `Indique el ${this.screen.partner.label.toLowerCase()} y los medios de pago.` };
});
protected readonly badge = computed<WindowBadge | undefined>(() => {
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
if (!this.doc()) return undefined;
return this.cancelled() ? { label: 'Anulado', tone: 'muted' } : { label: 'Vigente', tone: 'ok' };
});
protected readonly windowTitle = computed(() => {
const n = this.doc()?.['DocNum'];
return n === undefined ? this.title : `${this.title} · ${text(n)}`;
});
// --- edición -------------------------------------------------------------------------------
protected onType(value: string | number | null): void {
this.docType.set((value ?? this.screen.partner.docType) as PaymentDocType);
this.message.set(null);
}
/** Al escribir otro código el nombre y los documentos anteriores ya no valen. */
protected onPartnerText(code: string): void {
if (code === this.cardCode()) return;
this.cardCode.set(code);
this.cardName.set('');
this.docs.set([]);
}
protected async onPartner(row: Row): Promise<void> {
const code = text(row['CardCode']);
this.cardName.set(text(row['CardName']));
this.docs.set([]);
this.loadingDocs.set(true);
try {
const open = await this.openDocs.load(this.screen, code);
if (this.cardCode() === code) this.docs.set(open);
if (!open.length) this.message.set({ kind: 'info', text: `${text(row['CardName']) || code} no tiene documentos pendientes: el pago quedará a cuenta.` });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.loadingDocs.set(false);
}
}
protected setMeans(patch: Partial<Means>): void {
this.means.update((m) => ({ ...m, ...patch }));
this.message.set(null);
}
protected setDocs(rows: Row[]): void {
this.docs.set(rows as unknown as OpenDoc[]);
this.message.set(null);
}
protected setChecks(rows: Row[]): void {
this.checks.set(rows);
this.message.set(null);
}
protected setAccounts(rows: Row[]): void {
this.accounts.set(rows);
this.message.set(null);
}
protected addCheck(): void {
this.setChecks([...this.checks(), { DueDate: this.docDate() }]);
}
protected addAccount(): void {
this.setAccounts([...this.accounts(), {}]);
}
protected payAll(on: boolean): void {
this.docs.update((d) => applyAll(d, on));
this.message.set(null);
}
/** Pone en la transferencia lo aplicado a los documentos (lo habitual: pagar justo lo que se debe). */
protected useApplied(): void {
const t = this.totals();
this.setMeans({ transferSum: t.applied > 0 ? Math.max(0, t.applied - t.cash - t.checks) : null });
}
// --- acciones ------------------------------------------------------------------------------
protected async go(target: NavTarget): Promise<void> {
try {
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry());
if (entry === null) {
this.message.set({ kind: 'warn', text: 'No hay más pagos en esa dirección.' });
return;
}
this.message.set(null);
await this.router.navigate(['..', entry], { relativeTo: this.route });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
}
protected async save(): Promise<void> {
if (this.saving() || this.saved()) return;
const draft = {
docType: this.docType(),
cardCode: this.cardCode(),
docDate: this.docDate(),
taxDate: this.taxDate(),
series: this.series(),
remarks: this.remarks(),
journalRemarks: this.journalRemarks(),
means: this.means(),
checks: this.checks(),
docs: this.docs(),
accounts: this.accounts(),
};
const errors = validatePayment(draft);
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
}
this.saving.set(true);
try {
const created = await this.api.create(this.screen.resource, paymentPayload(this.screen, draft));
await this.router.navigate(['..', created['DocEntry']], { relativeTo: this.route, state: { created: text(created['DocNum']) } });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.saving.set(false);
}
}
protected async cancelPayment(): Promise<void> {
const id = this.docEntry();
if (id === null || this.saving()) return;
this.saving.set(true);
this.confirmCancel.set(false);
try {
await this.api.action(this.screen.resource, id, 'Cancel');
this.message.set({ kind: 'ok', text: `${this.screen.singular} anulado.` });
this.resource.reload();
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.saving.set(false);
}
}
}
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ListView } from '../../shared/list/list-view';
import type { ListRow } from '../../shared/list/list.types';
import type { PaymentScreen } from './payment-types';
/** Lista de los últimos pagos (recibidos o efectuados); abre uno o crea uno nuevo. */
@Component({
selector: 'sbo-payment-list-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ListView],
template: `
<sbo-list-view
[title]="title"
[resource]="screen.resource"
[columns]="screen.columns"
[extraFields]="['Cancelled']"
[filters]="screen.filters"
[orderby]="screen.orderby"
rowKey="DocEntry"
[createLabel]="screen.createLabel"
(open)="open($event)"
(create)="create()"
/>
`,
styles: ':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }',
})
export class PaymentListPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly screen = this.route.snapshot.data['screen'] as PaymentScreen;
protected readonly title = this.route.snapshot.data['title'] as string;
protected open(row: ListRow): void {
void this.router.navigate([String(row['DocEntry'])], { relativeTo: this.route });
}
protected create(): void {
void this.router.navigate(['nuevo'], { relativeTo: this.route });
}
}
@@ -0,0 +1,239 @@
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);
});
});
@@ -0,0 +1,242 @@
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[];
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;
/** 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'>): 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 means = round2(cash + transfer + checks);
const applied = round2(invoices - credits);
return {
invoices,
credits,
applied,
cash,
transfer,
checks,
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.`);
});
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,
}),
);
}
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;
}
@@ -0,0 +1,5 @@
/**
* Rutas de menú que son pantallas de pagos. Es lo único que el enrutador necesita al arrancar; la configuración
* completa (`payment-screens.ts`) se carga en diferido. Un test comprueba que coincide con `PAYMENT_SCREENS`.
*/
export const PAYMENT_PATHS: readonly string[] = ['/bancos/pagos-recibidos', '/bancos/pagos-efectuados'];
@@ -0,0 +1,76 @@
import { MENU } from '@basic-erp/sap-types';
import type { SelectFilter } from '../../core/sl/list-filters';
import type { ListColumn } from '../../shared/list/list.types';
import type { PaymentScreen } from './payment-types';
const docNum: ListColumn = { key: 'DocNum', title: 'Número', type: 'number', align: 'left', width: '110px', mono: true, searchable: true, format: (r) => String(r['DocNum'] ?? '') };
const status: ListColumn = {
key: 'Cancelled',
title: 'Estado',
width: '100px',
format: (r) => (r['Cancelled'] === 'tYES' ? 'Anulado' : 'Vigente'),
tone: (r) => (r['Cancelled'] === 'tYES' ? 'muted' : 'ok'),
};
const statusFilter: SelectFilter = {
type: 'select',
key: 'Cancelled',
label: 'Estado',
defaultValue: 'tNO',
options: [
{ value: 'tNO', label: 'Vigente' },
{ value: 'tYES', label: 'Anulado' },
],
};
/** Columnas de la lista: un pago no trae un total propio, así que se muestran sus medios en efectivo y transferencia. */
const columns = (partner: string): readonly ListColumn[] => [
docNum,
{ key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '110px' },
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
{ key: 'CardName', title: 'Nombre', searchable: true },
{ key: 'CashSum', title: 'Efectivo', type: 'currency', width: '120px' },
{ key: 'TransferSum', title: 'Transferencia', type: 'currency', width: '130px' },
status,
];
const dateFilter = { type: 'date', key: 'DocDate', label: 'Fecha' } as const;
export const PAYMENT_SCREENS: readonly PaymentScreen[] = [
{
path: '/bancos/pagos-recibidos',
resource: 'IncomingPayments',
kind: 'incoming',
singular: 'Pago recibido',
createLabel: 'Nuevo pago recibido',
partner: { label: 'Cliente', type: 'cCustomer', docType: 'rCustomer', lookupTitle: 'Clientes' },
invoices: { resource: 'Invoices', type: 'it_Invoice', label: 'Factura' },
credits: { resource: 'CreditNotes', type: 'it_CredItnote', label: 'Nota de crédito' },
objectType: 24,
localCurrency: 'CLP',
countryCode: 'CL',
columns: columns('Cliente'),
filters: [statusFilter, dateFilter],
orderby: 'DocEntry desc',
},
{
path: '/bancos/pagos-efectuados',
resource: 'VendorPayments',
kind: 'outgoing',
singular: 'Pago efectuado',
createLabel: 'Nuevo pago efectuado',
partner: { label: 'Proveedor', type: 'cSupplier', docType: 'rSupplier', lookupTitle: 'Proveedores' },
invoices: { resource: 'PurchaseInvoices', type: 'it_PurchaseInvoice', label: 'Factura de proveedor' },
credits: { resource: 'PurchaseCreditNotes', type: 'it_PurchaseCreditNote', label: 'Nota de crédito de compra' },
objectType: 46,
localCurrency: 'CLP',
countryCode: 'CL',
columns: columns('Proveedor'),
filters: [statusFilter, dateFilter],
orderby: 'DocEntry desc',
},
];
/** Nombre de una pantalla de pagos en el menú. */
export const paymentTitle = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
@@ -0,0 +1,34 @@
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn } from '../../shared/list/list.types';
export type PaymentKind = 'incoming' | 'outgoing';
/** Tipo de pago de SAP: a un cliente, a un proveedor o directo a cuentas contables. */
export type PaymentDocType = 'rCustomer' | 'rSupplier' | 'rAccount';
/** Tipo de documento al que se aplica un pago (`PaymentInvoices.InvoiceType`). */
export type AppliedType = 'it_Invoice' | 'it_CredItnote' | 'it_PurchaseInvoice' | 'it_PurchaseCreditNote';
/** Pantalla de pagos: recibidos (de clientes) o efectuados (a proveedores). */
export interface PaymentScreen {
path: string;
/** Recurso del proxy `/api/sl`. */
resource: 'IncomingPayments' | 'VendorPayments';
kind: PaymentKind;
/** Nombre en singular, para el título del formulario. */
singular: string;
createLabel: string;
partner: { label: string; type: 'cCustomer' | 'cSupplier'; docType: 'rCustomer' | 'rSupplier'; lookupTitle: string };
/** Facturas pendientes del socio y notas de crédito que se pueden aplicar. */
invoices: { resource: string; type: AppliedType; label: string };
credits: { resource: string; type: AppliedType; label: string };
/** Tipo de objeto de SAP del pago (para pedir sus series de numeración). */
objectType: number;
/** Solo se listan y aplican documentos en esta moneda (la local de la empresa). */
localCurrency: string;
/** País de los bancos de los cheques. */
countryCode: string;
columns: readonly ListColumn[];
filters: readonly ListFilter[];
orderby: string;
}