feat(checkout): add error handling for expired purchases in checkout process

This commit is contained in:
2026-08-21 10:10:20 -03:00
parent b14f34d3e8
commit 9b69a1d387
7 changed files with 155 additions and 329 deletions

View File

@@ -5,12 +5,7 @@
</div> </div>
} @else { } @else {
<div class="checkout-page"> <div class="checkout-page">
<div <div class="checkout-page__stepper-col">
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()"> <app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<app-step label="Datos" [isValid]="isStep1Valid()"> <app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step
@@ -45,12 +40,6 @@
</app-stepper> </app-stepper>
</div> </div>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</div>
}
<div class="checkout-page__cart-col"> <div class="checkout-page__cart-col">
<app-cart <app-cart
title="COMPRA" title="COMPRA"
@@ -58,23 +47,13 @@
[subtotal]="cartSubtotal()" [subtotal]="cartSubtotal()"
[discount]="cartDiscount()" [discount]="cartDiscount()"
[total]="cartTotal()" [total]="cartTotal()"
[readonly]="!canModifyCart()" [readonly]="true"
[allowModify]="true" [allowModify]="true"
[showModifyWhenReadonly]="true" [showModifyWhenReadonly]="true"
[allowUpdateQuantity]="canUpdateCartQuantity()" [modifyAsAction]="true"
[allowUpdateVariant]="canUpdateCartVariant()" [editingDisabled]="isRestoringCart()"
[requireEditingMode]="true"
[allowDelete]="canDeleteCartItems()"
[persistQuantityChanges]="false"
[persistVariantChanges]="false"
[persistDeleteChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent" backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)" (modify)="onModifyPurchase()"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
(itemVariantChange)="onPurchaseItemVariantChange($event)"
(itemRemove)="onPurchaseItemRemove($event)"
/> />
</div> </div>
</div> </div>

View File

@@ -11,8 +11,7 @@
order: 1; order: 1;
} }
.checkout-page__stepper-col, .checkout-page__stepper-col {
.checkout-page__editing-notice {
order: 2; order: 2;
} }
} }
@@ -21,27 +20,6 @@
min-width: 0; min-width: 0;
border-radius: 4px; border-radius: 4px;
min-height: 420px; min-height: 420px;
&--editing {
display: none;
}
}
&__editing-notice {
display: grid;
min-height: 420px;
place-items: center;
padding: 2rem;
border-radius: 4px;
background: #f5f5f5;
color: #666666;
text-align: center;
p {
max-width: 360px;
margin: 0;
font-size: 14px;
}
} }
&__cart-col { &__cart-col {

View File

@@ -9,8 +9,10 @@ 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 { 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 { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { CartEditingPolicy } from '../../../../core/services/tenant.interface'; import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
import { ToastService } from '../../../../core/services/toast.service';
import { CheckoutPageComponent } from './checkout-page.component'; import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => { describe('CheckoutPageComponent payment validation', () => {
@@ -20,7 +22,6 @@ describe('CheckoutPageComponent payment validation', () => {
updateItemQuantity: ReturnType<typeof vi.fn>; updateItemQuantity: ReturnType<typeof vi.fn>;
updateItemVariant: ReturnType<typeof vi.fn>; updateItemVariant: ReturnType<typeof vi.fn>;
removeItem: ReturnType<typeof vi.fn>; removeItem: ReturnType<typeof vi.fn>;
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>; cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>; generatePaymentIntent: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>; submitPurchaseForReview: ReturnType<typeof vi.fn>;
@@ -34,6 +35,11 @@ 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 globalLoadingServiceStub: {
start: ReturnType<typeof vi.fn>;
stop: 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<
@@ -65,7 +71,6 @@ describe('CheckoutPageComponent payment validation', () => {
updateItemQuantity: vi.fn(), updateItemQuantity: vi.fn(),
updateItemVariant: vi.fn(), updateItemVariant: vi.fn(),
removeItem: vi.fn(), removeItem: vi.fn(),
prepareItemEditing: vi.fn(),
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }), cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
generatePaymentIntent: vi.fn().mockResolvedValue({ generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' }, qr_data: { qr_code: 'qr-value' },
@@ -97,6 +102,8 @@ describe('CheckoutPageComponent payment validation', () => {
}); });
}); });
routerStub = { navigate: vi.fn() }; routerStub = { navigate: vi.fn() };
toastServiceStub = { danger: vi.fn() };
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
routeQueryParamMap = convertToParamMap({}); routeQueryParamMap = convertToParamMap({});
authUserState = signal(null); authUserState = signal(null);
tenantState = signal({ tenantState = signal({
@@ -118,6 +125,8 @@ 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: GlobalLoadingService, useValue: globalLoadingServiceStub },
{ provide: ToastService, useValue: toastServiceStub },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { useValue: {
@@ -472,83 +481,30 @@ describe('CheckoutPageComponent payment validation', () => {
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
}); });
it('updates a purchase item while editing and refreshes checkout totals', async () => { it('shows the API error in a toast when restoring the cart fails', async () => {
const updatedPurchase = { const message = 'La compra venció. Iniciá una nueva compra.';
id: 25, checkoutServiceStub.cancelPurchase.mockRejectedValue({
items: [], error: { code: 'purchase.expired', message },
subtotal: '300.00',
total: '300.00',
};
checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.isEditingItems.set(true);
await component.onPurchaseItemQuantityChange({
item: {
cartItemId: 91,
imageUrl: null,
product: 'Remera',
originalPrice: null,
discountedPrice: 100,
discountPercentage: null,
attributes: [],
quantity: 2,
},
quantity: 3,
}); });
const { component } = createComponent();
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith( await component.onModifyPurchase();
'tenant-test',
25, expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
91, expect(routerStub.navigate).not.toHaveBeenCalled();
3, expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
null, expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
'purchase', expect(component.isRestoringCart()).toBe(false);
});
it('restores the previous cart and opens it when Modificar is clicked', async () => {
let finishNavigation!: (navigated: boolean) => void;
routerStub.navigate.mockReturnValue(
new Promise<boolean>((resolve) => {
finishNavigation = resolve;
}),
); );
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
});
it('keeps the payment step selected while editing and regenerates payment afterward', async () => {
const editablePurchase = {
id: 25,
status: 'created',
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase);
const { component } = createComponent(); const { component } = createComponent();
component.stepper = { currentStepIndex: signal(1) };
const selectPaymentMethod = vi
.spyOn(component, 'selectPaymentMethod')
.mockResolvedValue(undefined);
await component.onEditingItemsChange(true);
expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isEditingItems()).toBe(true);
expect(component.createdPurchase()).toBe(editablePurchase);
await component.onEditingItemsChange(false);
expect(component.stepper.currentStepIndex()).toBe(1);
expect(selectPaymentMethod).toHaveBeenCalledWith('qr');
expect(component.isEditingItems()).toBe(false);
});
it('shows Modificar, restores the previous cart and opens it when checkout editing is disabled', async () => {
tenantState.set({
codigo: 'tenant-test',
checkout_editing_policy: {
code: 'disabled',
allow_modify: false,
allow_delete: false,
allow_update_quantity: false,
allow_update_variant: false,
},
});
const { fixture, component } = createComponent();
component.createdPurchase.set({ component.createdPurchase.set({
id: 25, id: 25,
status: 'created', status: 'created',
@@ -572,27 +528,28 @@ describe('CheckoutPageComponent payment validation', () => {
subtotal: '200.00', subtotal: '200.00',
total: '200.00', total: '200.00',
}); });
fixture.detectChanges(); const modification = component.onModifyPurchase();
await Promise.resolve();
await Promise.resolve();
const modifyButton = (fixture.nativeElement as HTMLElement).querySelector('.cart-edit-btn');
expect(modifyButton?.textContent?.trim()).toBe('Modificar');
expect(
(fixture.nativeElement as HTMLElement).querySelector('app-quantity-selector'),
).toBeNull();
await component.onEditingItemsChange(true);
expect(component.canUpdateCartQuantity()).toBe(false);
expect(component.canModifyCart()).toBe(false);
expect(component.isEditingItems()).toBe(false);
expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled();
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25); expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalled(); expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(component.createdPurchaseId()).toBeNull(); expect(component.createdPurchaseId()).toBeNull();
expect(component.createdPurchase()).toBeNull(); expect(component.createdPurchase()).toBeNull();
expect(routerStub.navigate).toHaveBeenCalledWith(['/'], { expect(routerStub.navigate).toHaveBeenCalledWith(['/'], {
queryParams: { openCart: true }, queryParams: { openCart: true },
}); });
finishNavigation(true);
await modification;
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
await component.canDeactivate();
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
}); });
it('updates customer data on the existing purchase before payment', async () => { it('updates customer data on the existing purchase before payment', async () => {

View File

@@ -20,6 +20,8 @@ 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 { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.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';
@@ -58,6 +60,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly globalLoadingService = inject(GlobalLoadingService);
private readonly toastService = inject(ToastService);
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;
@@ -84,19 +88,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null); protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true); protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0); protected readonly checkoutStepIndex = signal(0);
protected readonly canUpdateCartQuantity = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_update_quantity ?? false,
);
protected readonly canUpdateCartVariant = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_update_variant ?? false,
);
protected readonly canDeleteCartItems = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_delete ?? false,
);
protected readonly canModifyCart = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_modify ?? false,
);
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase(); const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.subtotal) : 0; return purchase ? parseFloat(purchase.subtotal) : 0;
@@ -125,9 +116,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly transferDni = signal<string>(''); protected readonly transferDni = signal<string>('');
protected readonly isUpdatingPurchase = signal(false); protected readonly isUpdatingPurchase = signal(false);
protected readonly isEditingItems = signal(false); protected readonly isRestoringCart = signal(false);
protected readonly isUpdatingItem = signal(false);
protected readonly isPreparingItemEdit = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null); protected readonly createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false); protected readonly isGeneratingIntent = signal(false);
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent()); protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
@@ -193,187 +182,39 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}; };
} }
protected async onEditingItemsChange(editing: boolean): Promise<void> { protected async onModifyPurchase(): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) { if (this.isRestoringCart()) {
return; return;
} }
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(['/'], {
queryParams: { openCart: true },
});
} catch (error) {
console.error('Failed to restore cart for editing:', error);
} finally {
this.isPreparingItemEdit.set(false);
}
return;
}
if (!editing) {
this.isEditingItems.set(false);
if (this.stepper?.currentStepIndex() === 1) {
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
return;
}
this.isEditingItems.set(true);
this.stopQrPolling();
this.stopTransferPolling();
this.qrData.set(null);
this.qrPaymentStatus.set('idle');
this.transferAccount.set(null);
this.transferValidationStatus.set('idle');
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) { if (!tenant || !purchaseId) {
this.isEditingItems.set(false);
return; return;
} }
this.isPreparingItemEdit.set(true); this.isRestoringCart.set(true);
this.globalLoadingService.start();
try { try {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId); await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase); this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
await firstValueFrom(this.cartService.loadCart());
this.navigationStarted = true;
await this.router.navigate(['/'], {
queryParams: { openCart: true },
});
} catch (error) { } catch (error) {
console.error('Failed to prepare purchase item editing:', error); console.error('Failed to restore cart for editing:', error);
this.isEditingItems.set(false); this.showRequestError(error, 'No se pudo recuperar el carrito para modificar la compra.');
} finally { } finally {
this.isPreparingItemEdit.set(false); this.globalLoadingService.stop();
} this.isRestoringCart.set(false);
}
protected async onPurchaseItemQuantityChange(event: {
item: CartItemMock;
quantity: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (
!this.canUpdateCartQuantity() ||
!tenant ||
!purchaseId ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
const purchase = await this.checkoutService.updateItemQuantity(
tenant.codigo,
purchaseId,
itemId,
event.quantity,
this.createdPurchase()?.cart_id ?? null,
this.createdPurchase()?.items_source ?? 'purchase',
);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to update purchase item quantity:', error);
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onPurchaseItemVariantChange(event: {
item: CartItemMock;
variantId: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchase = this.createdPurchase();
const itemId = event.item.cartItemId;
if (
!this.canUpdateCartVariant() ||
!tenant ||
!purchase ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
this.createdPurchase.set(
await this.checkoutService.updateItemVariant(
tenant.codigo,
purchase.id,
itemId,
event.variantId,
event.item.quantity,
purchase.cart_id,
purchase.items_source,
),
);
} catch (error) {
console.error('Failed to update purchase item variant:', error);
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onPurchaseItemRemove(event: { item: CartItemMock }): Promise<void> {
const tenant = this.tenantService.tenant();
const purchase = this.createdPurchase();
const itemId = event.item.cartItemId;
if (
!this.canDeleteCartItems() ||
!tenant ||
!purchase ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
this.createdPurchase.set(
await this.checkoutService.removeItem(
tenant.codigo,
purchase.id,
itemId,
purchase.cart_id,
purchase.items_source,
),
);
} catch (error) {
console.error('Failed to remove purchase item:', error);
} finally {
this.isUpdatingItem.set(false);
} }
} }
protected async onStep1Continue(): Promise<void> { protected async onStep1Continue(): Promise<void> {
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return; if (this.form.invalid || this.isUpdatingPurchase()) return;
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
@@ -397,7 +238,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
console.error('Failed to create purchase:', error); console.error('Failed to create purchase:', error);
// Here we could show an alert or toast this.showRequestError(error, 'No se pudieron actualizar los datos de la compra.');
} finally { } finally {
this.isUpdatingPurchase.set(false); this.isUpdatingPurchase.set(false);
} }
@@ -423,13 +264,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
await firstValueFrom(this.cartService.loadCart()); await firstValueFrom(this.cartService.loadCart());
} catch (error) { } catch (error) {
console.error('Failed to load the active cart after leaving checkout:', error); console.error('Failed to load the active cart after leaving checkout:', error);
this.showRequestError(error, 'No se pudo cargar el carrito.');
} }
return true; return true;
} }
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> { protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted || this.isEditingItems()) { if (this.navigationStarted) {
return; return;
} }
@@ -474,16 +316,13 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
} catch (error) { } catch (error) {
console.error('Failed to generate payment intent:', error); console.error('Failed to generate payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
} }
protected async generateTransferIntent(dni: string): Promise<void> { protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
return;
}
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
@@ -511,6 +350,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
} catch (error) { } catch (error) {
console.error('Failed to generate transfer payment intent:', error); console.error('Failed to generate transfer payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago por transferencia.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
@@ -541,7 +381,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
!purchaseId || !purchaseId ||
!tenant || !tenant ||
this.navigationStarted || this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking' this.transferValidationStatus() === 'checking'
) { ) {
return; return;
@@ -570,6 +409,7 @@ 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); console.error('Failed to submit transfer payment for review:', error);
this.showRequestError(error, 'No se pudo enviar el pago para su validación.');
if (runId === this.transferPollingRunId) { if (runId === this.transferPollingRunId) {
this.transferValidationStatus.set('error'); this.transferValidationStatus.set('error');
@@ -800,7 +640,21 @@ 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.');
void this.router.navigate(['/']); void this.router.navigate(['/']);
} }
} }
private showRequestError(error: unknown, fallbackMessage: string): void {
const payload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { message?: unknown } }).error
: undefined;
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message
: fallbackMessage;
this.toastService.danger(message);
}
} }

View File

@@ -9,17 +9,17 @@
@if ( @if (
allowModify() && allowModify() &&
(!readonly() || showModifyWhenReadonly()) && (!readonly() || showModifyWhenReadonly()) &&
requireEditingMode() && (requireEditingMode() || modifyAsAction()) &&
items().length > 0 items().length > 0
) { ) {
<button <button
class="btn btn-link p-0 border-0 cart-edit-btn" class="btn btn-link p-0 border-0 cart-edit-btn"
type="button" type="button"
[attr.aria-pressed]="editing()" [attr.aria-pressed]="modifyAsAction() ? null : editing()"
[disabled]="editingDisabled()" [disabled]="editingDisabled()"
(click)="toggleEditing()" (click)="toggleEditing()"
> >
{{ editing() ? 'Listo' : 'Modificar' }} {{ !modifyAsAction() && editing() ? 'Listo' : 'Modificar' }}
</button> </button>
} }

View File

@@ -367,6 +367,57 @@ describe('CartComponent', () => {
expect(editingChange).toHaveBeenLastCalledWith(false); expect(editingChange).toHaveBeenLastCalledWith(false);
}); });
it('emits Modificar as an action without toggling to Listo', async () => {
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem: vi.fn(),
},
},
{ provide: ModalService, useValue: {} },
{
provide: ToastService,
useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1,
},
]);
fixture.componentRef.setInput('readonly', true);
fixture.componentRef.setInput('showModifyWhenReadonly', true);
fixture.componentRef.setInput('modifyAsAction', true);
const modify = vi.fn();
fixture.componentInstance.modify.subscribe(modify);
fixture.detectChanges();
const modifyButton = fixture.debugElement.query(By.css('.cart-edit-btn'));
expect(modifyButton.nativeElement.textContent.trim()).toBe('Modificar');
modifyButton.nativeElement.click();
fixture.detectChanges();
expect(modify).toHaveBeenCalledOnce();
expect(modifyButton.nativeElement.textContent.trim()).toBe('Modificar');
expect(fixture.componentInstance.editing()).toBe(false);
});
it('allows editing directly when the optional Modificar toggle is disabled', async () => { it('allows editing directly when the optional Modificar toggle is disabled', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CartComponent], imports: [CartComponent],

View File

@@ -56,6 +56,7 @@ export class CartComponent {
readonly allowUpdateQuantity = input<boolean>(true); readonly allowUpdateQuantity = input<boolean>(true);
readonly allowModify = input<boolean>(true); readonly allowModify = input<boolean>(true);
readonly showModifyWhenReadonly = input<boolean>(false); readonly showModifyWhenReadonly = input<boolean>(false);
readonly modifyAsAction = input<boolean>(false);
readonly requireEditingMode = input<boolean>(false); readonly requireEditingMode = input<boolean>(false);
readonly allowUpdateVariant = input<boolean>(true); readonly allowUpdateVariant = input<boolean>(true);
readonly allowDelete = input<boolean>(true); readonly allowDelete = input<boolean>(true);
@@ -66,6 +67,7 @@ export class CartComponent {
readonly editing = model<boolean>(false); readonly editing = model<boolean>(false);
readonly closed = output<void>(); readonly closed = output<void>();
readonly modify = output<void>();
readonly itemQuantityChange = output<{ readonly itemQuantityChange = output<{
item: CartItemMock; item: CartItemMock;
index: number; index: number;
@@ -257,6 +259,11 @@ export class CartComponent {
return; return;
} }
if (this.modifyAsAction()) {
this.modify.emit();
return;
}
const editing = !this.editing(); const editing = !this.editing();
this.editing.set(editing); this.editing.set(editing);
} }