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:
1 parent
a77995536b
commit
c9d1a714c9
8 files changed
+91
-3
No files matched your search
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user