fix(checkout): redirect expired purchases from checkout

This commit is contained in:
2026-08-20 17:01:34 -03:00
parent cfa1091116
commit 4251ca8a8a
2 changed files with 149 additions and 13 deletions

View File

@@ -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<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let toastServiceStub: { danger: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
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(['/']);
});
});

View File

@@ -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<string, string[]>;
}
@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();
}
}