feat(checkout): implement QR payment polling and validation logic
This commit is contained in:
@@ -19,11 +19,15 @@
|
||||
[transferDni]="transferDni()"
|
||||
[isGeneratingIntent]="isPaymentLoading()"
|
||||
[qrData]="qrData()"
|
||||
[qrPaymentStatus]="qrPaymentStatus()"
|
||||
[isCheckingQrPayment]="isCheckingQrPayment()"
|
||||
[transferValidationStatus]="transferValidationStatus()"
|
||||
(paymentMethodChange)="selectPaymentMethod($event)"
|
||||
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
|
||||
(cancelStep)="onCancel()"
|
||||
(generateTransferIntent)="generateTransferIntent($event)"
|
||||
(complete)="onComplete()"
|
||||
(retryQrPolling)="retryQrPolling()"
|
||||
/>
|
||||
</app-step>
|
||||
</app-stepper>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { getTestBed, TestBed } from '@angular/core/testing';
|
||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||
import { CheckoutService } from '../../../../core/services/checkout.service';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
import { CheckoutPageComponent } from './checkout-page.component';
|
||||
|
||||
describe('CheckoutPageComponent payment validation', () => {
|
||||
let checkoutServiceStub: {
|
||||
generatePaymentIntent: ReturnType<typeof vi.fn>;
|
||||
getPurchase: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let cartServiceStub: {
|
||||
cart: ReturnType<typeof signal>;
|
||||
isUpdating: ReturnType<typeof signal<boolean>>;
|
||||
loadCart: ReturnType<typeof vi.fn>;
|
||||
clearCart: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let routerStub: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeAll(() => {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||
} catch {
|
||||
// Test environment may already be initialized by another setup entrypoint.
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
checkoutServiceStub = {
|
||||
generatePaymentIntent: vi.fn().mockResolvedValue({
|
||||
qr_data: { qr_code: 'qr-value' },
|
||||
}),
|
||||
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
|
||||
};
|
||||
cartServiceStub = {
|
||||
cart: signal({
|
||||
id: 10,
|
||||
tenant_codigo: 'tenant-test',
|
||||
status: 'active',
|
||||
items: [],
|
||||
subtotal: '0.00',
|
||||
}),
|
||||
isUpdating: signal(false),
|
||||
loadCart: vi.fn().mockReturnValue(of({})),
|
||||
clearCart: vi.fn(),
|
||||
};
|
||||
cartServiceStub.clearCart.mockImplementation(() => {
|
||||
cartServiceStub.cart.set({
|
||||
id: null,
|
||||
tenant_codigo: 'tenant-test',
|
||||
status: 'active',
|
||||
items: [],
|
||||
subtotal: '0.00',
|
||||
});
|
||||
});
|
||||
routerStub = { navigate: vi.fn() };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CheckoutPageComponent],
|
||||
providers: [
|
||||
{ provide: CheckoutService, useValue: checkoutServiceStub },
|
||||
{ provide: CartService, useValue: cartServiceStub },
|
||||
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
|
||||
{ provide: AuthService, useValue: { user: signal(null) } },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
],
|
||||
})
|
||||
.overrideComponent(CheckoutPageComponent, { set: { template: '' } })
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
function createComponent(): any {
|
||||
const fixture = TestBed.createComponent(CheckoutPageComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance['createdPurchaseId'].set(25);
|
||||
return { fixture, component: fixture.componentInstance as any };
|
||||
}
|
||||
|
||||
it('polls QR after five seconds and navigates only when payment is paid', async () => {
|
||||
checkoutServiceStub.getPurchase
|
||||
.mockResolvedValueOnce({ status: 'pending_payment' })
|
||||
.mockResolvedValueOnce({ status: 'paid' });
|
||||
const { component } = createComponent();
|
||||
|
||||
await component.selectPaymentMethod('qr');
|
||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
|
||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
|
||||
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the QR verification state while each status request is in progress', async () => {
|
||||
let resolvePurchase!: (purchase: { status: string }) => void;
|
||||
checkoutServiceStub.getPurchase.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePurchase = resolve;
|
||||
}),
|
||||
);
|
||||
const { fixture, component } = createComponent();
|
||||
|
||||
const statusRequest = component.checkQrPayment(component.qrPollingRunId);
|
||||
expect(component.isCheckingQrPayment()).toBe(true);
|
||||
|
||||
resolvePurchase({ status: 'pending_payment' });
|
||||
await statusRequest;
|
||||
expect(component.isCheckingQrPayment()).toBe(false);
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('stops QR polling after five minutes and can restart it', async () => {
|
||||
const { component } = createComponent();
|
||||
|
||||
await component.selectPaymentMethod('qr');
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(60);
|
||||
expect(component.qrPaymentStatus()).toBe('timed_out');
|
||||
|
||||
component.retryQrPolling();
|
||||
expect(component.qrPaymentStatus()).toBe('waiting');
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(61);
|
||||
});
|
||||
|
||||
it('cancels QR polling when payment method changes or component is destroyed', async () => {
|
||||
const first = createComponent();
|
||||
await first.component.selectPaymentMethod('qr');
|
||||
await first.component.selectPaymentMethod('transfer');
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||
|
||||
const second = createComponent();
|
||||
await second.component.selectPaymentMethod('qr');
|
||||
second.fixture.destroy();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks a transfer once and redirects to purchase status while pending', async () => {
|
||||
const { component } = createComponent();
|
||||
component.selectedPaymentMethod.set('transfer');
|
||||
|
||||
await component.onComplete();
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
|
||||
expect(component.transferValidationStatus()).toBe('pending');
|
||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
|
||||
|
||||
await component.onComplete();
|
||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('navigates after a transfer is confirmed as paid', async () => {
|
||||
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
|
||||
const { component } = createComponent();
|
||||
component.selectedPaymentMethod.set('transfer');
|
||||
|
||||
await component.onComplete();
|
||||
|
||||
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||
});
|
||||
|
||||
it('shows a retryable state when transfer validation fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
|
||||
const { component } = createComponent();
|
||||
component.selectedPaymentMethod.set('transfer');
|
||||
|
||||
await component.onComplete();
|
||||
|
||||
expect(component.transferValidationStatus()).toBe('error');
|
||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
untracked,
|
||||
@@ -28,8 +29,10 @@ import {
|
||||
CheckoutForm,
|
||||
PaymentMethod,
|
||||
PaymentMethodOption,
|
||||
QrPaymentStatus,
|
||||
TransferAccount,
|
||||
TransferField,
|
||||
TransferValidationStatus,
|
||||
} from './checkout-page.models';
|
||||
|
||||
@Component({
|
||||
@@ -46,13 +49,21 @@ import {
|
||||
styleUrl: './checkout-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CheckoutPageComponent implements OnInit {
|
||||
export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly checkoutService = inject(CheckoutService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly qrPollingIntervalMs = 5_000;
|
||||
private readonly qrPollingMaxAttempts = 60;
|
||||
|
||||
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private qrPollingAttempts = 0;
|
||||
private qrPollingRunId = 0;
|
||||
private paymentMethodRequestId = 0;
|
||||
private navigationStarted = false;
|
||||
|
||||
@ViewChild(StepperComponent) stepper!: StepperComponent;
|
||||
|
||||
@@ -96,6 +107,9 @@ export class CheckoutPageComponent implements OnInit {
|
||||
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
|
||||
);
|
||||
protected readonly qrData = signal<string | null>(null);
|
||||
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
|
||||
protected readonly isCheckingQrPayment = signal(false);
|
||||
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -105,7 +119,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
|
||||
untracked(() => {
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
if (cart && purchaseId) {
|
||||
if (cart && purchaseId && !this.navigationStarted) {
|
||||
// Trigger payment intent generation when cart changes and we are on the payment step
|
||||
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
||||
}
|
||||
@@ -133,6 +147,10 @@ export class CheckoutPageComponent implements OnInit {
|
||||
this.cartService.loadCart().subscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopQrPolling();
|
||||
}
|
||||
|
||||
private mapCartItemToMock(item: CartItem): CartItemMock {
|
||||
const fullName = item.product?.nombre ?? '';
|
||||
let product = fullName;
|
||||
@@ -202,11 +220,24 @@ export class CheckoutPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected onCancel(): void {
|
||||
this.stopQrPolling();
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
|
||||
if (this.navigationStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopQrPolling();
|
||||
this.qrPaymentStatus.set('idle');
|
||||
this.transferValidationStatus.set('idle');
|
||||
this.selectedPaymentMethod.set(method);
|
||||
const requestId = ++this.paymentMethodRequestId;
|
||||
|
||||
if (method === 'qr') {
|
||||
this.qrData.set(null);
|
||||
}
|
||||
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
const tenant = this.tenantService.tenant();
|
||||
@@ -230,7 +261,12 @@ export class CheckoutPageComponent implements OnInit {
|
||||
);
|
||||
|
||||
if (method === 'qr' && response.qr_data?.qr_code) {
|
||||
if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.qrData.set(response.qr_data.qr_code);
|
||||
this.startQrPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate payment intent:', error);
|
||||
@@ -248,6 +284,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.transferDni.set(dni);
|
||||
this.transferValidationStatus.set('idle');
|
||||
this.isGeneratingIntent.set(true);
|
||||
try {
|
||||
const response = await this.checkoutService.generatePaymentIntent(
|
||||
@@ -293,16 +330,140 @@ export class CheckoutPageComponent implements OnInit {
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
const tenant = this.tenantService.tenant();
|
||||
|
||||
if (!purchaseId || !tenant) {
|
||||
if (
|
||||
!purchaseId ||
|
||||
!tenant ||
|
||||
this.navigationStarted ||
|
||||
this.transferValidationStatus() === 'checking'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transferValidationStatus.set('checking');
|
||||
|
||||
try {
|
||||
await this.checkoutService.completePurchase(tenant.codigo, purchaseId);
|
||||
this.cartService.clearCart();
|
||||
void this.router.navigate(['/checkout/status', purchaseId]);
|
||||
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
|
||||
|
||||
if (purchase.status === 'paid') {
|
||||
this.handleConfirmedPayment(purchaseId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchase.status === 'pending_payment') {
|
||||
this.transferValidationStatus.set('pending');
|
||||
this.navigateToPurchaseStatus(purchaseId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.transferValidationStatus.set('pending');
|
||||
} catch (error) {
|
||||
console.error('Failed to complete purchase:', error);
|
||||
console.error('Failed to validate transfer payment:', error);
|
||||
this.transferValidationStatus.set('error');
|
||||
}
|
||||
}
|
||||
|
||||
protected retryQrPolling(): void {
|
||||
if (this.selectedPaymentMethod() === 'qr' && this.qrData()) {
|
||||
this.startQrPolling();
|
||||
}
|
||||
}
|
||||
|
||||
private startQrPolling(): void {
|
||||
this.stopQrPolling();
|
||||
this.qrPollingAttempts = 0;
|
||||
this.qrPaymentStatus.set('waiting');
|
||||
|
||||
const runId = this.qrPollingRunId;
|
||||
this.scheduleQrPoll(runId);
|
||||
}
|
||||
|
||||
private scheduleQrPoll(runId: number): void {
|
||||
this.qrPollingTimeoutId = setTimeout(() => {
|
||||
this.qrPollingTimeoutId = null;
|
||||
void this.checkQrPayment(runId);
|
||||
}, this.qrPollingIntervalMs);
|
||||
}
|
||||
|
||||
private async checkQrPayment(runId: number): Promise<void> {
|
||||
if (runId !== this.qrPollingRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
const tenant = this.tenantService.tenant();
|
||||
|
||||
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'qr') {
|
||||
this.stopQrPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
this.qrPollingAttempts += 1;
|
||||
this.isCheckingQrPayment.set(true);
|
||||
|
||||
try {
|
||||
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
|
||||
|
||||
if (runId !== this.qrPollingRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchase.status === 'paid') {
|
||||
this.handleConfirmedPayment(purchaseId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (purchase.status === 'rejected' || purchase.status === 'cancelled') {
|
||||
this.stopQrPolling();
|
||||
this.qrPaymentStatus.set('failed');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to validate QR payment:', error);
|
||||
} finally {
|
||||
if (runId === this.qrPollingRunId) {
|
||||
this.isCheckingQrPayment.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (runId !== this.qrPollingRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.qrPollingAttempts >= this.qrPollingMaxAttempts) {
|
||||
this.stopQrPolling();
|
||||
this.qrPaymentStatus.set('timed_out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleQrPoll(runId);
|
||||
}
|
||||
|
||||
private stopQrPolling(): void {
|
||||
this.qrPollingRunId += 1;
|
||||
this.isCheckingQrPayment.set(false);
|
||||
|
||||
if (this.qrPollingTimeoutId !== null) {
|
||||
clearTimeout(this.qrPollingTimeoutId);
|
||||
this.qrPollingTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleConfirmedPayment(purchaseId: number): void {
|
||||
this.navigateToPurchaseStatus(purchaseId, true);
|
||||
}
|
||||
|
||||
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
|
||||
if (this.navigationStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigationStarted = true;
|
||||
this.stopQrPolling();
|
||||
|
||||
if (clearCart) {
|
||||
this.cartService.clearCart();
|
||||
}
|
||||
|
||||
void this.router.navigate(['/checkout/status', purchaseId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { FormControl, FormGroup } from '@angular/forms';
|
||||
|
||||
export type PaymentMethod = 'qr' | 'transfer' | 'telepagos';
|
||||
export type TransferField = 'cvu' | 'alias';
|
||||
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
|
||||
export type TransferValidationStatus = 'idle' | 'checking' | 'pending' | 'error';
|
||||
|
||||
export interface PaymentMethodOption {
|
||||
id: PaymentMethod;
|
||||
|
||||
@@ -40,12 +40,18 @@
|
||||
<h3 class="payment-panel__title">Cargando información de pago...</h3>
|
||||
</div>
|
||||
} @else if (selectedPaymentMethod() === 'qr') {
|
||||
<app-checkout-payment-qr [qrData]="qrData()" />
|
||||
<app-checkout-payment-qr
|
||||
[qrData]="qrData()"
|
||||
[paymentStatus]="qrPaymentStatus()"
|
||||
[isCheckingPayment]="isCheckingQrPayment()"
|
||||
(retryPolling)="retryQrPolling.emit()"
|
||||
/>
|
||||
} @else if (selectedPaymentMethod() === 'transfer') {
|
||||
<app-checkout-payment-transfer
|
||||
[transferAccount]="transferAccount()"
|
||||
[transferDni]="transferDni()"
|
||||
[copiedTransferField]="copiedTransferField()"
|
||||
[validationStatus]="transferValidationStatus()"
|
||||
(copyTransferValue)="requestCopy($event.field, $event.value)"
|
||||
(submitDni)="generateTransferIntent.emit($event)"
|
||||
(completePurchase)="complete.emit()"
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
|
||||
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
|
||||
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
|
||||
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
|
||||
import {
|
||||
PaymentMethod,
|
||||
PaymentMethodOption,
|
||||
QrPaymentStatus,
|
||||
TransferAccount,
|
||||
TransferField,
|
||||
TransferValidationStatus,
|
||||
} from './checkout-page.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout-payment-step',
|
||||
standalone: true,
|
||||
imports: [ButtonComponent, CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
|
||||
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
|
||||
templateUrl: './checkout-payment-step.component.html',
|
||||
styleUrl: './checkout-payment-step.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -22,12 +28,16 @@ export class CheckoutPaymentStepComponent {
|
||||
readonly transferDni = input<string>('');
|
||||
readonly isGeneratingIntent = input<boolean>(false);
|
||||
readonly qrData = input<string | null>(null);
|
||||
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
|
||||
readonly isCheckingQrPayment = input<boolean>(false);
|
||||
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
|
||||
|
||||
readonly paymentMethodChange = output<PaymentMethod>();
|
||||
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
||||
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);
|
||||
|
||||
@@ -10,5 +10,25 @@
|
||||
<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>
|
||||
|
||||
@@ -103,3 +103,48 @@
|
||||
left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-status {
|
||||
margin: 1rem 0 0;
|
||||
color: #6f6f6f;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--warning {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #9a6a12;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-verification {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
|
||||
&__spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 4px solid rgba(17, 17, 17, 0.2);
|
||||
border-top-color: #111111;
|
||||
border-radius: 50%;
|
||||
animation: payment-verification-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
&__message {
|
||||
color: #111111;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes payment-verification-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getTestBed, TestBed } from '@angular/core/testing';
|
||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CheckoutPaymentQrComponent } from './checkout-payment-qr.component';
|
||||
|
||||
describe('CheckoutPaymentQrComponent', () => {
|
||||
beforeAll(() => {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||
} catch {
|
||||
// Test environment may already be initialized by another setup entrypoint.
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the animated payment verification overlay over the QR', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CheckoutPaymentQrComponent],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
|
||||
fixture.componentRef.setInput('isCheckingPayment', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const overlay = fixture.nativeElement.querySelector('.payment-verification') as HTMLElement;
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay.textContent).toContain('Verificando pago');
|
||||
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
|
||||
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
|
||||
import { QrPaymentStatus } from '../../checkout-page.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout-payment-qr',
|
||||
standalone: true,
|
||||
imports: [QRCodeComponent],
|
||||
imports: [QRCodeComponent, ButtonComponent],
|
||||
templateUrl: './checkout-payment-qr.component.html',
|
||||
styleUrl: './checkout-payment-qr.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CheckoutPaymentQrComponent {
|
||||
readonly qrData = input<string | null>(null);
|
||||
readonly paymentStatus = input<QrPaymentStatus>('idle');
|
||||
readonly isCheckingPayment = input<boolean>(false);
|
||||
readonly retryPolling = output<void>();
|
||||
}
|
||||
|
||||
@@ -85,10 +85,21 @@
|
||||
variant="primary"
|
||||
hostClass="w-100"
|
||||
buttonClass="w-100"
|
||||
[disabled]="validationStatus() === 'checking'"
|
||||
(click)="completePurchase.emit()"
|
||||
>
|
||||
Ya transferí
|
||||
{{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
|
||||
</app-button>
|
||||
|
||||
@if (validationStatus() === 'pending') {
|
||||
<p class="payment-validation payment-validation--pending" role="status">
|
||||
Todavía no recibimos la transferencia. Podés volver a verificar.
|
||||
</p>
|
||||
} @else if (validationStatus() === 'error') {
|
||||
<p class="payment-validation payment-validation--error" role="alert">
|
||||
No pudimos validar la transferencia. Intentá nuevamente.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -115,3 +115,17 @@
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-validation {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
|
||||
&--pending {
|
||||
color: #8a681d;
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { TransferAccount, TransferField, TransferValidationStatus } from '../../checkout-page.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout-payment-transfer',
|
||||
@@ -17,6 +17,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
|
||||
readonly transferAccount = input<TransferAccount | null>(null);
|
||||
readonly transferDni = input<string>('');
|
||||
readonly copiedTransferField = input<TransferField | null>(null);
|
||||
readonly validationStatus = input<TransferValidationStatus>('idle');
|
||||
|
||||
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
||||
readonly submitDni = output<string>();
|
||||
|
||||
Reference in New Issue
Block a user