From c9d1a714c975831c15b107b8fd9fdf529d46d214 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 14:26:42 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20tarjetas=20de=20cr=C3=A9dito=20en=20pag?= =?UTF-8?q?os=20recibidos=20(cat=C3=A1logo=20CreditCards,=20tabla=20en=20e?= =?UTF-8?q?l=20formulario,=20validaci=C3=B3n=20y=20env=C3=ADo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/sl/sl-resources.spec.ts | 1 + apps/api/src/sl/sl-resources.ts | 1 + .../features/banking/payment-form.page.html | 11 +++++++ .../app/features/banking/payment-form.page.ts | 30 ++++++++++++++++- .../features/banking/payment-model.spec.ts | 15 +++++++++ .../src/app/features/banking/payment-model.ts | 32 +++++++++++++++++-- .../app/features/banking/payment-screens.ts | 2 ++ .../src/app/features/banking/payment-types.ts | 2 ++ 8 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index 98cfb74..768f3f2 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -62,5 +62,6 @@ describe('recursos del Service Layer', () => { it('los bancos son un catálogo de solo lectura', () => { expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']); + expect(SL_RESOURCE_MAP.get('CreditCards')!.methods).toEqual(['GET']); }); }); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index cb092c9..a093812 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -95,6 +95,7 @@ export const SL_RESOURCES: readonly SlResource[] = [ catalog('BusinessPartnerGroups', 'number'), catalog('ItemGroups', 'number'), catalog('Banks', 'string'), + catalog('CreditCards', 'number'), // Dimensiones, proyectos y cuentas de los documentos (para elegirlos en cabecera y líneas). // Indicadores de los documentos (tabla OIDC; en la localización chilena, el tipo de documento: 33 factura, 39 boleta). catalog('FactoringIndicators', 'string'), diff --git a/apps/web/src/app/features/banking/payment-form.page.html b/apps/web/src/app/features/banking/payment-form.page.html index 2d92f50..c1c0b17 100644 --- a/apps/web/src/app/features/banking/payment-form.page.html +++ b/apps/web/src/app/features/banking/payment-form.page.html @@ -84,6 +84,14 @@

} + @if (screen.cards && (!saved() || cards().length)) { +

Tarjetas de crédito

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

+ } + } +
@@ -102,6 +110,9 @@ Efectivo{{ fmt(totals().cash) }} Transferencia{{ fmt(totals().transfer) }} Cheques{{ fmt(totals().checks) }} + @if (screen.cards) { + Tarjetas de crédito{{ fmt(totals().cards) }} + } Total medios de pago{{ fmt(totals().means) }} @if (!toAccounts()) { A cuenta del socio de negocio{{ fmt(totals().onAccount) }} diff --git a/apps/web/src/app/features/banking/payment-form.page.ts b/apps/web/src/app/features/banking/payment-form.page.ts index 116bf13..5e32948 100644 --- a/apps/web/src/app/features/banking/payment-form.page.ts +++ b/apps/web/src/app/features/banking/payment-form.page.ts @@ -25,6 +25,16 @@ 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 CREDIT_CARDS: VhLookup = { + title: 'Tarjetas de crédito', + resource: 'CreditCards', + orderby: 'CreditCardCode', + columns: [ + { key: 'CreditCardCode', title: 'Código', width: '100px', mono: true }, + { key: 'CreditCardName', title: 'Nombre' }, + ], +}; + const BANKS: VhLookup = { title: 'Bancos', resource: 'Banks', @@ -109,6 +119,7 @@ export class PaymentFormPage { }; }); protected readonly checks = linkedSignal(() => ((this.doc()?.['PaymentChecks'] as Row[] | undefined) ?? []).map((c) => ({ ...c, DueDate: dateOnly(c['DueDate']) }))); + protected readonly cards = linkedSignal(() => ((this.doc()?.['PaymentCreditCards'] as Row[] | undefined) ?? []).map((c) => ({ ...c, CardValidUntil: dateOnly(c['CardValidUntil']) }))); 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) => ({ @@ -137,7 +148,7 @@ export class PaymentFormPage { })(), ); - protected readonly totals = computed(() => paymentTotals({ means: this.means(), checks: this.checks(), docs: this.docs(), accounts: this.accounts() })); + protected readonly totals = computed(() => paymentTotals({ means: this.means(), checks: this.checks(), cards: this.cards(), docs: this.docs(), accounts: this.accounts() })); protected readonly toAccounts = computed(() => this.docType() === 'rAccount'); protected readonly fmt = money; @@ -199,6 +210,13 @@ export class PaymentFormPage { { key: 'DueDate', title: 'Vencimiento', type: 'date', required: true, width: '130px' }, { key: 'CheckSum', title: 'Importe', type: 'number', decimals: 2, required: true, width: '130px' }, ]; + protected readonly cardColumns: readonly DetailColumn[] = [ + { key: 'CreditCard', title: 'Tarjeta', type: 'lookup', required: true, lookup: CREDIT_CARDS, width: '90px' }, + { key: 'CreditCardNumber', title: '4 últimos dígitos', type: 'text', required: true, width: '130px' }, + { key: 'CardValidUntil', title: 'Válida hasta', type: 'date', required: true, width: '130px' }, + { key: 'VoucherNum', title: 'Comprobante', type: 'text', required: true, width: '130px' }, + { key: 'CreditSum', 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' }, @@ -275,6 +293,15 @@ export class PaymentFormPage { this.message.set(null); } + protected setCards(rows: Row[]): void { + this.cards.set(rows); + this.message.set(null); + } + + protected addCard(): void { + this.setCards([...this.cards(), { CardValidUntil: '' }]); + } + protected setAccounts(rows: Row[]): void { this.accounts.set(rows); this.message.set(null); @@ -327,6 +354,7 @@ export class PaymentFormPage { journalRemarks: this.journalRemarks(), means: this.means(), checks: this.checks(), + cards: this.cards(), docs: this.docs(), accounts: this.accounts(), }; diff --git a/apps/web/src/app/features/banking/payment-model.spec.ts b/apps/web/src/app/features/banking/payment-model.spec.ts index 223c90c..9bc0d69 100644 --- a/apps/web/src/app/features/banking/payment-model.spec.ts +++ b/apps/web/src/app/features/banking/payment-model.spec.ts @@ -236,4 +236,19 @@ describe('cuerpo del pago', () => { 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); }); + + it('las tarjetas de crédito suman a los medios, se validan y se envían con la moneda local', () => { + const card = { CreditCard: 2, CreditCardNumber: '1234', CardValidUntil: '2030-12-31', VoucherNum: 'V-1', CreditSum: 1000.456 }; + expect(paymentTotals({ means: { ...EMPTY_MEANS }, checks: [], cards: [card], docs: [], accounts: [] })).toMatchObject({ cards: 1000.46, means: 1000.46, onAccount: 1000.46 }); + expect(validatePayment(draft({ cards: [card] }))).toEqual([]); + expect(validatePayment(draft({ cards: [{ ...card, CreditCardNumber: '12', VoucherNum: '' }] }))).toEqual([ + 'Tarjeta 1: indique los 4 últimos dígitos.', + 'Tarjeta 1: indique el número de comprobante.', + ]); + expect(paymentPayload(incoming, draft({ cards: [card, { CreditCard: '', CreditSum: 0 }] }))['PaymentCreditCards']).toEqual([ + { CreditCard: 2, CreditCardNumber: '1234', CardValidUntil: '2030-12-31', VoucherNum: 'V-1', CreditSum: 1000.46, CreditCur: 'CLP' }, + ]); + expect(incoming.cards).toBe(true); + expect(outgoing.cards).toBe(false); + }); }); diff --git a/apps/web/src/app/features/banking/payment-model.ts b/apps/web/src/app/features/banking/payment-model.ts index 644f3c4..a7330af 100644 --- a/apps/web/src/app/features/banking/payment-model.ts +++ b/apps/web/src/app/features/banking/payment-model.ts @@ -64,6 +64,8 @@ export interface PaymentDraft { journalRemarks: string; means: Means; checks: Row[]; + /** Tarjetas de crédito (solo pagos recibidos). */ + cards?: Row[]; docs: OpenDoc[]; accounts: Row[]; } @@ -113,6 +115,7 @@ export interface PaymentTotals { cash: number; transfer: number; checks: number; + cards: number; /** Suma de los medios de pago. */ means: number; /** Lo que sobra de los medios sobre lo aplicado: queda a cuenta del socio. */ @@ -121,13 +124,14 @@ export interface PaymentTotals { accounts: number; } -export function paymentTotals(d: Pick): PaymentTotals { +export function paymentTotals(d: Pick & { cards?: Row[] }): PaymentTotals { const invoices = round2(d.docs.filter((x) => x.kind === 'invoice').reduce((s, x) => s + num(x.applied), 0)); const credits = round2(d.docs.filter((x) => x.kind === 'credit').reduce((s, x) => s + num(x.applied), 0)); const cash = round2(num(d.means.cashSum)); const transfer = round2(num(d.means.transferSum)); const checks = round2(d.checks.reduce((s, c) => s + num(c['CheckSum']), 0)); - const means = round2(cash + transfer + checks); + const cards = round2((d.cards ?? []).reduce((s, c) => s + num(c['CreditSum']), 0)); + const means = round2(cash + transfer + checks + cards); const applied = round2(invoices - credits); return { invoices, @@ -136,6 +140,7 @@ export function paymentTotals(d: Pick s + num(a['SumPaid']), 0)), @@ -169,6 +174,15 @@ export function validatePayment(draft: PaymentDraft): string[] { if (!has(c['DueDate'])) errors.push(`Cheque ${i + 1}: indique el vencimiento.`); }); + (draft.cards ?? []).forEach((c, i) => { + if (!has(c['CreditSum']) && !has(c['CreditCard']) && !has(c['CreditCardNumber'])) return; // fila vacía + if (num(c['CreditSum']) <= 0) errors.push(`Tarjeta ${i + 1}: indique el importe.`); + if (!has(c['CreditCard'])) errors.push(`Tarjeta ${i + 1}: indique la tarjeta.`); + if (!/^\d{4}$/.test(String(c['CreditCardNumber'] ?? '').trim())) errors.push(`Tarjeta ${i + 1}: indique los 4 últimos dígitos.`); + if (!has(c['CardValidUntil'])) errors.push(`Tarjeta ${i + 1}: indique la fecha de validez.`); + if (!has(c['VoucherNum'])) errors.push(`Tarjeta ${i + 1}: indique el número de comprobante.`); + }); + if (toAccounts) { const rows = draft.accounts.filter((a) => has(a['AccountCode']) || has(a['SumPaid'])); if (!rows.length) errors.push('Agregue al menos una cuenta contable.'); @@ -226,6 +240,20 @@ export function paymentPayload(screen: PaymentScreen, d: PaymentDraft): Row { ); } + const cards = (d.cards ?? []).filter((c) => num(c['CreditSum']) > 0); + if (cards.length) { + body['PaymentCreditCards'] = cards.map((c) => + clean({ + CreditCard: c['CreditCard'], + CreditCardNumber: String(c['CreditCardNumber']).trim(), + CardValidUntil: c['CardValidUntil'], + VoucherNum: c['VoucherNum'], + CreditSum: round2(num(c['CreditSum'])), + CreditCur: screen.localCurrency, + }), + ); + } + if (d.docType === 'rAccount') { body['PaymentAccounts'] = d.accounts.filter((a) => num(a['SumPaid']) > 0).map((a) => clean({ AccountCode: a['AccountCode'], SumPaid: round2(num(a['SumPaid'])), Decription: a['Decription'] })); } else { diff --git a/apps/web/src/app/features/banking/payment-screens.ts b/apps/web/src/app/features/banking/payment-screens.ts index f9c1898..154b9eb 100644 --- a/apps/web/src/app/features/banking/payment-screens.ts +++ b/apps/web/src/app/features/banking/payment-screens.ts @@ -50,6 +50,7 @@ export const PAYMENT_SCREENS: readonly PaymentScreen[] = [ objectType: 24, localCurrency: 'CLP', countryCode: 'CL', + cards: true, columns: columns('Cliente'), filters: [statusFilter, dateFilter], orderby: 'DocEntry desc', @@ -66,6 +67,7 @@ export const PAYMENT_SCREENS: readonly PaymentScreen[] = [ objectType: 46, localCurrency: 'CLP', countryCode: 'CL', + cards: false, columns: columns('Proveedor'), filters: [statusFilter, dateFilter], orderby: 'DocEntry desc', diff --git a/apps/web/src/app/features/banking/payment-types.ts b/apps/web/src/app/features/banking/payment-types.ts index 1cf9fe6..1bb43bf 100644 --- a/apps/web/src/app/features/banking/payment-types.ts +++ b/apps/web/src/app/features/banking/payment-types.ts @@ -28,6 +28,8 @@ export interface PaymentScreen { localCurrency: string; /** País de los bancos de los cheques. */ countryCode: string; + /** Admite tarjetas de crédito como medio de pago (los pagos efectuados no). */ + cards: boolean; columns: readonly ListColumn[]; filters: readonly ListFilter[]; orderby: string;