feat: tarjetas de crédito en pagos recibidos (catálogo CreditCards, tabla en el formulario, validación y envío)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 14:26:42 -05:00
1 parent a77995536b
commit c9d1a714c9
8 files changed
+91 -3

No files matched your search

+1
View File
@@ -62,5 +62,6 @@ describe('recursos del Service Layer', () => {
it('los bancos son un catálogo de solo lectura', () => { it('los bancos son un catálogo de solo lectura', () => {
expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']); expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']);
expect(SL_RESOURCE_MAP.get('CreditCards')!.methods).toEqual(['GET']);
}); });
}); });
+1
View File
@@ -95,6 +95,7 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('BusinessPartnerGroups', 'number'), catalog('BusinessPartnerGroups', 'number'),
catalog('ItemGroups', 'number'), catalog('ItemGroups', 'number'),
catalog('Banks', 'string'), catalog('Banks', 'string'),
catalog('CreditCards', 'number'),
// Dimensiones, proyectos y cuentas de los documentos (para elegirlos en cabecera y líneas). // 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). // Indicadores de los documentos (tabla OIDC; en la localización chilena, el tipo de documento: 33 factura, 39 boleta).
catalog('FactoringIndicators', 'string'), catalog('FactoringIndicators', 'string'),
@@ -84,6 +84,14 @@
<p class="row"><button type="button" class="btn" (click)="addCheck()">+ Añadir cheque</button></p> <p class="row"><button type="button" class="btn" (click)="addCheck()">+ Añadir cheque</button></p>
} }
@if (screen.cards && (!saved() || cards().length)) {
<h3>Tarjetas de crédito</h3>
<sbo-rows-table [columns]="cardColumns" [rows]="cards()" (rowsChange)="setCards($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin tarjetas." />
@if (!saved()) {
<p class="row"><button type="button" class="btn" (click)="addCard()">+ Añadir tarjeta</button></p>
}
}
<div class="sbo-hdr" style="margin-top: 14px"> <div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col"> <div class="sbo-col">
<sbo-textarea-field label="Comentarios" [(value)]="remarks" [readonly]="saved()" /> <sbo-textarea-field label="Comentarios" [(value)]="remarks" [readonly]="saved()" />
@@ -102,6 +110,9 @@
<tr><td>Efectivo</td><td>{{ fmt(totals().cash) }}</td></tr> <tr><td>Efectivo</td><td>{{ fmt(totals().cash) }}</td></tr>
<tr><td>Transferencia</td><td>{{ fmt(totals().transfer) }}</td></tr> <tr><td>Transferencia</td><td>{{ fmt(totals().transfer) }}</td></tr>
<tr><td>Cheques</td><td>{{ fmt(totals().checks) }}</td></tr> <tr><td>Cheques</td><td>{{ fmt(totals().checks) }}</td></tr>
@if (screen.cards) {
<tr><td>Tarjetas de crédito</td><td>{{ fmt(totals().cards) }}</td></tr>
}
<tr class="strong"><td>Total medios de pago</td><td>{{ fmt(totals().means) }}</td></tr> <tr class="strong"><td>Total medios de pago</td><td>{{ fmt(totals().means) }}</td></tr>
@if (!toAccounts()) { @if (!toAccounts()) {
<tr><td>A cuenta del socio de negocio</td><td>{{ fmt(totals().onAccount) }}</td></tr> <tr><td>A cuenta del socio de negocio</td><td>{{ fmt(totals().onAccount) }}</td></tr>
@@ -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 dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
const money = (n: number): string => formatNumber(n, 2); 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 = { const BANKS: VhLookup = {
title: 'Bancos', title: 'Bancos',
resource: 'Banks', resource: 'Banks',
@@ -109,6 +119,7 @@ export class PaymentFormPage {
}; };
}); });
protected readonly checks = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentChecks'] as Row[] | undefined) ?? []).map((c) => ({ ...c, DueDate: dateOnly(c['DueDate']) }))); protected readonly checks = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentChecks'] as Row[] | undefined) ?? []).map((c) => ({ ...c, DueDate: dateOnly(c['DueDate']) })));
protected readonly cards = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentCreditCards'] as Row[] | undefined) ?? []).map((c) => ({ ...c, CardValidUntil: dateOnly(c['CardValidUntil']) })));
protected readonly accounts = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentAccounts'] as Row[] | undefined) ?? []).map((a) => ({ ...a }))); protected readonly accounts = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentAccounts'] as Row[] | undefined) ?? []).map((a) => ({ ...a })));
protected readonly docs = linkedSignal<OpenDoc[]>(() => protected readonly docs = linkedSignal<OpenDoc[]>(() =>
((this.doc()?.['PaymentInvoices'] as Row[] | undefined) ?? []).map((i) => ({ ((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 toAccounts = computed(() => this.docType() === 'rAccount');
protected readonly fmt = money; protected readonly fmt = money;
@@ -199,6 +210,13 @@ export class PaymentFormPage {
{ key: 'DueDate', title: 'Vencimiento', type: 'date', required: true, width: '130px' }, { key: 'DueDate', title: 'Vencimiento', type: 'date', required: true, width: '130px' },
{ key: 'CheckSum', title: 'Importe', type: 'number', decimals: 2, 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[] = [ protected readonly accountColumns: readonly DetailColumn[] = [
{ key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' }, { key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' },
{ key: 'Decription', title: 'Descripción', type: 'text', width: '260px' }, { key: 'Decription', title: 'Descripción', type: 'text', width: '260px' },
@@ -275,6 +293,15 @@ export class PaymentFormPage {
this.message.set(null); 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 { protected setAccounts(rows: Row[]): void {
this.accounts.set(rows); this.accounts.set(rows);
this.message.set(null); this.message.set(null);
@@ -327,6 +354,7 @@ export class PaymentFormPage {
journalRemarks: this.journalRemarks(), journalRemarks: this.journalRemarks(),
means: this.means(), means: this.means(),
checks: this.checks(), checks: this.checks(),
cards: this.cards(),
docs: this.docs(), docs: this.docs(),
accounts: this.accounts(), accounts: this.accounts(),
}; };
@@ -236,4 +236,19 @@ describe('cuerpo del pago', () => {
const bare = paymentPayload(outgoing, draft({ docType: 'rSupplier', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1 } })); 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); 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);
});
}); });
@@ -64,6 +64,8 @@ export interface PaymentDraft {
journalRemarks: string; journalRemarks: string;
means: Means; means: Means;
checks: Row[]; checks: Row[];
/** Tarjetas de crédito (solo pagos recibidos). */
cards?: Row[];
docs: OpenDoc[]; docs: OpenDoc[];
accounts: Row[]; accounts: Row[];
} }
@@ -113,6 +115,7 @@ export interface PaymentTotals {
cash: number; cash: number;
transfer: number; transfer: number;
checks: number; checks: number;
cards: number;
/** Suma de los medios de pago. */ /** Suma de los medios de pago. */
means: number; means: number;
/** Lo que sobra de los medios sobre lo aplicado: queda a cuenta del socio. */ /** Lo que sobra de los medios sobre lo aplicado: queda a cuenta del socio. */
@@ -121,13 +124,14 @@ export interface PaymentTotals {
accounts: number; accounts: number;
} }
export function paymentTotals(d: Pick<PaymentDraft, 'means' | 'checks' | 'docs' | 'accounts'>): PaymentTotals { export function paymentTotals(d: Pick<PaymentDraft, 'means' | 'checks' | 'docs' | 'accounts'> & { cards?: Row[] }): PaymentTotals {
const invoices = round2(d.docs.filter((x) => x.kind === 'invoice').reduce((s, x) => s + num(x.applied), 0)); const 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 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 cash = round2(num(d.means.cashSum));
const transfer = round2(num(d.means.transferSum)); const transfer = round2(num(d.means.transferSum));
const checks = round2(d.checks.reduce((s, c) => s + num(c['CheckSum']), 0)); 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); const applied = round2(invoices - credits);
return { return {
invoices, invoices,
@@ -136,6 +140,7 @@ export function paymentTotals(d: Pick<PaymentDraft, 'means' | 'checks' | 'docs'
cash, cash,
transfer, transfer,
checks, checks,
cards,
means, means,
onAccount: round2(Math.max(0, means - applied)), onAccount: round2(Math.max(0, means - applied)),
accounts: round2(d.accounts.reduce((s, a) => s + num(a['SumPaid']), 0)), accounts: round2(d.accounts.reduce((s, a) => 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.`); 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) { if (toAccounts) {
const rows = draft.accounts.filter((a) => has(a['AccountCode']) || has(a['SumPaid'])); const rows = draft.accounts.filter((a) => has(a['AccountCode']) || has(a['SumPaid']));
if (!rows.length) errors.push('Agregue al menos una cuenta contable.'); 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') { 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'] })); 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 { } else {
@@ -50,6 +50,7 @@ export const PAYMENT_SCREENS: readonly PaymentScreen[] = [
objectType: 24, objectType: 24,
localCurrency: 'CLP', localCurrency: 'CLP',
countryCode: 'CL', countryCode: 'CL',
cards: true,
columns: columns('Cliente'), columns: columns('Cliente'),
filters: [statusFilter, dateFilter], filters: [statusFilter, dateFilter],
orderby: 'DocEntry desc', orderby: 'DocEntry desc',
@@ -66,6 +67,7 @@ export const PAYMENT_SCREENS: readonly PaymentScreen[] = [
objectType: 46, objectType: 46,
localCurrency: 'CLP', localCurrency: 'CLP',
countryCode: 'CL', countryCode: 'CL',
cards: false,
columns: columns('Proveedor'), columns: columns('Proveedor'),
filters: [statusFilter, dateFilter], filters: [statusFilter, dateFilter],
orderby: 'DocEntry desc', orderby: 'DocEntry desc',
@@ -28,6 +28,8 @@ export interface PaymentScreen {
localCurrency: string; localCurrency: string;
/** País de los bancos de los cheques. */ /** País de los bancos de los cheques. */
countryCode: string; countryCode: string;
/** Admite tarjetas de crédito como medio de pago (los pagos efectuados no). */
cards: boolean;
columns: readonly ListColumn[]; columns: readonly ListColumn[];
filters: readonly ListFilter[]; filters: readonly ListFilter[];
orderby: string; orderby: string;