From d61b1e4b0a3c83d7ec837ca1862350e0db3a3534 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 08:52:42 -0300 Subject: [PATCH] feat(checkout): synchronize purchase countdown --- .../checkout-countdown.service.spec.ts | 57 ++++++++++++ .../services/checkout-countdown.service.ts | 87 +++++++++++++++++++ .../checkout-page/checkout-page.component.ts | 13 +++ 3 files changed, 157 insertions(+) create mode 100644 src/app/core/services/checkout-countdown.service.spec.ts create mode 100644 src/app/core/services/checkout-countdown.service.ts diff --git a/src/app/core/services/checkout-countdown.service.spec.ts b/src/app/core/services/checkout-countdown.service.spec.ts new file mode 100644 index 0000000..c104e28 --- /dev/null +++ b/src/app/core/services/checkout-countdown.service.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CheckoutCountdownService } from './checkout-countdown.service'; + +describe('CheckoutCountdownService', () => { + let service: CheckoutCountdownService; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-27T12:00:00.000Z')); + TestBed.configureTestingModule({ providers: [CheckoutCountdownService] }); + service = TestBed.inject(CheckoutCountdownService); + }); + + afterEach(() => { + service.clear(); + vi.useRealTimers(); + }); + + it('counts down from the checkout server timing without depending on the client clock', () => { + service.synchronize({ + expires_at: '2026-08-27T15:10:00.000Z', + expires_in_seconds: 600, + server_time: '2026-08-27T15:00:00.000Z', + }); + + expect(service.remainingSeconds()).toBe(600); + + vi.advanceTimersByTime(1_000); + + expect(service.remainingSeconds()).toBe(599); + }); + + it('recalculates against the deadline after a delayed browser interval', () => { + service.synchronize({ + expires_at: null, + expires_in_seconds: 10, + server_time: '2026-08-27T15:00:00.000Z', + }); + + vi.setSystemTime(new Date('2026-08-27T12:00:07.000Z')); + vi.advanceTimersByTime(1_000); + + expect(service.remainingSeconds()).toBe(2); + }); + + it('clears the countdown when checkout has no expiration', () => { + service.synchronize({ + expires_at: null, + expires_in_seconds: null, + server_time: '2026-08-27T15:00:00.000Z', + }); + + expect(service.remainingSeconds()).toBeNull(); + }); +}); diff --git a/src/app/core/services/checkout-countdown.service.ts b/src/app/core/services/checkout-countdown.service.ts new file mode 100644 index 0000000..6038230 --- /dev/null +++ b/src/app/core/services/checkout-countdown.service.ts @@ -0,0 +1,87 @@ +import { Injectable, OnDestroy, signal } from '@angular/core'; + +export interface CheckoutTiming { + expires_at: string | null; + expires_in_seconds: number | null; + server_time: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class CheckoutCountdownService implements OnDestroy { + private readonly remainingSecondsState = signal(null); + private deadlineMs: number | null = null; + private intervalId: ReturnType | null = null; + + readonly remainingSeconds = this.remainingSecondsState.asReadonly(); + + synchronize(timing: CheckoutTiming): void { + const remainingSeconds = this.resolveRemainingSeconds(timing); + + if (remainingSeconds === null) { + this.clear(); + return; + } + + this.stopInterval(); + this.deadlineMs = Date.now() + remainingSeconds * 1_000; + this.updateRemainingSeconds(); + + if (remainingSeconds > 0) { + this.intervalId = setInterval(() => this.updateRemainingSeconds(), 1_000); + } + } + + clear(): void { + this.stopInterval(); + this.deadlineMs = null; + this.remainingSecondsState.set(null); + } + + ngOnDestroy(): void { + this.clear(); + } + + private resolveRemainingSeconds(timing: CheckoutTiming): number | null { + const expiresAt = timing.expires_at ? Date.parse(timing.expires_at) : Number.NaN; + const serverTime = Date.parse(timing.server_time); + + if (Number.isFinite(expiresAt) && Number.isFinite(serverTime)) { + return Math.max(0, Math.ceil((expiresAt - serverTime) / 1_000)); + } + + if ( + typeof timing.expires_in_seconds === 'number' && + Number.isFinite(timing.expires_in_seconds) + ) { + return Math.max(0, Math.ceil(timing.expires_in_seconds)); + } + + if (Number.isFinite(expiresAt)) { + return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1_000)); + } + + return null; + } + + private updateRemainingSeconds(): void { + if (this.deadlineMs === null) { + return; + } + + const remainingSeconds = Math.max(0, Math.ceil((this.deadlineMs - Date.now()) / 1_000)); + this.remainingSecondsState.set(remainingSeconds); + + if (remainingSeconds === 0) { + this.stopInterval(); + } + } + + private stopInterval(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } +} 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 d15bf71..c9d0913 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 @@ -28,6 +28,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { CheckoutDataStepComponent } from './checkout-data-step.component'; import { CheckoutPaymentStepComponent } from './checkout-payment-step.component'; +import { CheckoutCountdownService } from '../../../../core/services/checkout-countdown.service'; import { CheckoutForm, PaymentMethod, @@ -64,6 +65,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly tenantService = inject(TenantService); private readonly checkoutService = inject(CheckoutService); + private readonly checkoutCountdownService = inject(CheckoutCountdownService); private readonly authService = inject(AuthService); private readonly globalLoadingService = inject(GlobalLoadingService); private readonly toastService = inject(ToastService); @@ -161,6 +163,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.checkoutCountdownService.clear(); + const purchaseId = Number(this.route.snapshot.paramMap.get('id')); if (!Number.isInteger(purchaseId) || purchaseId <= 0) { void this.router.navigate(['/']); @@ -173,6 +177,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.stopQrPolling(); this.stopTransferPolling(); + this.checkoutCountdownService.clear(); } private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock { @@ -228,6 +233,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { nombre_apellido: formValue.nombre, }); this.createdPurchase.set(purchase); + this.checkoutCountdownService.synchronize(purchase); this.stepper.next(); @@ -451,6 +457,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return; } + this.checkoutCountdownService.synchronize(purchase); + if (purchase.status === 'paid') { this.navigateToPurchaseStatus(purchaseId); return; @@ -500,6 +508,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return; } + this.checkoutCountdownService.synchronize(purchase); + if (purchase.status === 'paid') { this.navigateToPurchaseStatus(purchaseId); return; @@ -586,6 +596,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return; } + this.checkoutCountdownService.synchronize(purchase); + if (purchase.status === 'paid') { this.handleConfirmedPayment(purchaseId); return; @@ -696,6 +708,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.createdPurchaseId.set(purchase.id); this.createdPurchase.set(purchase); + this.checkoutCountdownService.synchronize(purchase); this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0); if (