From d89d01b511a2dc231d3bf52fdece4b49b1c8e354 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 16:47:17 -0300 Subject: [PATCH] fix(checkout): refresh expired carts --- .../store-layout/store-layout.component.spec.ts | 12 +++++++++--- .../layout/store-layout/store-layout.component.ts | 10 +++++++++- src/app/core/services/checkout.service.ts | 15 +++++++++++++++ .../category-items-page.component.ts | 10 +++++++++- .../pages/search-page/search-page.component.ts | 10 +++++++++- .../store-home-page/store-home-page.component.ts | 9 +++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index c7551ad..04fd5be 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -681,10 +681,13 @@ describe('StoreLayoutComponent', () => { expect((fixture.componentInstance as any).isCartOpen()).toBe(false); }); - it('shows the backend message when starting checkout fails', async () => { - const message = 'Alcanzaste el límite de compra para este producto.'; + it('shows the backend message and refreshes the cart when its reservation expired', async () => { + const message = 'La reserva de stock venció. Usá el carrito activo para continuar.'; checkoutServiceStub.startCheckout.mockRejectedValue( - new HttpErrorResponse({ status: 422, error: { message } }), + new HttpErrorResponse({ + status: 422, + error: { code: 'stock_reservation.expired', message }, + }), ); cartState.set({ id: 1, @@ -702,11 +705,14 @@ describe('StoreLayoutComponent', () => { }); const fixture = TestBed.createComponent(StoreLayoutComponent); + const loadCart = vi.spyOn(TestBed.inject(CartService), 'loadCart'); fixture.detectChanges(); + loadCart.mockClear(); await (fixture.componentInstance as any).onCheckoutClick(); expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message); + expect(loadCart).toHaveBeenCalledOnce(); }); it('allows modifying quantities directly in the regular cart without a toggle', () => { diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index ea5dd24..4bf5c38 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -18,7 +18,10 @@ import { ButtonComponent } from '../../../shared/components/button/button.compon import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface'; import { AuthService } from '../../services/auth/auth.service'; import { findMenu } from '../../services/menu.utils'; -import { CheckoutService } from '../../services/checkout.service'; +import { + CheckoutService, + isExpiredStockReservationResponse, +} from '../../services/checkout.service'; import { ToastService } from '../../services/toast.service'; import { Category } from '../../services/tenant.interface'; @@ -285,6 +288,11 @@ export class StoreLayoutComponent implements OnInit { : 'No se pudo iniciar la compra.'; this.toastService.danger(message); + if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { + this.cartService.loadCart().subscribe({ + error: (refreshError) => console.error('Error refreshing expired cart', refreshError), + }); + } } finally { this.isCreatingPurchase.set(false); } diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index df1a4e2..1952549 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -45,6 +45,21 @@ export function isInsufficientStockResponse(value: unknown): value is Insufficie ); } +export interface ExpiredStockReservationResponse { + code: 'stock_reservation.expired'; + message: string; +} + +export function isExpiredStockReservationResponse( + value: unknown, +): value is ExpiredStockReservationResponse { + return ( + typeof value === 'object' && + value !== null && + (value as Partial).code === 'stock_reservation.expired' + ); +} + export type StartCheckoutPayload = | { cart_id: number; diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.ts index cf8d447..e020300 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.ts +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.ts @@ -23,7 +23,10 @@ import { import { AuthService } from '../../../../core/services/auth/auth.service'; import { CartService } from '../../../../core/services/cart/cart.service'; -import { CheckoutService } from '../../../../core/services/checkout.service'; +import { + CheckoutService, + isExpiredStockReservationResponse, +} from '../../../../core/services/checkout.service'; import { CatalogGroupLayout, CategoryItemsResponse, @@ -176,6 +179,11 @@ export class CategoryItemsPageComponent { : 'No se pudo iniciar la compra directa.'; this.toastService.danger(message); + if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { + this.cartService.loadCart().subscribe({ + error: (refreshError) => console.error('Error refreshing expired cart', refreshError), + }); + } } finally { this.creatingDirectPurchase.set(false); } diff --git a/src/app/features/store/pages/search-page/search-page.component.ts b/src/app/features/store/pages/search-page/search-page.component.ts index 729e387..ba3bc45 100644 --- a/src/app/features/store/pages/search-page/search-page.component.ts +++ b/src/app/features/store/pages/search-page/search-page.component.ts @@ -24,7 +24,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CartService } from '../../../../core/services/cart/cart.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; -import { CheckoutService } from '../../../../core/services/checkout.service'; +import { + CheckoutService, + isExpiredStockReservationResponse, +} from '../../../../core/services/checkout.service'; import { CatalogFeaturedItem, CatalogFeaturedItems, @@ -208,6 +211,11 @@ export class SearchPageComponent { : 'No se pudo iniciar la compra directa.'; this.toastService.danger(message); + if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { + this.cartService.loadCart().subscribe({ + error: (refreshError) => console.error('Error refreshing expired cart', refreshError), + }); + } } finally { this.creatingDirectPurchase.set(false); } diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index e9fa018..12b8605 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -21,6 +21,7 @@ import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; import { CheckoutService, + isExpiredStockReservationResponse, isInsufficientStockResponse, } from '../../../../core/services/checkout.service'; import { @@ -221,6 +222,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { } catch (error) { console.error('Failed to create direct purchase:', error); + if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { + this.toastService.danger(error.error.message); + this.cartService.loadCart().subscribe({ + error: (refreshError) => console.error('Error refreshing expired cart', refreshError), + }); + return; + } + if (error instanceof HttpErrorResponse && isInsufficientStockResponse(error.error)) { const unavailableIds = error.error.unavailable_items .map((item) => item.variant_id)