fix(checkout): handle expired purchase exits
This commit is contained in:
parent
f4e0a9e028
commit
d06b146104
|
|
@ -460,7 +460,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the API error in a toast when cancelling the purchase fails', async () => {
|
it('shows the API error and redirects to status when cancellation finds an expired purchase', async () => {
|
||||||
const message = 'La compra venció. Iniciá una nueva compra.';
|
const message = 'La compra venció. Iniciá una nueva compra.';
|
||||||
checkoutServiceStub.cancelPurchase.mockRejectedValue({
|
checkoutServiceStub.cancelPurchase.mockRejectedValue({
|
||||||
error: { code: 'purchase.expired', message },
|
error: { code: 'purchase.expired', message },
|
||||||
|
|
@ -470,7 +470,9 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||||
await component.onModifyPurchase();
|
await component.onModifyPurchase();
|
||||||
|
|
||||||
expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
|
expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
|
||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
|
||||||
|
queryParams: { status: 'expired' },
|
||||||
|
});
|
||||||
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
||||||
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
||||||
expect(component.isCancellingPurchase()).toBe(false);
|
expect(component.isCancellingPurchase()).toBe(false);
|
||||||
|
|
@ -587,7 +589,24 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||||
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 () => {
|
it('allows leaving checkout when cancellation finds an expired stock reservation', async () => {
|
||||||
|
const message = 'La reserva de stock venció. Usá el carrito activo para continuar.';
|
||||||
|
checkoutServiceStub.cancelPurchase.mockRejectedValue({
|
||||||
|
error: { code: 'stock_reservation.expired', message },
|
||||||
|
});
|
||||||
|
const { component } = createComponent();
|
||||||
|
|
||||||
|
await expect(component.canDeactivate()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
|
||||||
|
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
|
||||||
|
expect(component.createdPurchaseId()).toBeNull();
|
||||||
|
expect(component.createdPurchase()).toBeNull();
|
||||||
|
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
||||||
|
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the API message and redirects to status when a checkout operation finds an expired purchase', async () => {
|
||||||
checkoutServiceStub.generatePaymentIntent.mockRejectedValue(
|
checkoutServiceStub.generatePaymentIntent.mockRejectedValue(
|
||||||
new HttpErrorResponse({
|
new HttpErrorResponse({
|
||||||
status: 422,
|
status: 422,
|
||||||
|
|
@ -604,10 +623,35 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||||
expect(toastServiceStub.danger).toHaveBeenCalledWith(
|
expect(toastServiceStub.danger).toHaveBeenCalledWith(
|
||||||
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
|
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
|
||||||
);
|
);
|
||||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/']);
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
|
||||||
|
queryParams: { status: 'expired' },
|
||||||
|
});
|
||||||
expect(component.isGeneratingIntent()).toBe(false);
|
expect(component.isGeneratingIntent()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redirects to status when QR polling receives a purchase-expired response', async () => {
|
||||||
|
checkoutServiceStub.getPurchase.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');
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
|
||||||
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
|
||||||
|
queryParams: { status: 'expired' },
|
||||||
|
});
|
||||||
|
expect(toastServiceStub.danger).toHaveBeenCalledWith(
|
||||||
|
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('treats a generic customer-data error as expired when the local deadline passed', () => {
|
it('treats a generic customer-data error as expired when the local deadline passed', () => {
|
||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
component.createdPurchase.set({
|
component.createdPurchase.set({
|
||||||
|
|
@ -635,6 +679,8 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||||
expect(toastServiceStub.danger).toHaveBeenCalledWith(
|
expect(toastServiceStub.danger).toHaveBeenCalledWith(
|
||||||
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
|
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
|
||||||
);
|
);
|
||||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/']);
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
|
||||||
|
queryParams: { status: 'expired' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,18 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cancel the current purchase:', error);
|
console.error('Failed to cancel the current purchase:', error);
|
||||||
|
|
||||||
|
if (this.isStockReservationExpiredError(error)) {
|
||||||
|
this.showRequestError(error, 'La reserva de stock venció.');
|
||||||
|
this.cartService.loadCart().subscribe({
|
||||||
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
|
});
|
||||||
|
this.createdPurchaseId.set(null);
|
||||||
|
this.createdPurchase.set(null);
|
||||||
|
this.navigationStarted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
this.showRequestError(error, 'No se pudo cancelar la compra.');
|
this.showRequestError(error, 'No se pudo cancelar la compra.');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -492,8 +504,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (purchase.status === 'expired') {
|
||||||
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to validate transfer payment:', error);
|
console.error('Failed to validate transfer payment:', error);
|
||||||
|
if (this.isPurchaseExpiredError(error)) {
|
||||||
|
this.showRequestError(error, 'La compra venció. Iniciá una nueva compra.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runId !== this.transferPollingRunId) {
|
if (runId !== this.transferPollingRunId) {
|
||||||
|
|
@ -575,8 +596,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
this.qrPaymentStatus.set('failed');
|
this.qrPaymentStatus.set('failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (purchase.status === 'expired') {
|
||||||
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to validate QR payment:', error);
|
console.error('Failed to validate QR payment:', error);
|
||||||
|
if (this.isPurchaseExpiredError(error)) {
|
||||||
|
this.showRequestError(error, 'La compra venció. Iniciá una nueva compra.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (runId === this.qrPollingRunId) {
|
if (runId === this.qrPollingRunId) {
|
||||||
this.isCheckingQrPayment.set(false);
|
this.isCheckingQrPayment.set(false);
|
||||||
|
|
@ -683,15 +713,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load purchase:', error);
|
console.error('Failed to load purchase:', error);
|
||||||
this.showRequestError(error, 'No se pudo cargar la compra.');
|
const expired = this.showRequestError(error, 'No se pudo cargar la compra.');
|
||||||
void this.router.navigate(['/']);
|
if (!expired) {
|
||||||
|
void this.router.navigate(['/']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private showRequestError(error: unknown, fallbackMessage: string): void {
|
private showRequestError(error: unknown, fallbackMessage: string): boolean {
|
||||||
const payload =
|
const payload =
|
||||||
typeof error === 'object' && error !== null && 'error' in error
|
typeof error === 'object' && error !== null && 'error' in error
|
||||||
? (error as { error?: { message?: unknown } }).error
|
? (error as { error?: ApiErrorResponse }).error
|
||||||
: undefined;
|
: undefined;
|
||||||
const message =
|
const message =
|
||||||
typeof payload?.message === 'string' && payload.message.trim()
|
typeof payload?.message === 'string' && payload.message.trim()
|
||||||
|
|
@ -699,6 +731,13 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
: fallbackMessage;
|
: fallbackMessage;
|
||||||
|
|
||||||
this.toastService.danger(message);
|
this.toastService.danger(message);
|
||||||
|
|
||||||
|
if (payload?.code === 'purchase.expired') {
|
||||||
|
this.navigateToExpiredPurchaseStatus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean {
|
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean {
|
||||||
|
|
@ -717,7 +756,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
? response.message
|
? response.message
|
||||||
: 'La compra venció. Iniciá una nueva compra.',
|
: 'La compra venció. Iniciá una nueva compra.',
|
||||||
);
|
);
|
||||||
void this.router.navigate(['/']);
|
this.navigateToExpiredPurchaseStatus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -729,6 +768,42 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private navigateToExpiredPurchaseStatus(): void {
|
||||||
|
const purchaseId = this.createdPurchaseId() ?? this.createdPurchase()?.id;
|
||||||
|
|
||||||
|
if (purchaseId) {
|
||||||
|
if (this.navigationStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.navigationStarted = true;
|
||||||
|
this.stopQrPolling();
|
||||||
|
this.stopTransferPolling();
|
||||||
|
void this.router.navigate(['/checkout/status', purchaseId], {
|
||||||
|
queryParams: { status: 'expired' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPurchaseExpiredError(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null || !('error' in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (error as { error?: ApiErrorResponse }).error?.code === 'purchase.expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isStockReservationExpiredError(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null || !('error' in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (error as { error?: ApiErrorResponse }).error?.code === 'stock_reservation.expired';
|
||||||
|
}
|
||||||
|
|
||||||
private hasExpiredPurchase(): boolean {
|
private hasExpiredPurchase(): boolean {
|
||||||
const purchase = this.createdPurchase();
|
const purchase = this.createdPurchase();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ describe('PurchaseStatusPageComponent', () => {
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function render(hasGeneratedTickets: boolean) {
|
async function render(hasGeneratedTickets: boolean, forcedStatus?: string) {
|
||||||
const checkoutService = {
|
const checkoutService = {
|
||||||
getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)),
|
getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)),
|
||||||
withCustomLoading() {
|
withCustomLoading() {
|
||||||
|
|
@ -85,7 +85,14 @@ describe('PurchaseStatusPageComponent', () => {
|
||||||
{ provide: TenantService, useValue: { tenant: () => tenant } },
|
{ provide: TenantService, useValue: { tenant: () => tenant } },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } },
|
useValue: {
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({ id: '42' }),
|
||||||
|
queryParamMap: convertToParamMap(
|
||||||
|
forcedStatus ? { status: forcedStatus } : {},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ provide: Router, useValue: router },
|
{ provide: Router, useValue: router },
|
||||||
],
|
],
|
||||||
|
|
@ -134,6 +141,14 @@ describe('PurchaseStatusPageComponent', () => {
|
||||||
openSpy.mockRestore();
|
openSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the expired result without polling when checkout redirects after expiration', async () => {
|
||||||
|
const { element, checkoutService } = await render(false, 'expired');
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('LA COMPRA VENCIÓ');
|
||||||
|
expect(element.textContent).not.toContain('ESTAMOS VERIFICANDO TU PAGO');
|
||||||
|
expect(checkoutService.getPurchase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('polls every five seconds while the payment is pending and shows the confirmation', async () => {
|
it('polls every five seconds while the payment is pending and shows the confirmation', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,12 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
|
||||||
this.purchaseId = purchaseId;
|
this.purchaseId = purchaseId;
|
||||||
this.tenantCode = tenant.codigo;
|
this.tenantCode = tenant.codigo;
|
||||||
|
|
||||||
|
if (this.route.snapshot.queryParamMap?.get('status') === 'expired') {
|
||||||
|
this.status.set('expired');
|
||||||
|
this.isLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void this.loadStatus();
|
void this.loadStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +113,10 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
|
||||||
console.error('Failed to fetch purchase status:', error);
|
console.error('Failed to fetch purchase status:', error);
|
||||||
|
|
||||||
if (!this.isDestroyed) {
|
if (!this.isDestroyed) {
|
||||||
if (isPolling) {
|
if (this.isPurchaseExpiredError(error)) {
|
||||||
|
this.status.set('expired');
|
||||||
|
this.stopPolling();
|
||||||
|
} else if (isPolling) {
|
||||||
this.schedulePolling();
|
this.schedulePolling();
|
||||||
} else {
|
} else {
|
||||||
this.status.set('error');
|
this.status.set('error');
|
||||||
|
|
@ -154,6 +163,14 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
|
||||||
return 'pending';
|
return 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPurchaseExpiredError(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null || !('error' in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (error as { error?: { code?: string } }).error?.code === 'purchase.expired';
|
||||||
|
}
|
||||||
|
|
||||||
protected goToTickets(): void {
|
protected goToTickets(): void {
|
||||||
const route = this.ticketsRoute();
|
const route = this.ticketsRoute();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue