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 { signal } from '@angular/core';
|
||||||
import { getTestBed, TestBed } from '@angular/core/testing';
|
import { getTestBed, TestBed } from '@angular/core/testing';
|
||||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/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 { CheckoutService } from '../../../../core/services/checkout.service';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
|
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
|
||||||
import { CheckoutPageComponent } from './checkout-page.component';
|
import { CheckoutPageComponent } from './checkout-page.component';
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
clearCart: ReturnType<typeof vi.fn>;
|
clearCart: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let routerStub: { navigate: ReturnType<typeof vi.fn> };
|
let routerStub: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
let toastServiceStub: { danger: ReturnType<typeof vi.fn> };
|
||||||
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
|
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
|
||||||
let authUserState: ReturnType<typeof signal>;
|
let authUserState: ReturnType<typeof signal>;
|
||||||
let tenantState: ReturnType<
|
let tenantState: ReturnType<
|
||||||
@@ -97,6 +100,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
routerStub = { navigate: vi.fn() };
|
routerStub = { navigate: vi.fn() };
|
||||||
|
toastServiceStub = { danger: vi.fn() };
|
||||||
routeQueryParamMap = convertToParamMap({});
|
routeQueryParamMap = convertToParamMap({});
|
||||||
authUserState = signal(null);
|
authUserState = signal(null);
|
||||||
tenantState = signal({
|
tenantState = signal({
|
||||||
@@ -118,6 +122,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
{ provide: CartService, useValue: cartServiceStub },
|
{ provide: CartService, useValue: cartServiceStub },
|
||||||
{ provide: TenantService, useValue: { tenant: tenantState } },
|
{ provide: TenantService, useValue: { tenant: tenantState } },
|
||||||
{ provide: AuthService, useValue: { user: authUserState } },
|
{ provide: AuthService, useValue: { user: authUserState } },
|
||||||
|
{ provide: ToastService, useValue: toastServiceStub },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -635,4 +640,55 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||||
expect(component.createdPurchaseId()).toBe(25);
|
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,
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { FormBuilder, Validators } from '@angular/forms';
|
import { FormBuilder, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { firstValueFrom, startWith } from 'rxjs';
|
import { firstValueFrom, startWith } from 'rxjs';
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
} from '../../../../core/services/checkout.service';
|
} from '../../../../core/services/checkout.service';
|
||||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { BankAccount } from '../../../../core/services/tenant.interface';
|
import { BankAccount } from '../../../../core/services/tenant.interface';
|
||||||
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
|
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
|
||||||
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
||||||
@@ -36,6 +38,12 @@ import {
|
|||||||
TransferValidationStatus,
|
TransferValidationStatus,
|
||||||
} from './checkout-page.models';
|
} from './checkout-page.models';
|
||||||
|
|
||||||
|
interface ApiErrorResponse {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-checkout-page',
|
selector: 'app-checkout-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -59,6 +67,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly qrPollingIntervalMs = 5_000;
|
private readonly qrPollingIntervalMs = 5_000;
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
private readonly qrPollingMaxAttempts = 9;
|
private readonly qrPollingMaxAttempts = 9;
|
||||||
private readonly transferPollingIntervalMs = 3_000;
|
private readonly transferPollingIntervalMs = 3_000;
|
||||||
private readonly transferPollingMaxAttempts = 4;
|
private readonly transferPollingMaxAttempts = 4;
|
||||||
@@ -199,9 +208,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editing && !this.canModifyCart()) {
|
if (editing && !this.canModifyCart()) {
|
||||||
|
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(['/'], {
|
await this.router.navigate(['/'], {
|
||||||
queryParams: { openCart: true },
|
queryParams: { openCart: true },
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleCheckoutError(error, 'No se pudo restaurar el carrito para editarlo.');
|
||||||
|
} finally {
|
||||||
|
this.isPreparingItemEdit.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +264,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
|
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
} catch (error) {
|
} 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);
|
this.isEditingItems.set(false);
|
||||||
} finally {
|
} finally {
|
||||||
this.isPreparingItemEdit.set(false);
|
this.isPreparingItemEdit.set(false);
|
||||||
@@ -274,7 +302,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update purchase item quantity:', error);
|
this.handleCheckoutError(error, 'No se pudo actualizar la cantidad del producto.');
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdatingItem.set(false);
|
this.isUpdatingItem.set(false);
|
||||||
}
|
}
|
||||||
@@ -313,7 +341,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update purchase item variant:', error);
|
this.handleCheckoutError(error, 'No se pudo actualizar la variante del producto.');
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdatingItem.set(false);
|
this.isUpdatingItem.set(false);
|
||||||
}
|
}
|
||||||
@@ -347,7 +375,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove purchase item:', error);
|
this.handleCheckoutError(error, 'No se pudo eliminar el producto de la compra.');
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdatingItem.set(false);
|
this.isUpdatingItem.set(false);
|
||||||
}
|
}
|
||||||
@@ -377,8 +405,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
// Auto trigger intent for default option
|
// Auto trigger intent for default option
|
||||||
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create purchase:', error);
|
this.handleCheckoutError(error, 'No se pudieron actualizar los datos de la compra.');
|
||||||
// Here we could show an alert or toast
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdatingPurchase.set(false);
|
this.isUpdatingPurchase.set(false);
|
||||||
}
|
}
|
||||||
@@ -454,7 +481,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
this.startQrPolling();
|
this.startQrPolling();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate payment intent:', error);
|
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de pago.');
|
||||||
} finally {
|
} finally {
|
||||||
this.isGeneratingIntent.set(false);
|
this.isGeneratingIntent.set(false);
|
||||||
}
|
}
|
||||||
@@ -491,7 +518,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate transfer payment intent:', error);
|
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de transferencia.');
|
||||||
} finally {
|
} finally {
|
||||||
this.isGeneratingIntent.set(false);
|
this.isGeneratingIntent.set(false);
|
||||||
}
|
}
|
||||||
@@ -550,9 +577,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.scheduleTransferPoll(runId);
|
this.scheduleTransferPoll(runId);
|
||||||
} catch (error) {
|
} 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');
|
this.transferValidationStatus.set('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -784,4 +814,54 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
void this.router.navigate(['/']);
|
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