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 <app-checkout-payment-step
[paymentMethods]="paymentMethods" [paymentMethods]="paymentMethods"
[selectedPaymentMethod]="selectedPaymentMethod()" [selectedPaymentMethod]="selectedPaymentMethod()"
[paymentMethodDisabled]="hasSubmittedTransfer()"
[copiedTransferField]="copiedTransferField()" [copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()" [transferAccount]="transferAccount()"
[transferDni]="transferDni()" [transferDni]="transferDni()"

View File

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

View File

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

View File

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

View File

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

View File

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