diff --git a/CLAUDE.md b/CLAUDE.md index 83cf568..68eee73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**. - 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 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). diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index c32600d..3d6ac0c 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -2,6 +2,8 @@ import { MENU } from '@basic-erp/sap-types'; import { describe, expect, it } from 'vitest'; import { routes } from './app.routes'; 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_SCREENS } from './features/documents/document-screens'; 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()); }); + 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ú', () => { const route = children.find((r) => r.path === 'dev/componentes'); expect(route?.loadComponent).toBeDefined(); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 5e5f034..7636b04 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -3,6 +3,7 @@ import type { Route, Routes } from '@angular/router'; import { MENU } from '@basic-erp/sap-types'; import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards'; import { DOCUMENT_PATHS } from './features/documents/document-paths'; +import { PAYMENT_PATHS } from './features/banking/payment-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; @@ -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); * 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. */ -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) .filter((item) => !builtPaths.has(item.path)) .map( @@ -118,6 +136,7 @@ export const routes: Routes = [ ...devRoutes, ...documentRoutes, ...masterRoutes, + ...paymentRoutes, ...adminRoutes, ...pendingRoutes, ], diff --git a/apps/web/src/app/core/sl/sl-document.ts b/apps/web/src/app/core/sl/sl-document.ts index 7468c0d..9e4fe7f 100644 --- a/apps/web/src/app/core/sl/sl-document.ts +++ b/apps/web/src/app/core/sl/sl-document.ts @@ -31,6 +31,11 @@ export class SlDocumentApi { 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 { + return firstValueFrom(this.http.post(`${this.url(resource, docEntry)}/${action}`, {})); + } + /** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */ create(resource: string, body: DocRecord): Promise { return firstValueFrom(this.http.post(this.url(resource), body)); diff --git a/apps/web/src/app/features/banking/open-docs.ts b/apps/web/src/app/features/banking/open-docs.ts new file mode 100644 index 0000000..a7fdae2 --- /dev/null +++ b/apps/web/src/app/features/banking/open-docs.ts @@ -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>> { + 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>>(`${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 { + 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); + } +} diff --git a/apps/web/src/app/features/banking/payment-form.page.css b/apps/web/src/app/features/banking/payment-form.page.css new file mode 100644 index 0000000..22a76bc --- /dev/null +++ b/apps/web/src/app/features/banking/payment-form.page.css @@ -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; } diff --git a/apps/web/src/app/features/banking/payment-form.page.html b/apps/web/src/app/features/banking/payment-form.page.html new file mode 100644 index 0000000..62378e2 --- /dev/null +++ b/apps/web/src/app/features/banking/payment-form.page.html @@ -0,0 +1,128 @@ + + Lista + Nuevo + + + + + + + + +
+
+ + @if (!toAccounts()) { + + + } +
+
+ @if (isNew()) { + + } @else { + + } + + +
+
+ + @if (!toAccounts()) { +

Documentos pendientes

+ @if (saved()) { + + } @else { + +

+ + + +

+ } + } @else { +

Cuentas contables

+ + @if (!saved()) { +

+ } + } + +

Medios de pago

+
+
+ + + + +
+
+ + + +
+
+ +

Cheques

+ + @if (!saved()) { +

+ } + +
+
+ + +
+
+ @if (!saved()) { + + + @if (!toAccounts()) { + + + + } @else { + + } + + + + + @if (!toAccounts()) { + + } + +
Facturas{{ fmt(totals().invoices) }}
Notas de crédito{{ fmt(totals().credits) }}
Aplicado{{ fmt(totals().applied) }}
Cuentas{{ fmt(totals().accounts) }}
Efectivo{{ fmt(totals().cash) }}
Transferencia{{ fmt(totals().transfer) }}
Cheques{{ fmt(totals().checks) }}
Total medios de pago{{ fmt(totals().means) }}
A cuenta del socio{{ fmt(totals().onAccount) }}
+ } +
+
+ + @if (isNew()) { + + } @else if (!cancelled() && doc()) { + @if (confirmCancel()) { + ¿Anular el pago? + + + } @else { + + } + } + {{ saved() ? 'Cerrar' : 'Cancelar' }} +
diff --git a/apps/web/src/app/features/banking/payment-form.page.ts b/apps/web/src/app/features/banking/payment-form.page.ts new file mode 100644 index 0000000..116bf13 --- /dev/null +++ b/apps/web/src/app/features/banking/payment-form.page.ts @@ -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; + +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> = { + 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(() => { + 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(() => (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(() => (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(() => { + 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(() => ((this.doc()?.['PaymentChecks'] as Row[] | undefined) ?? []).map((c) => ({ ...c, DueDate: dateOnly(c['DueDate']) }))); + protected readonly accounts = linkedSignal(() => ((this.doc()?.['PaymentAccounts'] as Row[] | undefined) ?? []).map((a) => ({ ...a }))); + protected readonly docs = linkedSignal(() => + ((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 => { + 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>(() => + 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(() => this.docs() as unknown as Row[]); + + // --- estado -------------------------------------------------------------------------------- + + protected readonly status = computed(() => { + 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(() => { + 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 { + 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): 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/apps/web/src/app/features/banking/payment-list.page.ts b/apps/web/src/app/features/banking/payment-list.page.ts new file mode 100644 index 0000000..a381186 --- /dev/null +++ b/apps/web/src/app/features/banking/payment-list.page.ts @@ -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: ` + + `, + 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 }); + } +} diff --git a/apps/web/src/app/features/banking/payment-model.spec.ts b/apps/web/src/app/features/banking/payment-model.spec.ts new file mode 100644 index 0000000..223c90c --- /dev/null +++ b/apps/web/src/app/features/banking/payment-model.spec.ts @@ -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 => ({ + id: 'Invoices:206', + docEntry: 206, + docNum: 206, + kind: 'invoice', + type: 'it_Invoice', + label: 'Factura', + date: '2026-09-01', + dueDate: '2026-10-01', + reference: '', + total: 1000, + balance: 1000, + applied: 0, + ...over, +}); + +const draft = (over: Partial = {}): PaymentDraft => ({ + docType: 'rCustomer', + cardCode: 'C1', + docDate: '2026-10-16', + taxDate: '', + series: null, + remarks: '', + journalRemarks: '', + means: { ...EMPTY_MEANS }, + checks: [], + docs: [], + accounts: [], + ...over, +}); + +describe('pantallas de pagos', () => { + it('son dos, con sus recursos y facturas de su lado', () => { + expect(PAYMENT_SCREENS.map((s) => [s.path, s.resource, s.invoices.resource, s.credits.resource])).toEqual([ + ['/bancos/pagos-recibidos', 'IncomingPayments', 'Invoices', 'CreditNotes'], + ['/bancos/pagos-efectuados', 'VendorPayments', 'PurchaseInvoices', 'PurchaseCreditNotes'], + ]); + expect(incoming.partner.docType).toBe('rCustomer'); + expect(outgoing.partner.docType).toBe('rSupplier'); + }); + + it('la lista de rutas del enrutador coincide con las pantallas', () => { + expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort()); + }); + + it('los tipos de objeto de SAP son 24 (pagos recibidos) y 46 (efectuados)', () => { + expect([incoming.objectType, outgoing.objectType]).toEqual([24, 46]); + }); +}); + +describe('documentos pendientes', () => { + it('el filtro pide los abiertos del socio en la moneda local, escapando las comillas', () => { + expect(openDocsFilter("O'N", 'CLP')).toBe(`CardCode eq 'O''N' and DocumentStatus eq 'bost_Open' and DocCurrency eq 'CLP'`); + }); + + it('una factura de SAP: el saldo es el total menos lo ya pagado, y el folio es la referencia', () => { + const d = toOpenDoc(incoming, 'invoice', { DocEntry: 206, DocNum: 206, DocDate: '2026-09-01T00:00:00Z', DocDueDate: '2026-10-01T00:00:00Z', DocTotal: 7363125, PaidToDate: 363125, FolioPrefixString: 'F', FolioNumber: 77 }); + expect(d).toMatchObject({ id: 'Invoices:206', docEntry: 206, kind: 'invoice', type: 'it_Invoice', date: '2026-09-01', dueDate: '2026-10-01', total: 7363125, balance: 7000000, applied: 0, reference: 'F77' }); + }); + + it('una nota de crédito usa su propio recurso y tipo; sin folio, la referencia del documento', () => { + const d = toOpenDoc(incoming, 'credit', { DocEntry: 16, DocNum: 16, DocTotal: 163625, PaidToDate: 0, NumAtCard: 'NC-1' }); + expect(d).toMatchObject({ id: 'CreditNotes:16', kind: 'credit', type: 'it_CredItnote', label: 'Nota de crédito', balance: 163625, reference: 'NC-1' }); + expect(toOpenDoc(outgoing, 'credit', { DocEntry: 3, DocNum: 3, DocTotal: 1, PaidToDate: 0 })).toMatchObject({ type: 'it_PurchaseCreditNote' }); + }); + + it('«pagar todo» aplica el saldo de cada documento, y quitarlo lo deja en cero', () => { + const docs = [doc({ balance: 300 }), doc({ id: 'x', balance: 50.5 })]; + expect(applyAll(docs, true).map((d) => d.applied)).toEqual([300, 50.5]); + expect(applyAll(applyAll(docs, true), false).map((d) => d.applied)).toEqual([0, 0]); + }); +}); + +describe('totales', () => { + it('suma facturas, descuenta notas de crédito y suma los medios de pago, incluidos los cheques', () => { + const t = paymentTotals({ + means: { ...EMPTY_MEANS, cashSum: 100, transferSum: 400 }, + checks: [{ CheckSum: 250 }, { CheckSum: 50.5 }], + docs: [doc({ applied: 700 }), doc({ id: 'b', applied: 100 }), doc({ id: 'c', kind: 'credit', applied: 150 })], + accounts: [], + }); + expect(t).toMatchObject({ invoices: 800, credits: 150, applied: 650, cash: 100, transfer: 400, checks: 300.5, means: 800.5 }); + }); + + it('lo que sobra de los medios sobre lo aplicado queda a cuenta; nunca negativo', () => { + const base = { checks: [], accounts: [] }; + expect(paymentTotals({ ...base, means: { ...EMPTY_MEANS, transferSum: 1000 }, docs: [doc({ applied: 600 })] }).onAccount).toBe(400); + expect(paymentTotals({ ...base, means: { ...EMPTY_MEANS, transferSum: 100 }, docs: [doc({ applied: 600 })] }).onAccount).toBe(0); + }); + + it('suma las cuentas contables y redondea a dos decimales', () => { + const t = paymentTotals({ means: { ...EMPTY_MEANS, cashSum: 0.1 + 0.2 }, checks: [], docs: [], accounts: [{ SumPaid: 10.005 }, { SumPaid: 5 }] }); + expect(t.cash).toBe(0.3); + expect(t.accounts).toBe(15.01); + }); +}); + +describe('validación', () => { + const ok = () => draft({ means: { ...EMPTY_MEANS, transferAccount: '1-1-010-20-000', transferSum: 500 }, docs: [doc({ applied: 500 })] }); + + it('un pago correcto no tiene errores', () => expect(validatePayment(ok())).toEqual([])); + + it('exige el socio, la fecha y un medio de pago', () => { + expect(validatePayment(draft({ cardCode: '', docDate: '' }))).toEqual(['Indique el cliente.', 'Indique la fecha de contabilización.', 'Indique al menos un medio de pago con su importe.']); + expect(validatePayment(draft({ docType: 'rSupplier', cardCode: '' }))[0]).toBe('Indique el proveedor.'); + }); + + it('cada medio con importe exige su cuenta', () => { + expect(validatePayment(draft({ means: { ...EMPTY_MEANS, cashSum: 10 } }))).toEqual(['Indique la cuenta del efectivo.']); + expect(validatePayment(draft({ means: { ...EMPTY_MEANS, transferSum: 10 } }))).toEqual(['Indique la cuenta de la transferencia.']); + }); + + it('los cheques exigen importe, número, banco, vencimiento y la cuenta de cheques del pago', () => { + const errors = validatePayment(draft({ checks: [{ CheckSum: 100 }] })); + expect(errors).toEqual(['Indique la cuenta de cheques.', 'Cheque 1: indique el número.', 'Cheque 1: indique el banco.', 'Cheque 1: indique el vencimiento.']); + const full = draft({ means: { ...EMPTY_MEANS, checkAccount: '1-1-010-20-000' }, checks: [{ CheckSum: 100, CheckNumber: 1, BankCode: '001', DueDate: '2026-11-30' }] }); + expect(validatePayment(full)).toEqual([]); + }); + + it('una fila de cheque vacía se ignora', () => { + expect(validatePayment({ ...ok(), checks: [{}] })).toEqual([]); + }); + + it('no deja aplicar más que el saldo de la factura (SAP lo recortaría en silencio)', () => { + const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 9_999_999 }, docs: [doc({ balance: 327250, applied: 9_999_999 })] }); + expect(validatePayment(d)).toEqual(['Factura 206: no se puede aplicar más que su saldo (327250).']); + }); + + it('los medios de pago deben cubrir lo aplicado', () => { + const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 100 }, docs: [doc({ applied: 600 })] }); + expect(validatePayment(d)).toEqual(['Los medios de pago (100) no cubren lo aplicado a los documentos (600).']); + }); + + it('una nota de crédito aplicada reduce lo que los medios deben cubrir', () => { + const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 450 }, docs: [doc({ applied: 600 }), doc({ id: 'n', kind: 'credit', balance: 150, applied: 150 })] }); + expect(validatePayment(d)).toEqual([]); + }); + + it('un pago sin documentos queda a cuenta y es válido', () => { + expect(validatePayment(draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 50 } }))).toEqual([]); + }); + + it('a cuentas contables: exige cuentas con importe y que los medios igualen su total', () => { + const base = { docType: 'rAccount' as const, cardCode: '', means: { ...EMPTY_MEANS, cashAccount: 'C', cashSum: 5000 } }; + expect(validatePayment(draft({ ...base, accounts: [] }))).toEqual(['Agregue al menos una cuenta contable.']); + expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000' }] }))).toEqual(['Cuenta 1: indique el importe.', 'Los medios de pago (5000) deben igualar el total de las cuentas (0).']); + expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 4000 }] }))).toEqual(['Los medios de pago (5000) deben igualar el total de las cuentas (4000).']); + expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000 }] }))).toEqual([]); + }); +}); + +describe('cuerpo del pago', () => { + it('transferencia aplicada a una factura: solo lo indicado, con la fecha del pago si no hay otra', () => { + const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, transferAccount: '1-1-010-20-000', transferSum: 100000, transferReference: 'REF-1' }, docs: [doc({ docEntry: 819, applied: 100000 })] })); + expect(body).toEqual({ + DocType: 'rCustomer', + CardCode: 'C1', + DocDate: '2026-10-16', + TransferAccount: '1-1-010-20-000', + TransferSum: 100000, + TransferDate: '2026-10-16', + TransferReference: 'REF-1', + PaymentInvoices: [{ DocEntry: 819, InvoiceType: 'it_Invoice', SumApplied: 100000 }], + }); + }); + + it('la nota de crédito se aplica con importe NEGATIVO y las facturas sin aplicar no se envían', () => { + const body = paymentPayload(incoming, draft({ + means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 163625 }, + docs: [doc({ docEntry: 819, applied: 327250 }), doc({ id: 'CreditNotes:16', docEntry: 16, kind: 'credit', type: 'it_CredItnote', applied: 163625 }), doc({ id: 'z', docEntry: 5, applied: 0 })], + })); + expect(body['PaymentInvoices']).toEqual([ + { DocEntry: 819, InvoiceType: 'it_Invoice', SumApplied: 327250 }, + { DocEntry: 16, InvoiceType: 'it_CredItnote', SumApplied: -163625 }, + ]); + }); + + it('un pago a cuenta no lleva PaymentInvoices: la diferencia la deja a cuenta SAP', () => { + const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 50000 } })); + expect(body).not.toHaveProperty('PaymentInvoices'); + expect(body['TransferSum']).toBe(50000); + }); + + it('efectivo y transferencia a la vez; el medio sin importe no se envía', () => { + const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, cashAccount: 'C', cashSum: 20, transferAccount: 'T', transferSum: null } })); + expect(body).toMatchObject({ CashAccount: 'C', CashSum: 20 }); + for (const k of ['TransferAccount', 'TransferSum', 'TransferDate', 'CheckAccount']) expect(body).not.toHaveProperty(k); + }); + + it('los cheques llevan la cuenta de cheques en el pago, el país del banco y sin campos vacíos', () => { + const body = paymentPayload(incoming, draft({ + means: { ...EMPTY_MEANS, checkAccount: '1-1-010-20-000' }, + checks: [{ CheckNumber: 8001, BankCode: '001', Branch: '', AccounttNum: '00000001', DueDate: '2026-11-30', CheckSum: 30000 }, { CheckSum: 0 }], + })); + expect(body['CheckAccount']).toBe('1-1-010-20-000'); + expect(body['PaymentChecks']).toEqual([{ CheckNumber: 8001, BankCode: '001', AccounttNum: '00000001', DueDate: '2026-11-30', CheckSum: 30000, CountryCode: 'CL' }]); + }); + + it('a cuentas contables: sin socio y con PaymentAccounts', () => { + const body = paymentPayload(outgoing, draft({ + docType: 'rAccount', + cardCode: 'ignorado', + means: { ...EMPTY_MEANS, cashAccount: '1-1-010-10-001', cashSum: 5000 }, + accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000, Decription: 'Gasto' }, { AccountCode: '', SumPaid: 0 }], + })); + expect(body).toEqual({ + DocType: 'rAccount', + DocDate: '2026-10-16', + CashAccount: '1-1-010-10-001', + CashSum: 5000, + PaymentAccounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000, Decription: 'Gasto' }], + }); + }); + + it('serie, comentarios y fecha del documento solo si se indicaron', () => { + const body = paymentPayload(outgoing, draft({ docType: 'rSupplier', series: 21, taxDate: '2026-10-15', remarks: 'Pago facturas', journalRemarks: 'Pago', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1, transferDate: '2026-10-20' } })); + expect(body).toMatchObject({ DocType: 'rSupplier', Series: 21, TaxDate: '2026-10-15', Remarks: 'Pago facturas', JournalRemarks: 'Pago', TransferDate: '2026-10-20' }); + const bare = paymentPayload(outgoing, draft({ docType: 'rSupplier', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1 } })); + for (const k of ['Series', 'TaxDate', 'Remarks', 'JournalRemarks']) expect(bare).not.toHaveProperty(k); + }); +}); diff --git a/apps/web/src/app/features/banking/payment-model.ts b/apps/web/src/app/features/banking/payment-model.ts new file mode 100644 index 0000000..644f3c4 --- /dev/null +++ b/apps/web/src/app/features/banking/payment-model.ts @@ -0,0 +1,242 @@ +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[]; + 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): 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; +} diff --git a/apps/web/src/app/features/banking/payment-paths.ts b/apps/web/src/app/features/banking/payment-paths.ts new file mode 100644 index 0000000..4fed49f --- /dev/null +++ b/apps/web/src/app/features/banking/payment-paths.ts @@ -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']; diff --git a/apps/web/src/app/features/banking/payment-screens.ts b/apps/web/src/app/features/banking/payment-screens.ts new file mode 100644 index 0000000..f9c1898 --- /dev/null +++ b/apps/web/src/app/features/banking/payment-screens.ts @@ -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; diff --git a/apps/web/src/app/features/banking/payment-types.ts b/apps/web/src/app/features/banking/payment-types.ts new file mode 100644 index 0000000..1cf9fe6 --- /dev/null +++ b/apps/web/src/app/features/banking/payment-types.ts @@ -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; +}