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
@@ -84,6 +84,14 @@
|
||||
<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-col">
|
||||
<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>Transferencia</td><td>{{ fmt(totals().transfer) }}</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>
|
||||
@if (!toAccounts()) {
|
||||
<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 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<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 docs = linkedSignal<OpenDoc[]>(() =>
|
||||
((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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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 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<PaymentDraft, 'means' | 'checks' | 'docs'
|
||||
cash,
|
||||
transfer,
|
||||
checks,
|
||||
cards,
|
||||
means,
|
||||
onAccount: round2(Math.max(0, means - applied)),
|
||||
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.`);
|
||||
});
|
||||
|
||||
(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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user