feat: add WhatsApp support and improve payment timeout handling in checkout process

This commit is contained in:
2026-07-28 16:57:00 -03:00
parent 15a74bb56e
commit c233845ce0
9 changed files with 136 additions and 41 deletions

View File

@@ -31,6 +31,8 @@
[qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[qrPaymentAmount]="cartTotal()"
[whatsappUrl]="whatsappUrl()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"

View File

@@ -160,19 +160,19 @@ describe('CheckoutPageComponent payment validation', () => {
fixture.destroy();
});
it('stops QR polling after five minutes and can restart it', async () => {
it('stops QR polling after 45 seconds and can restart it', async () => {
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(300_000);
await vi.advanceTimersByTimeAsync(45_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(60);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(9);
expect(component.qrPaymentStatus()).toBe('timed_out');
component.retryQrPolling();
expect(component.qrPaymentStatus()).toBe('waiting');
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(61);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(10);
});
it('cancels QR polling when payment method changes or component is destroyed', async () => {

View File

@@ -59,7 +59,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 60;
private readonly qrPollingMaxAttempts = 9;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
@@ -119,6 +119,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
protected readonly whatsappUrl = computed(
() =>
this.tenantService
.tenant()
?.social_media?.find((socialMedia) => socialMedia.code === 'whatsapp')?.url ?? null,
);
constructor() {
this.form.statusChanges

View File

@@ -44,7 +44,8 @@
[qrData]="qrData()"
[paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else if (selectedPaymentMethod() === 'transfer') {
<app-checkout-payment-transfer

View File

@@ -30,6 +30,8 @@ export class CheckoutPaymentStepComponent {
readonly qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly qrPaymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>();
@@ -37,7 +39,6 @@ export class CheckoutPaymentStepComponent {
readonly cancelStep = output<void>();
readonly complete = output<void>();
readonly generateTransferIntent = output<string>();
readonly retryQrPolling = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);

View File

@@ -1,34 +1,47 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
@if (qrData()) {
<qrcode [qrdata]="qrData()!" [width]="200" [errorCorrectionLevel]="'M'"></qrcode>
} @else {
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
}
@if (isCheckingPayment()) {
<div class="payment-verification" role="status" aria-live="polite">
<span class="payment-verification__spinner" aria-hidden="true"></span>
<span class="payment-verification__message">Verificando pago</span>
</div>
}
</div>
@if (paymentStatus() === 'timed_out') {
<p class="payment-status payment-status--warning" role="status">
Todavía no recibimos el pago.
</p>
<app-button type="button" variant="secondary" (click)="retryPolling.emit()">
Volver a verificar
</app-button>
} @else if (paymentStatus() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
<div class="payment-timeout" role="alert">
<div class="payment-timeout__icon" aria-hidden="true">
<i class="fa-solid fa-xmark"></i>
</div>
<h4 class="payment-timeout__title">No pudimos verificar el pago de {{ formattedAmount() }}.</h4>
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
@if (whatsappUrl()) {
<app-button
type="button"
variant="primary"
buttonClass="d-inline-flex align-items-center justify-content-center gap-2"
(click)="openWhatsApp()"
>
<span>WhatsApp</span>
</app-button>
}
</div>
} @else {
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
@if (qrData()) {
<qrcode [qrdata]="qrData()!" [width]="200" [errorCorrectionLevel]="'M'"></qrcode>
} @else {
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
}
@if (isCheckingPayment()) {
<div class="payment-verification" role="status" aria-live="polite">
<span class="payment-verification__spinner" aria-hidden="true"></span>
<span class="payment-verification__message">Verificando pago</span>
</div>
}
</div>
@if (paymentStatus() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
}
}
</div>

View File

@@ -116,6 +116,43 @@
}
}
.payment-timeout {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-top: 0.85rem;
&__icon {
display: grid;
width: 84px;
height: 84px;
place-items: center;
border: 1px solid var(--tenant-danger, #dc3545);
border-radius: 50%;
color: var(--tenant-danger, #dc3545);
font-size: 2.7rem;
}
&__title {
max-width: 250px;
margin: 1.35rem 0 0;
color: var(--tenant-danger, #dc3545);
font-size: 1.05rem;
font-weight: 700;
line-height: 1.3;
}
&__message {
max-width: 250px;
margin: 2rem 0 1.75rem;
color: #666666;
font-size: 1rem;
line-height: 1.25;
}
}
.payment-verification {
position: absolute;
inset: 0;

View File

@@ -27,4 +27,23 @@ describe('CheckoutPaymentQrComponent', () => {
expect(overlay.textContent).toContain('Verificando pago');
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
});
it('shows the payment verification error and WhatsApp action after timeout', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentQrComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
fixture.componentRef.setInput('paymentStatus', 'timed_out');
fixture.componentRef.setInput('paymentAmount', 300000);
fixture.componentRef.setInput('whatsappUrl', 'https://wa.me/543412602222');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const whatsapp = element.querySelector<HTMLAnchorElement>('.payment-timeout__whatsapp');
expect(element.textContent).toMatch(/No pudimos verificar el pago de \$\s*300\.000\./);
expect(element.textContent).toContain('Por favor contactate con nosotros para resolverlo.');
expect(whatsapp?.href).toBe('https://wa.me/543412602222');
expect(element.textContent).not.toContain('Volver a verificar');
});
});

View File

@@ -1,12 +1,12 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
import { QrPaymentStatus } from '../../checkout-page.models';
@Component({
selector: 'app-checkout-payment-qr',
standalone: true,
imports: [QRCodeComponent, ButtonComponent],
imports: [ButtonComponent, QRCodeComponent],
templateUrl: './checkout-payment-qr.component.html',
styleUrl: './checkout-payment-qr.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -15,5 +15,21 @@ export class CheckoutPaymentQrComponent {
readonly qrData = input<string | null>(null);
readonly paymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingPayment = input<boolean>(false);
readonly retryPolling = output<void>();
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
protected readonly formattedAmount = computed(() =>
new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: 0,
}).format(this.paymentAmount()),
);
protected openWhatsApp(): void {
const url = this.whatsappUrl();
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
}