feat(checkout): implement QR payment polling and validation logic
This commit is contained in:
@@ -19,11 +19,15 @@
|
|||||||
[transferDni]="transferDni()"
|
[transferDni]="transferDni()"
|
||||||
[isGeneratingIntent]="isPaymentLoading()"
|
[isGeneratingIntent]="isPaymentLoading()"
|
||||||
[qrData]="qrData()"
|
[qrData]="qrData()"
|
||||||
|
[qrPaymentStatus]="qrPaymentStatus()"
|
||||||
|
[isCheckingQrPayment]="isCheckingQrPayment()"
|
||||||
|
[transferValidationStatus]="transferValidationStatus()"
|
||||||
(paymentMethodChange)="selectPaymentMethod($event)"
|
(paymentMethodChange)="selectPaymentMethod($event)"
|
||||||
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
|
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
|
||||||
(cancelStep)="onCancel()"
|
(cancelStep)="onCancel()"
|
||||||
(generateTransferIntent)="generateTransferIntent($event)"
|
(generateTransferIntent)="generateTransferIntent($event)"
|
||||||
(complete)="onComplete()"
|
(complete)="onComplete()"
|
||||||
|
(retryQrPolling)="retryQrPolling()"
|
||||||
/>
|
/>
|
||||||
</app-step>
|
</app-step>
|
||||||
</app-stepper>
|
</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,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
@@ -28,8 +29,10 @@ import {
|
|||||||
CheckoutForm,
|
CheckoutForm,
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
PaymentMethodOption,
|
PaymentMethodOption,
|
||||||
|
QrPaymentStatus,
|
||||||
TransferAccount,
|
TransferAccount,
|
||||||
TransferField,
|
TransferField,
|
||||||
|
TransferValidationStatus,
|
||||||
} from './checkout-page.models';
|
} from './checkout-page.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -46,13 +49,21 @@ import {
|
|||||||
styleUrl: './checkout-page.component.scss',
|
styleUrl: './checkout-page.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class CheckoutPageComponent implements OnInit {
|
export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
private readonly checkoutService = inject(CheckoutService);
|
private readonly checkoutService = inject(CheckoutService);
|
||||||
private readonly authService = inject(AuthService);
|
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;
|
@ViewChild(StepperComponent) stepper!: StepperComponent;
|
||||||
|
|
||||||
@@ -96,6 +107,9 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
|
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
|
||||||
);
|
);
|
||||||
protected readonly qrData = signal<string | null>(null);
|
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() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -105,7 +119,7 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
|
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
const purchaseId = this.createdPurchaseId();
|
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
|
// Trigger payment intent generation when cart changes and we are on the payment step
|
||||||
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
||||||
}
|
}
|
||||||
@@ -133,6 +147,10 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
this.cartService.loadCart().subscribe();
|
this.cartService.loadCart().subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopQrPolling();
|
||||||
|
}
|
||||||
|
|
||||||
private mapCartItemToMock(item: CartItem): CartItemMock {
|
private mapCartItemToMock(item: CartItem): CartItemMock {
|
||||||
const fullName = item.product?.nombre ?? '';
|
const fullName = item.product?.nombre ?? '';
|
||||||
let product = fullName;
|
let product = fullName;
|
||||||
@@ -202,11 +220,24 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onCancel(): void {
|
protected onCancel(): void {
|
||||||
|
this.stopQrPolling();
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
|
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);
|
this.selectedPaymentMethod.set(method);
|
||||||
|
const requestId = ++this.paymentMethodRequestId;
|
||||||
|
|
||||||
|
if (method === 'qr') {
|
||||||
|
this.qrData.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
const tenant = this.tenantService.tenant();
|
const tenant = this.tenantService.tenant();
|
||||||
@@ -230,7 +261,12 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (method === 'qr' && response.qr_data?.qr_code) {
|
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.qrData.set(response.qr_data.qr_code);
|
||||||
|
this.startQrPolling();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate payment intent:', error);
|
console.error('Failed to generate payment intent:', error);
|
||||||
@@ -248,6 +284,7 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.transferDni.set(dni);
|
this.transferDni.set(dni);
|
||||||
|
this.transferValidationStatus.set('idle');
|
||||||
this.isGeneratingIntent.set(true);
|
this.isGeneratingIntent.set(true);
|
||||||
try {
|
try {
|
||||||
const response = await this.checkoutService.generatePaymentIntent(
|
const response = await this.checkoutService.generatePaymentIntent(
|
||||||
@@ -293,16 +330,140 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
const tenant = this.tenantService.tenant();
|
const tenant = this.tenantService.tenant();
|
||||||
|
|
||||||
if (!purchaseId || !tenant) {
|
if (
|
||||||
|
!purchaseId ||
|
||||||
|
!tenant ||
|
||||||
|
this.navigationStarted ||
|
||||||
|
this.transferValidationStatus() === 'checking'
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.transferValidationStatus.set('checking');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.checkoutService.completePurchase(tenant.codigo, purchaseId);
|
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
|
||||||
this.cartService.clearCart();
|
|
||||||
void this.router.navigate(['/checkout/status', 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) {
|
} 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 PaymentMethod = 'qr' | 'transfer' | 'telepagos';
|
||||||
export type TransferField = 'cvu' | 'alias';
|
export type TransferField = 'cvu' | 'alias';
|
||||||
|
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
|
||||||
|
export type TransferValidationStatus = 'idle' | 'checking' | 'pending' | 'error';
|
||||||
|
|
||||||
export interface PaymentMethodOption {
|
export interface PaymentMethodOption {
|
||||||
id: PaymentMethod;
|
id: PaymentMethod;
|
||||||
|
|||||||
@@ -40,12 +40,18 @@
|
|||||||
<h3 class="payment-panel__title">Cargando información de pago...</h3>
|
<h3 class="payment-panel__title">Cargando información de pago...</h3>
|
||||||
</div>
|
</div>
|
||||||
} @else if (selectedPaymentMethod() === 'qr') {
|
} @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') {
|
} @else if (selectedPaymentMethod() === 'transfer') {
|
||||||
<app-checkout-payment-transfer
|
<app-checkout-payment-transfer
|
||||||
[transferAccount]="transferAccount()"
|
[transferAccount]="transferAccount()"
|
||||||
[transferDni]="transferDni()"
|
[transferDni]="transferDni()"
|
||||||
[copiedTransferField]="copiedTransferField()"
|
[copiedTransferField]="copiedTransferField()"
|
||||||
|
[validationStatus]="transferValidationStatus()"
|
||||||
(copyTransferValue)="requestCopy($event.field, $event.value)"
|
(copyTransferValue)="requestCopy($event.field, $event.value)"
|
||||||
(submitDni)="generateTransferIntent.emit($event)"
|
(submitDni)="generateTransferIntent.emit($event)"
|
||||||
(completePurchase)="complete.emit()"
|
(completePurchase)="complete.emit()"
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
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 { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
|
||||||
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
|
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
|
||||||
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.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({
|
@Component({
|
||||||
selector: 'app-checkout-payment-step',
|
selector: 'app-checkout-payment-step',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ButtonComponent, CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
|
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
|
||||||
templateUrl: './checkout-payment-step.component.html',
|
templateUrl: './checkout-payment-step.component.html',
|
||||||
styleUrl: './checkout-payment-step.component.scss',
|
styleUrl: './checkout-payment-step.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -22,12 +28,16 @@ export class CheckoutPaymentStepComponent {
|
|||||||
readonly transferDni = input<string>('');
|
readonly transferDni = input<string>('');
|
||||||
readonly isGeneratingIntent = input<boolean>(false);
|
readonly isGeneratingIntent = input<boolean>(false);
|
||||||
readonly qrData = input<string | null>(null);
|
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 paymentMethodChange = output<PaymentMethod>();
|
||||||
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
||||||
readonly cancelStep = output<void>();
|
readonly cancelStep = output<void>();
|
||||||
readonly complete = output<void>();
|
readonly complete = output<void>();
|
||||||
readonly generateTransferIntent = output<string>();
|
readonly generateTransferIntent = output<string>();
|
||||||
|
readonly retryQrPolling = output<void>();
|
||||||
|
|
||||||
protected selectPaymentMethod(method: PaymentMethod): void {
|
protected selectPaymentMethod(method: PaymentMethod): void {
|
||||||
this.paymentMethodChange.emit(method);
|
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--tr"></span>
|
||||||
<span class="qr-code__finder qr-code__finder--bl"></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>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -103,3 +103,48 @@
|
|||||||
left: 10px;
|
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 { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
|
||||||
|
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
|
||||||
|
import { QrPaymentStatus } from '../../checkout-page.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-checkout-payment-qr',
|
selector: 'app-checkout-payment-qr',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [QRCodeComponent],
|
imports: [QRCodeComponent, ButtonComponent],
|
||||||
templateUrl: './checkout-payment-qr.component.html',
|
templateUrl: './checkout-payment-qr.component.html',
|
||||||
styleUrl: './checkout-payment-qr.component.scss',
|
styleUrl: './checkout-payment-qr.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CheckoutPaymentQrComponent {
|
export class CheckoutPaymentQrComponent {
|
||||||
readonly qrData = input<string | null>(null);
|
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"
|
variant="primary"
|
||||||
hostClass="w-100"
|
hostClass="w-100"
|
||||||
buttonClass="w-100"
|
buttonClass="w-100"
|
||||||
|
[disabled]="validationStatus() === 'checking'"
|
||||||
(click)="completePurchase.emit()"
|
(click)="completePurchase.emit()"
|
||||||
>
|
>
|
||||||
Ya transferí
|
{{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
|
||||||
</app-button>
|
</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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,3 +115,17 @@
|
|||||||
height: 100%;
|
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 { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
||||||
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
|
||||||
import { InputComponent } from '../../../../../../shared/components/input/input.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({
|
@Component({
|
||||||
selector: 'app-checkout-payment-transfer',
|
selector: 'app-checkout-payment-transfer',
|
||||||
@@ -17,6 +17,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
|
|||||||
readonly transferAccount = input<TransferAccount | null>(null);
|
readonly transferAccount = input<TransferAccount | null>(null);
|
||||||
readonly transferDni = input<string>('');
|
readonly transferDni = input<string>('');
|
||||||
readonly copiedTransferField = input<TransferField | null>(null);
|
readonly copiedTransferField = input<TransferField | null>(null);
|
||||||
|
readonly validationStatus = input<TransferValidationStatus>('idle');
|
||||||
|
|
||||||
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
||||||
readonly submitDni = output<string>();
|
readonly submitDni = output<string>();
|
||||||
|
|||||||
Reference in New Issue
Block a user