From 643d43adea752f639bac9b85a7c0713d717c6b1a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 17:05:59 -0300 Subject: [PATCH] fix(checkout): handle expired purchase exits --- .../checkout-page.component.spec.ts | 56 ++++++++++-- .../checkout-page/checkout-page.component.ts | 85 +++++++++++++++++-- .../purchase-status-page.component.spec.ts | 19 ++++- .../purchase-status-page.component.ts | 19 ++++- 4 files changed, 166 insertions(+), 13 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 77b29a7..e7e34a2 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 @@ -460,7 +460,7 @@ describe('CheckoutPageComponent payment validation', () => { expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); }); - it('shows the API error in a toast when cancelling the purchase fails', async () => { + it('shows the API error and redirects to status when cancellation finds an expired purchase', async () => { const message = 'La compra venció. Iniciá una nueva compra.'; checkoutServiceStub.cancelPurchase.mockRejectedValue({ error: { code: 'purchase.expired', message }, @@ -470,7 +470,9 @@ describe('CheckoutPageComponent payment validation', () => { await component.onModifyPurchase(); expect(toastServiceStub.danger).toHaveBeenCalledWith(message); - expect(routerStub.navigate).not.toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], { + queryParams: { status: 'expired' }, + }); expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce(); expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce(); expect(component.isCancellingPurchase()).toBe(false); @@ -587,7 +589,24 @@ describe('CheckoutPageComponent payment validation', () => { expect(component.createdPurchaseId()).toBe(25); }); - it('shows the API message and redirects home when a checkout operation finds an expired purchase', async () => { + it('allows leaving checkout when cancellation finds an expired stock reservation', async () => { + const message = 'La reserva de stock venció. Usá el carrito activo para continuar.'; + checkoutServiceStub.cancelPurchase.mockRejectedValue({ + error: { code: 'stock_reservation.expired', message }, + }); + const { component } = createComponent(); + + await expect(component.canDeactivate()).resolves.toBe(true); + + expect(toastServiceStub.danger).toHaveBeenCalledWith(message); + expect(cartServiceStub.loadCart).toHaveBeenCalledOnce(); + expect(component.createdPurchaseId()).toBeNull(); + expect(component.createdPurchase()).toBeNull(); + expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce(); + expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce(); + }); + + it('shows the API message and redirects to status when a checkout operation finds an expired purchase', async () => { checkoutServiceStub.generatePaymentIntent.mockRejectedValue( new HttpErrorResponse({ status: 422, @@ -604,10 +623,35 @@ describe('CheckoutPageComponent payment validation', () => { expect(toastServiceStub.danger).toHaveBeenCalledWith( 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', ); - expect(routerStub.navigate).toHaveBeenCalledWith(['/']); + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], { + queryParams: { status: 'expired' }, + }); expect(component.isGeneratingIntent()).toBe(false); }); + it('redirects to status when QR polling receives a purchase-expired response', async () => { + checkoutServiceStub.getPurchase.mockRejectedValue( + new HttpErrorResponse({ + status: 422, + error: { + code: 'purchase.expired', + message: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', + }, + }), + ); + const { component } = createComponent(); + + await component.selectPaymentMethod('qr'); + await vi.advanceTimersByTimeAsync(5_000); + + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], { + queryParams: { status: 'expired' }, + }); + expect(toastServiceStub.danger).toHaveBeenCalledWith( + 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', + ); + }); + it('treats a generic customer-data error as expired when the local deadline passed', () => { const { component } = createComponent(); component.createdPurchase.set({ @@ -635,6 +679,8 @@ describe('CheckoutPageComponent payment validation', () => { expect(toastServiceStub.danger).toHaveBeenCalledWith( 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', ); - expect(routerStub.navigate).toHaveBeenCalledWith(['/']); + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], { + queryParams: { status: 'expired' }, + }); }); }); 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 e4205dd..faca4cf 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 @@ -296,6 +296,18 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return true; } catch (error) { console.error('Failed to cancel the current purchase:', error); + + if (this.isStockReservationExpiredError(error)) { + this.showRequestError(error, 'La reserva de stock venció.'); + this.cartService.loadCart().subscribe({ + error: (refreshError) => console.error('Error refreshing expired cart', refreshError), + }); + this.createdPurchaseId.set(null); + this.createdPurchase.set(null); + this.navigationStarted = true; + return true; + } + this.showRequestError(error, 'No se pudo cancelar la compra.'); return false; } finally { @@ -492,8 +504,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.navigateToPurchaseStatus(purchaseId); return; } + + if (purchase.status === 'expired') { + this.navigateToPurchaseStatus(purchaseId); + return; + } } catch (error) { console.error('Failed to validate transfer payment:', error); + if (this.isPurchaseExpiredError(error)) { + this.showRequestError(error, 'La compra venció. Iniciá una nueva compra.'); + return; + } } if (runId !== this.transferPollingRunId) { @@ -575,8 +596,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.qrPaymentStatus.set('failed'); return; } + + if (purchase.status === 'expired') { + this.navigateToPurchaseStatus(purchaseId); + return; + } } catch (error) { console.error('Failed to validate QR payment:', error); + if (this.isPurchaseExpiredError(error)) { + this.showRequestError(error, 'La compra venció. Iniciá una nueva compra.'); + return; + } } finally { if (runId === this.qrPollingRunId) { this.isCheckingQrPayment.set(false); @@ -683,15 +713,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } } catch (error) { console.error('Failed to load purchase:', error); - this.showRequestError(error, 'No se pudo cargar la compra.'); - void this.router.navigate(['/']); + const expired = this.showRequestError(error, 'No se pudo cargar la compra.'); + if (!expired) { + void this.router.navigate(['/']); + } } } - private showRequestError(error: unknown, fallbackMessage: string): void { + private showRequestError(error: unknown, fallbackMessage: string): boolean { const payload = typeof error === 'object' && error !== null && 'error' in error - ? (error as { error?: { message?: unknown } }).error + ? (error as { error?: ApiErrorResponse }).error : undefined; const message = typeof payload?.message === 'string' && payload.message.trim() @@ -699,6 +731,13 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { : fallbackMessage; this.toastService.danger(message); + + if (payload?.code === 'purchase.expired') { + this.navigateToExpiredPurchaseStatus(); + return true; + } + + return false; } private handleCheckoutError(error: unknown, fallbackMessage: string): boolean { @@ -717,7 +756,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ? response.message : 'La compra venció. Iniciá una nueva compra.', ); - void this.router.navigate(['/']); + this.navigateToExpiredPurchaseStatus(); return true; } @@ -729,6 +768,42 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return false; } + private navigateToExpiredPurchaseStatus(): void { + const purchaseId = this.createdPurchaseId() ?? this.createdPurchase()?.id; + + if (purchaseId) { + if (this.navigationStarted) { + return; + } + + this.navigationStarted = true; + this.stopQrPolling(); + this.stopTransferPolling(); + void this.router.navigate(['/checkout/status', purchaseId], { + queryParams: { status: 'expired' }, + }); + return; + } + + void this.router.navigate(['/']); + } + + private isPurchaseExpiredError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('error' in error)) { + return false; + } + + return (error as { error?: ApiErrorResponse }).error?.code === 'purchase.expired'; + } + + private isStockReservationExpiredError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('error' in error)) { + return false; + } + + return (error as { error?: ApiErrorResponse }).error?.code === 'stock_reservation.expired'; + } + private hasExpiredPurchase(): boolean { const purchase = this.createdPurchase(); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts index a41a839..7cd36b6 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts @@ -66,7 +66,7 @@ describe('PurchaseStatusPageComponent', () => { TestBed.resetTestingModule(); }); - async function render(hasGeneratedTickets: boolean) { + async function render(hasGeneratedTickets: boolean, forcedStatus?: string) { const checkoutService = { getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)), withCustomLoading() { @@ -85,7 +85,14 @@ describe('PurchaseStatusPageComponent', () => { { provide: TenantService, useValue: { tenant: () => tenant } }, { provide: ActivatedRoute, - useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } }, + useValue: { + snapshot: { + paramMap: convertToParamMap({ id: '42' }), + queryParamMap: convertToParamMap( + forcedStatus ? { status: forcedStatus } : {}, + ), + }, + }, }, { provide: Router, useValue: router }, ], @@ -134,6 +141,14 @@ describe('PurchaseStatusPageComponent', () => { openSpy.mockRestore(); }); + it('shows the expired result without polling when checkout redirects after expiration', async () => { + const { element, checkoutService } = await render(false, 'expired'); + + expect(element.textContent).toContain('LA COMPRA VENCIÓ'); + expect(element.textContent).not.toContain('ESTAMOS VERIFICANDO TU PAGO'); + expect(checkoutService.getPurchase).not.toHaveBeenCalled(); + }); + it('polls every five seconds while the payment is pending and shows the confirmation', async () => { vi.useFakeTimers(); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts index 2797027..216fd9e 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts @@ -72,6 +72,12 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy { this.purchaseId = purchaseId; this.tenantCode = tenant.codigo; + if (this.route.snapshot.queryParamMap?.get('status') === 'expired') { + this.status.set('expired'); + this.isLoading.set(false); + return; + } + void this.loadStatus(); } @@ -107,7 +113,10 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy { console.error('Failed to fetch purchase status:', error); if (!this.isDestroyed) { - if (isPolling) { + if (this.isPurchaseExpiredError(error)) { + this.status.set('expired'); + this.stopPolling(); + } else if (isPolling) { this.schedulePolling(); } else { this.status.set('error'); @@ -154,6 +163,14 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy { return 'pending'; } + private isPurchaseExpiredError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('error' in error)) { + return false; + } + + return (error as { error?: { code?: string } }).error?.code === 'purchase.expired'; + } + protected goToTickets(): void { const route = this.ticketsRoute();