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 11218aa..c874f2e 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 @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { signal } from '@angular/core'; import { getTestBed, TestBed } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; @@ -10,6 +11,7 @@ import { CartService } from '../../../../core/services/cart/cart.service'; import { CheckoutService } from '../../../../core/services/checkout.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { TenantService } from '../../../../core/services/tenant.service'; +import { ToastService } from '../../../../core/services/toast.service'; import { CartEditingPolicy } from '../../../../core/services/tenant.interface'; import { CheckoutPageComponent } from './checkout-page.component'; @@ -34,6 +36,7 @@ describe('CheckoutPageComponent payment validation', () => { clearCart: ReturnType; }; let routerStub: { navigate: ReturnType }; + let toastServiceStub: { danger: ReturnType }; let routeQueryParamMap: ReturnType; let authUserState: ReturnType; let tenantState: ReturnType< @@ -97,6 +100,7 @@ describe('CheckoutPageComponent payment validation', () => { }); }); routerStub = { navigate: vi.fn() }; + toastServiceStub = { danger: vi.fn() }; routeQueryParamMap = convertToParamMap({}); authUserState = signal(null); tenantState = signal({ @@ -118,6 +122,7 @@ describe('CheckoutPageComponent payment validation', () => { { provide: CartService, useValue: cartServiceStub }, { provide: TenantService, useValue: { tenant: tenantState } }, { provide: AuthService, useValue: { user: authUserState } }, + { provide: ToastService, useValue: toastServiceStub }, { provide: ActivatedRoute, useValue: { @@ -635,4 +640,55 @@ describe('CheckoutPageComponent payment validation', () => { expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(component.createdPurchaseId()).toBe(25); }); + + it('shows the API message and redirects home when a checkout operation finds an expired purchase', async () => { + checkoutServiceStub.generatePaymentIntent.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'); + + expect(toastServiceStub.danger).toHaveBeenCalledWith( + 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', + ); + expect(routerStub.navigate).toHaveBeenCalledWith(['/']); + expect(component.isGeneratingIntent()).toBe(false); + }); + + it('treats a generic customer-data error as expired when the local deadline passed', () => { + const { component } = createComponent(); + component.createdPurchase.set({ + id: 25, + status: 'created', + expires_at: new Date(Date.now() - 1_000).toISOString(), + items: [], + subtotal: '100.00', + total: '100.00', + }); + + const handled = component.handleCheckoutError( + new HttpErrorResponse({ + status: 422, + error: { + errors: { + purchase: ['La compra ya no se puede modificar.'], + }, + }, + }), + 'No se pudieron actualizar los datos de la compra.', + ); + + expect(handled).toBe(true); + expect(toastServiceStub.danger).toHaveBeenCalledWith( + 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', + ); + expect(routerStub.navigate).toHaveBeenCalledWith(['/']); + }); }); 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 6e67b73..5524da0 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 @@ -8,6 +8,7 @@ import { signal, ViewChild, } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; import { FormBuilder, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { firstValueFrom, startWith } from 'rxjs'; @@ -20,6 +21,7 @@ import { } from '../../../../core/services/checkout.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; import { CartService } from '../../../../core/services/cart/cart.service'; +import { ToastService } from '../../../../core/services/toast.service'; import { BankAccount } from '../../../../core/services/tenant.interface'; import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component'; @@ -36,6 +38,12 @@ import { TransferValidationStatus, } from './checkout-page.models'; +interface ApiErrorResponse { + code?: string; + message?: string; + errors?: Record; +} + @Component({ selector: 'app-checkout-page', standalone: true, @@ -59,6 +67,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { private readonly authService = inject(AuthService); private readonly cartService = inject(CartService); private readonly qrPollingIntervalMs = 5_000; + private readonly toastService = inject(ToastService); private readonly qrPollingMaxAttempts = 9; private readonly transferPollingIntervalMs = 3_000; private readonly transferPollingMaxAttempts = 4; @@ -199,9 +208,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } if (editing && !this.canModifyCart()) { - await this.router.navigate(['/'], { - queryParams: { openCart: true }, - }); + const tenant = this.tenantService.tenant(); + const purchaseId = this.createdPurchaseId(); + + if (!tenant || !purchaseId) { + return; + } + + this.isPreparingItemEdit.set(true); + try { + await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId); + this.createdPurchaseId.set(null); + this.createdPurchase.set(null); + await firstValueFrom(this.cartService.loadCart()); + await this.router.navigate(['/'], { + queryParams: { openCart: true }, + }); + } catch (error) { + this.handleCheckoutError(error, 'No se pudo restaurar el carrito para editarlo.'); + } finally { + this.isPreparingItemEdit.set(false); + } + return; } @@ -236,7 +264,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId); this.createdPurchase.set(purchase); } catch (error) { - console.error('Failed to prepare purchase item editing:', error); + this.handleCheckoutError(error, 'No se pudo preparar la compra para editarla.'); this.isEditingItems.set(false); } finally { this.isPreparingItemEdit.set(false); @@ -274,7 +302,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ); this.createdPurchase.set(purchase); } catch (error) { - console.error('Failed to update purchase item quantity:', error); + this.handleCheckoutError(error, 'No se pudo actualizar la cantidad del producto.'); } finally { this.isUpdatingItem.set(false); } @@ -313,7 +341,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ), ); } catch (error) { - console.error('Failed to update purchase item variant:', error); + this.handleCheckoutError(error, 'No se pudo actualizar la variante del producto.'); } finally { this.isUpdatingItem.set(false); } @@ -347,7 +375,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { ), ); } catch (error) { - console.error('Failed to remove purchase item:', error); + this.handleCheckoutError(error, 'No se pudo eliminar el producto de la compra.'); } finally { this.isUpdatingItem.set(false); } @@ -377,8 +405,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { // Auto trigger intent for default option void this.selectPaymentMethod(this.selectedPaymentMethod()); } catch (error) { - console.error('Failed to create purchase:', error); - // Here we could show an alert or toast + this.handleCheckoutError(error, 'No se pudieron actualizar los datos de la compra.'); } finally { this.isUpdatingPurchase.set(false); } @@ -454,7 +481,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.startQrPolling(); } } catch (error) { - console.error('Failed to generate payment intent:', error); + this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de pago.'); } finally { this.isGeneratingIntent.set(false); } @@ -491,7 +518,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { }); } } catch (error) { - console.error('Failed to generate transfer payment intent:', error); + this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de transferencia.'); } finally { this.isGeneratingIntent.set(false); } @@ -550,9 +577,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.scheduleTransferPoll(runId); } catch (error) { - console.error('Failed to submit transfer payment for review:', error); + const expired = this.handleCheckoutError( + error, + 'No se pudo enviar la compra a revisi\u00f3n.', + ); - if (runId === this.transferPollingRunId) { + if (!expired && runId === this.transferPollingRunId) { this.transferValidationStatus.set('error'); } } @@ -784,4 +814,54 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { void this.router.navigate(['/']); } } + + private handleCheckoutError(error: unknown, fallbackMessage: string): boolean { + if (!(error instanceof HttpErrorResponse)) { + this.toastService.danger(fallbackMessage); + + return false; + } + + const response = error.error as ApiErrorResponse | null; + + if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) { + this.navigationStarted = true; + this.stopQrPolling(); + this.stopTransferPolling(); + this.toastService.danger( + response?.code === 'purchase.expired' && response.message + ? response.message + : 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', + ); + void this.router.navigate(['/']); + + return true; + } + + const validationMessage = response?.errors + ? Object.values(response.errors).flat().find(Boolean) + : undefined; + + this.toastService.danger(validationMessage ?? response?.message ?? fallbackMessage); + + return false; + } + + private hasExpiredPurchase(): boolean { + const purchase = this.createdPurchase(); + + if (!purchase) { + return false; + } + + if (purchase.status === 'expired') { + return true; + } + + if (!purchase.expires_at) { + return false; + } + + return Date.parse(purchase.expires_at) <= Date.now(); + } }