feat(checkout): enhance transfer payment flow with DNI input and validation

This commit is contained in:
2026-07-16 10:55:28 -03:00
parent f163e8a353
commit 524eea38f4
11 changed files with 173 additions and 59 deletions

View File

@@ -81,9 +81,13 @@ export class CheckoutService {
return purchase;
}
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos'): Promise<any> {
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.payer_dni = payerDni;
}
const response = await firstValueFrom(
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, { method })
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload)
);
if (!response) {
throw new Error('Error al generar la intención de pago.');

View File

@@ -21,6 +21,7 @@
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()"
(generateTransferIntent)="generateTransferIntent($event)"
(complete)="onComplete()"
/>
</app-step>

View File

@@ -86,12 +86,7 @@ export class CheckoutPageComponent implements OnInit {
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = signal<TransferAccount>({
titular: 'Nombre y Apellido',
entidad: 'TelePagos',
cvu: '0000000000000000000000',
alias: 'telepagos.ar',
});
protected readonly transferAccount = signal<TransferAccount | null>(null);
protected readonly isCreatingPurchase = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null);
@@ -219,6 +214,11 @@ export class CheckoutPageComponent implements OnInit {
return;
}
if (method === 'transfer') {
this.transferAccount.set(null);
return;
}
this.isGeneratingIntent.set(true);
try {
@@ -230,7 +230,32 @@ export class CheckoutPageComponent implements OnInit {
if (method === 'qr' && response.qr_data?.qr_code) {
this.qrData.set(response.qr_data.qr_code);
} else if (method === 'transfer' && response.transfer_data) {
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
} finally {
this.isGeneratingIntent.set(false);
}
}
protected async generateTransferIntent(dni: string): Promise<void> {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
return;
}
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService.generatePaymentIntent(
tenant.codigo,
purchaseId,
'transfer',
dni
);
if (response.transfer_data) {
this.transferAccount.set({
titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad,
@@ -239,7 +264,7 @@ export class CheckoutPageComponent implements OnInit {
});
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
console.error('Failed to generate transfer payment intent:', error);
} finally {
this.isGeneratingIntent.set(false);
}

View File

@@ -46,6 +46,7 @@
[transferAccount]="transferAccount()"
[copiedTransferField]="copiedTransferField()"
(copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
/>
} @else {
<app-checkout-payment-telepagos />
@@ -68,7 +69,7 @@
type="button"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
[disabled]="isGeneratingIntent()"
[disabled]="isGeneratingIntent() || (selectedPaymentMethod() === 'transfer' && !transferAccount())"
(click)="complete.emit()"
>
Finalizar compra

View File

@@ -112,7 +112,7 @@
min-width: 0;
&__card {
width: 100%;
max-width: 290px;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;

View File

@@ -18,7 +18,7 @@ export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
readonly selectedPaymentMethod = input.required<PaymentMethod>();
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input.required<TransferAccount>();
readonly transferAccount = input<TransferAccount | null>(null);
readonly isGeneratingIntent = input<boolean>(false);
readonly qrData = input<string | null>(null);
@@ -26,6 +26,7 @@ export class CheckoutPaymentStepComponent {
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>();
readonly complete = output<void>();
readonly generateTransferIntent = output<string>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);

View File

@@ -1,6 +1,6 @@
.payment-panel__card {
width: 100%;
max-width: 290px;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;

View File

@@ -1,6 +1,6 @@
.payment-panel__card {
width: 100%;
max-width: 290px;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;

View File

@@ -1,46 +1,74 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3>
<div class="payment-panel__divider"></div>
@if (transferAccount()) {
<h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3>
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<strong class="payment-detail__value">{{ transferAccount().titular }}</strong>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<strong class="payment-detail__value">{{ transferAccount()!.titular }}</strong>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<strong class="payment-detail__value">{{ transferAccount()!.entidad }}</strong>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<strong class="payment-detail__value">{{ transferAccount()!.cvu }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount()!.cvu)"
/>
</div>
@if (copiedTransferField() === 'cvu') {
<small class="payment-detail__feedback">CVU copiado</small>
}
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<strong class="payment-detail__value">{{ transferAccount()!.alias }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar alias"
(click)="requestCopy('alias', transferAccount()!.alias)"
/>
</div>
@if (copiedTransferField() === 'alias') {
<small class="payment-detail__feedback">Alias copiado</small>
}
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<strong class="payment-detail__value">{{ transferAccount().entidad }}</strong>
} @else {
<div class="dni-form-container">
<h3 class="dni-form-title">Ingresá DNI de quién va a transferir</h3>
<form class="dni-form row gx-2" (ngSubmit)="onSubmitDni()">
<div class="col-8">
<app-input
type="text"
placeholder="99.999.999"
[value]="dniControl.value"
(valueChange)="dniControl.setValue($event.toString()); dniControl.markAsDirty()"
[invalid]="dniControl.invalid && (dniControl.dirty || dniControl.touched)"
/>
</div>
<div class="col-4">
<app-button
type="submit"
variant="primary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
[disabled]="dniControl.invalid"
>
Continuar
</app-button>
</div>
</form>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<strong class="payment-detail__value">{{ transferAccount().cvu }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount().cvu)"
/>
</div>
@if (copiedTransferField() === 'cvu') {
<small class="payment-detail__feedback">CVU copiado</small>
}
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<strong class="payment-detail__value">{{ transferAccount().alias }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar alias"
(click)="requestCopy('alias', transferAccount().alias)"
/>
</div>
@if (copiedTransferField() === 'alias') {
<small class="payment-detail__feedback">Alias copiado</small>
}
</div>
}
</div>

View File

@@ -1,10 +1,10 @@
.payment-panel__card {
width: 100%;
max-width: 290px;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
background: #bbb6b6;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
@@ -76,3 +76,42 @@
font-weight: 700;
}
}
.dni-form-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
flex: 1;
width: 100%;
padding: 1rem 0;
}
.dni-form-title {
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
margin: 0 0 1.25rem 0;
text-align: center;
}
.dni-form {
width: 100%;
margin: 0;
app-input {
display: block;
height: 100%;
margin-bottom: 0;
::ng-deep .input-control,
::ng-deep input.form-control {
height: 100%;
}
}
::ng-deep .btn {
min-width: 0 !important;
height: 100%;
}
}

View File

@@ -1,22 +1,37 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import { TransferAccount, TransferField } from '../../checkout-page.models';
@Component({
selector: 'app-checkout-payment-transfer',
standalone: true,
imports: [IconButtonComponent],
imports: [IconButtonComponent, ButtonComponent, InputComponent, ReactiveFormsModule],
templateUrl: './checkout-payment-transfer.component.html',
styleUrl: './checkout-payment-transfer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentTransferComponent {
readonly transferAccount = input.required<TransferAccount>();
readonly transferAccount = input<TransferAccount | null>(null);
readonly copiedTransferField = input<TransferField | null>(null);
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>();
protected readonly dniControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)]
});
protected requestCopy(field: TransferField, value: string): void {
this.copyTransferValue.emit({ field, value });
}
protected onSubmitDni(): void {
if (this.dniControl.valid) {
this.submitDni.emit(this.dniControl.value);
}
}
}