fix(checkout): refresh expired carts

This commit is contained in:
ncoronel 2026-08-25 16:47:17 -03:00
parent eeb245209b
commit d89d01b511
6 changed files with 60 additions and 6 deletions

View File

@ -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', () => {

View File

@ -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);
}

View File

@ -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<ExpiredStockReservationResponse>).code === 'stock_reservation.expired'
);
}
export type StartCheckoutPayload =
| {
cart_id: number;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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)