From 38a57adf55c4cba72309ecdfd0514089489c5838 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 20 Aug 2026 11:09:52 -0300 Subject: [PATCH] feat(checkout): implement transfer payment polling with cancellation --- .../checkout-page.component.spec.ts | 46 ++++++++++--- .../checkout-page/checkout-page.component.ts | 67 ++++++++++++++++++- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts index 5743880..dcf604d 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts @@ -213,19 +213,27 @@ describe('CheckoutPageComponent payment validation', () => { expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); }); - it('checks the purchase detail once when the transfer was made', async () => { + it('polls a transfer every three seconds up to four attempts', async () => { checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' }); const { component } = createComponent(); component.selectedPaymentMethod.set('transfer'); - await component.onComplete(); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25); + component.onComplete(); + expect(component.transferValidationStatus()).toBe('checking'); + expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); + + for (let attempt = 1; attempt <= 3; attempt += 1) { + await vi.advanceTimersByTimeAsync(3_000); + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(attempt); + expect(component.transferValidationStatus()).toBe('checking'); + } + + await vi.advanceTimersByTimeAsync(3_000); + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); + expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25); expect(component.transferValidationStatus()).toBe('error'); expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(30_000); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); }); it('navigates after a transfer is confirmed as paid', async () => { @@ -233,26 +241,46 @@ describe('CheckoutPageComponent payment validation', () => { const { component } = createComponent(); component.selectedPaymentMethod.set('transfer'); - await component.onComplete(); + component.onComplete(); + await vi.advanceTimersByTimeAsync(3_000); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25); expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); }); - it('shows a retryable state when transfer validation fails', async () => { + it('keeps polling after transfer validation requests fail', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error')); const { component } = createComponent(); component.selectedPaymentMethod.set('transfer'); - await component.onComplete(); + component.onComplete(); + await vi.advanceTimersByTimeAsync(12_000); + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); expect(component.transferValidationStatus()).toBe('error'); expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled(); }); + it('cancels transfer polling when the payment method changes or the component is destroyed', async () => { + const first = createComponent(); + first.component.selectedPaymentMethod.set('transfer'); + first.component.onComplete(); + checkoutServiceStub.generatePaymentIntent.mockResolvedValueOnce({}); + await first.component.selectPaymentMethod('qr'); + await vi.advanceTimersByTimeAsync(12_000); + expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); + + const second = createComponent(); + second.component.selectedPaymentMethod.set('transfer'); + second.component.onComplete(); + second.fixture.destroy(); + await vi.advanceTimersByTimeAsync(12_000); + expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); + }); + it('loads purchase items but prefills customer data from the user', async () => { const purchase = { id: 25, diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index 01336b6..26bf78f 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -60,10 +60,15 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { private readonly cartService = inject(CartService); private readonly qrPollingIntervalMs = 5_000; private readonly qrPollingMaxAttempts = 9; + private readonly transferPollingIntervalMs = 3_000; + private readonly transferPollingMaxAttempts = 4; private qrPollingTimeoutId: ReturnType | null = null; private qrPollingAttempts = 0; private qrPollingRunId = 0; + private transferPollingTimeoutId: ReturnType | null = null; + private transferPollingAttempts = 0; + private transferPollingRunId = 0; private paymentMethodRequestId = 0; private navigationStarted = false; @@ -167,6 +172,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.stopQrPolling(); + this.stopTransferPolling(); } private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock { @@ -211,6 +217,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.isEditingItems.set(true); this.stopQrPolling(); + this.stopTransferPolling(); this.qrData.set(null); this.qrPaymentStatus.set('idle'); this.transferAccount.set(null); @@ -385,6 +392,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { public async canDeactivate(): Promise { this.stopQrPolling(); + this.stopTransferPolling(); if (this.navigationStarted) { return true; @@ -421,6 +429,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } this.stopQrPolling(); + this.stopTransferPolling(); this.qrPaymentStatus.set('idle'); this.transferValidationStatus.set('idle'); this.selectedPaymentMethod.set(method); @@ -478,6 +487,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } this.transferDni.set(dni); + this.stopTransferPolling(); this.transferValidationStatus.set('idle'); this.isGeneratingIntent.set(true); try { @@ -518,7 +528,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } } - protected async onComplete(): Promise { + protected onComplete(): void { const purchaseId = this.createdPurchaseId(); const tenant = this.tenantService.tenant(); @@ -532,22 +542,72 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return; } + this.stopTransferPolling(); this.transferValidationStatus.set('checking'); + this.transferPollingAttempts = 0; + + const runId = this.transferPollingRunId; + this.scheduleTransferPoll(runId); + } + + private scheduleTransferPoll(runId: number): void { + this.transferPollingTimeoutId = setTimeout(() => { + this.transferPollingTimeoutId = null; + void this.checkTransferPayment(runId); + }, this.transferPollingIntervalMs); + } + + private async checkTransferPayment(runId: number): Promise { + if (runId !== this.transferPollingRunId) { + return; + } + + const purchaseId = this.createdPurchaseId(); + const tenant = this.tenantService.tenant(); + + if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'transfer') { + this.stopTransferPolling(); + return; + } + + this.transferPollingAttempts += 1; try { const purchase = await this.checkoutService .withCustomLoading() .getPurchase(tenant.codigo, purchaseId); + if (runId !== this.transferPollingRunId) { + return; + } + if (purchase.status === 'paid') { this.navigateToPurchaseStatus(purchaseId); return; } - - this.transferValidationStatus.set('error'); } catch (error) { console.error('Failed to validate transfer payment:', error); + } + + if (runId !== this.transferPollingRunId) { + return; + } + + if (this.transferPollingAttempts >= this.transferPollingMaxAttempts) { + this.stopTransferPolling(); this.transferValidationStatus.set('error'); + return; + } + + this.scheduleTransferPoll(runId); + } + + private stopTransferPolling(): void { + this.transferPollingRunId += 1; + + if (this.transferPollingTimeoutId !== null) { + clearTimeout(this.transferPollingTimeoutId); + this.transferPollingTimeoutId = null; } } @@ -661,6 +721,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.navigationStarted = true; this.stopQrPolling(); + this.stopTransferPolling(); void this.router.navigate(['/checkout/status', purchaseId]); }