fix(checkout): redirect expired purchases from checkout
This commit is contained in:
@@ -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(['/']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user