fix(checkout): lock reviewed payments and refresh cart on exit

This commit is contained in:
2026-08-21 16:16:11 -03:00
parent 21e89805d0
commit 88c3a08a23
6 changed files with 54 additions and 23 deletions

View File

@@ -19,6 +19,7 @@
<app-checkout-payment-step
[paymentMethods]="paymentMethods"
[selectedPaymentMethod]="selectedPaymentMethod()"
[paymentMethodDisabled]="hasSubmittedTransfer()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"

View File

@@ -3,6 +3,7 @@ import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { of } from 'rxjs';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from '../../../../core/services/auth/auth.service';
@@ -11,6 +12,7 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
import { CheckoutPageComponent } from './checkout-page.component';
@@ -30,6 +32,7 @@ describe('CheckoutPageComponent payment validation', () => {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
let cartServiceStub: { loadCart: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType<
@@ -62,7 +65,7 @@ describe('CheckoutPageComponent payment validation', () => {
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(),
};
@@ -70,6 +73,9 @@ describe('CheckoutPageComponent payment validation', () => {
routerStub = { navigate: vi.fn() };
toastServiceStub = { danger: vi.fn() };
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
cartServiceStub = {
loadCart: vi.fn().mockReturnValue(of({ id: 30, items: [], subtotal: '0.00' })),
};
routeQueryParamMap = convertToParamMap({});
authUserState = signal(null);
tenantState = signal({
@@ -92,6 +98,7 @@ describe('CheckoutPageComponent payment validation', () => {
{ provide: AuthService, useValue: { user: authUserState } },
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
{ provide: ToastService, useValue: toastServiceStub },
{ provide: CartService, useValue: cartServiceStub },
{
provide: ActivatedRoute,
useValue: {
@@ -220,8 +227,11 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.hasSubmittedTransfer()).toBe(true);
expect(component.isPurchaseModificationDisabled()).toBe(true);
await component.selectPaymentMethod('qr');
await component.onModifyPurchase();
expect(component.selectedPaymentMethod()).toBe('transfer');
expect(checkoutServiceStub.generatePaymentIntent).not.toHaveBeenCalled();
expect(checkoutServiceStub.cancelPurchase).not.toHaveBeenCalled();
expect(globalLoadingServiceStub.start).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
@@ -267,19 +277,11 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.transferValidationStatus()).toBe('error');
});
it('cancels transfer polling when the payment method changes or the component is destroyed', async () => {
const first = createComponent();
first.component.selectedPaymentMethod.set('transfer');
first.component.onComplete();
checkoutServiceStub.generatePaymentIntent.mockResolvedValueOnce({});
await first.component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
const second = createComponent();
second.component.selectedPaymentMethod.set('transfer');
second.component.onComplete();
second.fixture.destroy();
it('cancels transfer polling when the component is destroyed', async () => {
const checkout = createComponent();
checkout.component.selectedPaymentMethod.set('transfer');
await checkout.component.onComplete();
checkout.fixture.destroy();
await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
@@ -510,6 +512,7 @@ describe('CheckoutPageComponent payment validation', () => {
await Promise.resolve();
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
expect(component.createdPurchaseId()).toBeNull();
@@ -566,6 +569,7 @@ describe('CheckoutPageComponent payment validation', () => {
await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(component.createdPurchaseId()).toBeNull();
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();

View File

@@ -11,7 +11,7 @@ import {
import { HttpErrorResponse } from '@angular/common/http';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { startWith } from 'rxjs';
import { firstValueFrom, startWith } from 'rxjs';
import { TenantService } from '../../../../core/services/tenant.service';
import {
@@ -22,6 +22,7 @@ import {
import { AuthService } from '../../../../core/services/auth/auth.service';
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
@@ -66,6 +67,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly authService = inject(AuthService);
private readonly globalLoadingService = inject(GlobalLoadingService);
private readonly toastService = inject(ToastService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 9;
private readonly transferPollingIntervalMs = 3_000;
@@ -286,6 +288,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
await firstValueFrom(this.cartService.loadCart());
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
@@ -301,7 +304,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted) {
if (this.navigationStarted || this.hasSubmittedTransfer()) {
return;
}
@@ -353,6 +356,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
protected async generateTransferIntent(dni: string): Promise<void> {
if (this.hasSubmittedTransfer()) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
@@ -642,6 +649,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
if (
(purchase.status === 'pending_payment' && purchase.expires_at === null) ||
purchase.status === 'in_review' ||
purchase.status === 'paid' ||
purchase.status === 'cancelled' ||
purchase.status === 'rejected' ||

View File

@@ -7,13 +7,18 @@
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods(); track method.id) {
<label class="payment-method" [class.is-selected]="selectedPaymentMethod() === method.id">
<label
class="payment-method"
[class.is-selected]="selectedPaymentMethod() === method.id"
[class.is-disabled]="paymentMethodDisabled()"
>
<input
class="payment-method__radio"
type="radio"
name="payment-method"
[value]="method.id"
[checked]="selectedPaymentMethod() === method.id"
[disabled]="paymentMethodDisabled()"
(change)="selectPaymentMethod(method.id)"
/>

View File

@@ -63,10 +63,14 @@
cursor: pointer;
transition: color 0.2s ease;
&:hover {
&:not(.is-disabled):hover {
color: #4f4f4f;
}
&.is-disabled {
cursor: default;
}
&.is-selected {
color: var(--tenant-primary, #6376f3);
}
@@ -79,6 +83,10 @@
cursor: pointer;
}
&.is-disabled &__radio {
cursor: default;
}
&__label {
min-width: 0;
font-size: 13px;
@@ -91,11 +99,13 @@
font-size: 0.95rem;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
&.is-selected &__chevron,
&:hover &__chevron {
&:not(.is-disabled):hover &__chevron {
opacity: 1;
transform: translateX(0);
}
@@ -140,6 +150,4 @@
font-weight: 700;
line-height: 1.35;
}
}

View File

@@ -17,11 +17,12 @@ import {
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
readonly selectedPaymentMethod = input.required<PaymentMethod>();
readonly paymentMethodDisabled = input<boolean>(false);
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
@@ -40,6 +41,10 @@ export class CheckoutPaymentStepComponent {
readonly generateTransferIntent = output<string>();
protected selectPaymentMethod(method: PaymentMethod): void {
if (this.paymentMethodDisabled()) {
return;
}
this.paymentMethodChange.emit(method);
}