From 158b28ec1ac1f262eb68e5dbb446a67987816550 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 3 Sep 2026 08:36:41 -0300 Subject: [PATCH] feat(checkout-page): implement countdown expiration check and purchase validation --- .../checkout-page.component.spec.ts | 58 +++++++++++++++++++ .../checkout-page/checkout-page.component.ts | 53 +++++++++++++++++ 2 files changed, 111 insertions(+) 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 ace59a0..59a7409 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 @@ -159,6 +159,64 @@ describe('CheckoutPageComponent payment validation', () => { expect(component.checkoutRemainingTime()).toBe('10:00'); }); + it('checks the purchase when the countdown expires and redirects to the expired status', async () => { + const countdown = TestBed.inject(CheckoutCountdownService); + checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'expired' }); + const { fixture, component } = createComponent(); + component.createdPurchase.set({ + id: 25, + status: 'created', + expires_at: new Date(Date.now() + 1_000).toISOString(), + expires_in_seconds: 1, + server_time: new Date().toISOString(), + items: [], + subtotal: '0.00', + total: '0.00', + }); + countdown.synchronize(component.createdPurchase()); + fixture.detectChanges(); + + await vi.advanceTimersByTimeAsync(1_000); + fixture.detectChanges(); + await Promise.resolve(); + + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledOnce(); + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25); + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], { + queryParams: { status: 'expired' }, + }); + }); + + it('checks the purchase only once when the expired countdown remains at zero', async () => { + const countdown = TestBed.inject(CheckoutCountdownService); + checkoutServiceStub.getPurchase.mockResolvedValue({ + status: 'pending_payment', + expires_at: new Date(Date.now() - 1_000).toISOString(), + expires_in_seconds: 0, + server_time: new Date().toISOString(), + }); + const { fixture, component } = createComponent(); + component.createdPurchase.set({ + id: 25, + status: 'pending_payment', + expires_at: new Date(Date.now() - 1_000).toISOString(), + expires_in_seconds: 0, + server_time: new Date().toISOString(), + items: [], + subtotal: '0.00', + total: '0.00', + }); + countdown.synchronize(component.createdPurchase()); + fixture.detectChanges(); + + await Promise.resolve(); + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledOnce(); + expect(routerStub.navigate).not.toHaveBeenCalled(); + }); + it('polls QR after five seconds and navigates only when payment is paid', async () => { checkoutServiceStub.getPurchase .mockResolvedValueOnce({ status: 'pending_payment' }) 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 bd8e320..25201a7 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 @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, OnDestroy, OnInit, @@ -86,6 +87,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { private paymentMethodRequestId = 0; private navigationStarted = false; private cancelPurchasePromise: Promise | null = null; + private expirationCheckPurchaseId: number | null = null; @ViewChild(StepperComponent) stepper!: StepperComponent; @@ -176,6 +178,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ); constructor() { + effect(() => { + const remainingSeconds = this.checkoutCountdownService.remainingSeconds(); + const purchaseId = this.createdPurchaseId() ?? this.createdPurchase()?.id ?? null; + + if (remainingSeconds !== null && remainingSeconds > 0) { + this.expirationCheckPurchaseId = null; + return; + } + + if ( + remainingSeconds !== 0 || + purchaseId === null || + this.navigationStarted || + this.expirationCheckPurchaseId === purchaseId + ) { + return; + } + + this.expirationCheckPurchaseId = purchaseId; + void this.checkPurchaseAfterCountdownExpiration(purchaseId); + }); + this.form.statusChanges .pipe(startWith(this.form.status)) .subscribe(() => this.isStep1Valid.set(this.form.valid)); @@ -719,6 +743,35 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { void this.router.navigate(['/checkout/status', purchaseId]); } + private async checkPurchaseAfterCountdownExpiration(purchaseId: number): Promise { + const tenant = this.tenantService.tenant(); + + if (!tenant || this.navigationStarted) { + return; + } + + try { + const purchase = await this.checkoutService + .withCustomLoading() + .getPurchase(tenant.codigo, purchaseId); + + if (this.navigationStarted) { + return; + } + + if (purchase.status === 'expired') { + this.navigateToExpiredPurchaseStatus(); + return; + } + + this.checkoutCountdownService.synchronize(purchase); + } catch (error) { + if (this.isPurchaseExpiredError(error)) { + this.navigateToExpiredPurchaseStatus(); + } + } + } + private async loadPurchase(purchaseId: number): Promise { const tenant = this.tenantService.tenant(); if (!tenant) {