Compare commits

..

13 Commits

26 changed files with 317 additions and 72 deletions

View File

@@ -1,5 +1,7 @@
:host { :host {
display: flex; display: flex;
width: 100%;
min-width: 0;
min-height: 100dvh; min-height: 100dvh;
background: #f5f5f5; background: #f5f5f5;
color: #202020; color: #202020;
@@ -7,6 +9,8 @@
.store-layout { .store-layout {
position: relative; position: relative;
width: 100%;
min-width: 0;
} }
.store-layout__cart-overlay { .store-layout__cart-overlay {

View File

@@ -290,7 +290,7 @@ export class StoreLayoutComponent implements OnInit {
this.toastService.danger(message); this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -83,6 +83,20 @@ describe('CartService', () => {
req.flush({ data: mockCart }); req.flush({ data: mockCart });
}); });
it('refreshes catalog availability only after the expired cart has been reloaded', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.loadCart(true).subscribe();
expect(availabilityChanged).not.toHaveBeenCalled();
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
req.flush({ data: mockCart });
expect(service.cart()).toEqual(mockCart);
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('propagates a custom loading mode to the request context', () => { it('propagates a custom loading mode to the request context', () => {
service.withCustomLoading().loadCart().subscribe(); service.withCustomLoading().loadCart().subscribe();

View File

@@ -34,14 +34,19 @@ export class CartService extends BaseApiService {
}); });
} }
loadCart(): Observable<Cart> { loadCart(refreshCatalogAvailability = false): Observable<Cart> {
return this.http return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, { .get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true, withCredentials: true,
}) })
.pipe( .pipe(
map((response) => response.data), map((response) => response.data),
tap((cart) => this.cartState.set(cart)), tap((cart) => {
this.cartState.set(cart);
if (refreshCatalogAvailability) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
}),
); );
} }

View File

@@ -14,7 +14,6 @@ import {
CatalogItemDetail, CatalogItemDetail,
CatalogVariantOptionsResponse, CatalogVariantOptionsResponse,
CategoryItemsResponse, CategoryItemsResponse,
Product,
} from './catalog.interface'; } from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[]; type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
@@ -33,12 +32,6 @@ export class CatalogService extends BaseApiService {
return this.tenantService.getTenantApiUrl(); return this.tenantService.getTenantApiUrl();
} }
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
params: this.buildHttpParams(params),
});
}
getCatalog(): Observable<CatalogFeaturedGroup[]> { getCatalog(): Observable<CatalogFeaturedGroup[]> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`); return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
} }

View File

@@ -1,9 +1,10 @@
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
import { CheckoutService } from './checkout.service'; import { CheckoutService } from './checkout.service';
describe('CheckoutService', () => { describe('CheckoutService', () => {
@@ -39,6 +40,45 @@ describe('CheckoutService', () => {
await expect(purchasePromise).resolves.toMatchObject({ id: 55 }); await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
}); });
it('refreshes catalog availability when starting checkout fails', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{ message: 'No hay stock disponible.' },
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).toHaveBeenCalledOnce();
});
it('waits for the expired cart refresh before refreshing catalog availability', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{
code: 'stock_reservation.expired',
message: 'La reserva de stock venció.',
},
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).not.toHaveBeenCalled();
});
it('preserves checkout timing fields when completing a purchase', async () => { it('preserves checkout timing fields when completing a purchase', async () => {
const purchasePromise = service.completePurchase('desfile', 55); const purchasePromise = service.completePurchase('desfile', 55);
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`); const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`);

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ApiPaginatedResponse } from './api-paginated-response.interface'; import { ApiPaginatedResponse } from './api-paginated-response.interface';
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface'; import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
import { BaseApiService } from './base-api.service'; import { BaseApiService } from './base-api.service';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
export interface UpdatePurchaseCustomerPayload { export interface UpdatePurchaseCustomerPayload {
dni: string; dni: string;
@@ -73,6 +74,29 @@ export interface PurchaseStatusResponse {
expires_at: string | null; expires_at: string | null;
expires_in_seconds: number | null; expires_in_seconds: number | null;
server_time: string; server_time: string;
payment_verification?: PurchasePaymentVerificationResponse;
}
export type PurchasePaymentCandidateReason =
| 'ambiguous_exact_match'
| 'exact_dni_near_amount'
| 'exact_amount_near_dni';
export interface PurchasePaymentCandidatePrimaryResponse {
reason: PurchasePaymentCandidateReason;
dni_distance: number | null;
payment_amount: string;
purchase_amount: string;
amount_difference: string;
confidence: 'exact' | 'high' | 'medium';
detected_at: string | null;
}
export interface PurchasePaymentVerificationResponse {
status: 'pending' | 'candidate';
candidate_count: number;
primary: PurchasePaymentCandidatePrimaryResponse | null;
reasons: PurchasePaymentCandidateReason[];
} }
export interface PurchaseSummaryResponse extends PurchaseStatusResponse { export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
@@ -126,24 +150,34 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
providedIn: 'root', providedIn: 'root',
}) })
export class CheckoutService extends BaseApiService { export class CheckoutService extends BaseApiService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
async startCheckout( async startCheckout(
tenantCode: string, tenantCode: string,
payload: StartCheckoutPayload, payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> { ): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom( try {
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( const response = await firstValueFrom(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`, this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
payload, `${environment.url}tenants/${tenantCode}/compras/start-checkout`,
), payload,
); ),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response); const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) { if (!purchase?.id) {
throw new Error('Error al crear la compra.'); throw new Error('Error al crear la compra.');
}
return purchase;
} catch (error) {
const responseBody = (error as { error?: unknown } | null)?.error;
if (!isExpiredStockReservationResponse(responseBody)) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
throw error;
} }
return purchase;
} }
async generatePaymentIntent( async generatePaymentIntent(
@@ -188,23 +222,6 @@ export class CheckoutService extends BaseApiService {
return purchase; return purchase;
} }
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al finalizar la compra.');
}
return purchase;
}
async submitPurchaseForReview( async submitPurchaseForReview(
tenantCode: string, tenantCode: string,
purchaseId: number, purchaseId: number,

View File

@@ -180,7 +180,7 @@ export class CategoryItemsPageComponent {
this.toastService.danger(message); this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -34,6 +34,7 @@
[qrPaymentAmount]="cartTotal()" [qrPaymentAmount]="cartTotal()"
[whatsappUrl]="whatsappUrl()" [whatsappUrl]="whatsappUrl()"
[transferValidationStatus]="transferValidationStatus()" [transferValidationStatus]="transferValidationStatus()"
[transferVerificationErrorTitle]="transferVerificationErrorTitle()"
(paymentMethodChange)="selectPaymentMethod($event)" (paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)" (copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()" (cancelStep)="onCancel()"

View File

@@ -225,7 +225,7 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
}); });
it('polls a transfer every three seconds up to four attempts', async () => { it('polls a transfer every three seconds for one minute', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' }); checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' });
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
@@ -235,14 +235,12 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25); expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
for (let attempt = 1; attempt <= 3; attempt += 1) { await vi.advanceTimersByTimeAsync(57_000);
await vi.advanceTimersByTimeAsync(3_000); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(19);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(attempt); expect(component.transferValidationStatus()).toBe('checking');
expect(component.transferValidationStatus()).toBe('checking');
}
await vi.advanceTimersByTimeAsync(3_000); await vi.advanceTimersByTimeAsync(3_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(20);
expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25); expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('error'); expect(component.transferValidationStatus()).toBe('error');
expect(routerStub.navigate).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled();
@@ -286,13 +284,43 @@ describe('CheckoutPageComponent payment validation', () => {
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
await component.onComplete(); await component.onComplete();
await vi.advanceTimersByTimeAsync(12_000); await vi.advanceTimersByTimeAsync(60_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(20);
expect(component.transferValidationStatus()).toBe('error'); expect(component.transferValidationStatus()).toBe('error');
expect(routerStub.navigate).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled();
}); });
it('uses the primary candidate reason when transfer polling times out', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({
status: 'in_review',
payment_verification: {
status: 'candidate',
candidate_count: 1,
primary: {
reason: 'exact_amount_near_dni',
dni_distance: 1,
payment_amount: '300000.00',
purchase_amount: '300000.00',
amount_difference: '0.00',
confidence: 'medium',
detected_at: '2026-08-27T18:00:00-03:00',
},
reasons: ['exact_amount_near_dni'],
},
});
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
await vi.advanceTimersByTimeAsync(60_000);
expect(component.transferValidationStatus()).toBe('error');
expect(component.transferVerificationErrorTitle()).toBe(
'El DNI no corresponde con el de la transferencia',
);
});
it('does not poll when submitting a transfer for review fails', async () => { it('does not poll when submitting a transfer for review fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error')); checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));

View File

@@ -16,8 +16,10 @@ import { firstValueFrom, startWith } from 'rxjs';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { import {
CheckoutService, CheckoutService,
PurchasePaymentCandidateReason,
PurchaseDetailItemResponse, PurchaseDetailItemResponse,
PurchaseDetailResponse, PurchaseDetailResponse,
PurchaseStatusResponse,
} 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 { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service'; import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
@@ -73,7 +75,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly qrPollingIntervalMs = 5_000; private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 120; private readonly qrPollingMaxAttempts = 120;
private readonly transferPollingIntervalMs = 3_000; private readonly transferPollingIntervalMs = 3_000;
private readonly transferPollingMaxAttempts = 209; private readonly transferPollingMaxAttempts = 20;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null; private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0; private qrPollingAttempts = 0;
@@ -102,8 +104,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
if ( if (
!purchase || !purchase ||
(typeof purchase.expires_at !== 'string' && (typeof purchase.expires_at !== 'string' && typeof purchase.expires_in_seconds !== 'number')
typeof purchase.expires_in_seconds !== 'number')
) { ) {
return null; return null;
} }
@@ -159,6 +160,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle'); protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false); protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle'); protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
private readonly transferPrimaryCandidateReason = signal<PurchasePaymentCandidateReason | null>(
null,
);
protected readonly transferVerificationErrorTitle = computed(() =>
this.transferPrimaryCandidateReason() === 'exact_amount_near_dni'
? 'El DNI no corresponde con el de la transferencia'
: null,
);
protected readonly whatsappUrl = computed( protected readonly whatsappUrl = computed(
() => () =>
this.tenantService this.tenantService
@@ -326,7 +335,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
if (this.isStockReservationExpiredError(error)) { if (this.isStockReservationExpiredError(error)) {
this.showRequestError(error, 'La reserva de stock venció.'); this.showRequestError(error, 'La reserva de stock venció.');
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
this.createdPurchaseId.set(null); this.createdPurchaseId.set(null);
@@ -410,6 +419,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferDni.set(dni); this.transferDni.set(dni);
this.stopTransferPolling(); this.stopTransferPolling();
this.transferValidationStatus.set('idle'); this.transferValidationStatus.set('idle');
this.transferPrimaryCandidateReason.set(null);
this.isGeneratingIntent.set(true); this.isGeneratingIntent.set(true);
try { try {
const response = await this.checkoutService const response = await this.checkoutService
@@ -466,6 +476,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.stopTransferPolling(); this.stopTransferPolling();
this.hasSubmittedTransfer.set(true); this.hasSubmittedTransfer.set(true);
this.transferValidationStatus.set('checking'); this.transferValidationStatus.set('checking');
this.transferPrimaryCandidateReason.set(null);
this.transferPollingAttempts = 0; this.transferPollingAttempts = 0;
const runId = this.transferPollingRunId; const runId = this.transferPollingRunId;
@@ -480,6 +491,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
this.checkoutCountdownService.synchronize(purchase); this.checkoutCountdownService.synchronize(purchase);
this.captureTransferCandidateReason(purchase);
if (purchase.status === 'paid') { if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId); this.navigateToPurchaseStatus(purchaseId);
@@ -531,6 +543,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
this.checkoutCountdownService.synchronize(purchase); this.checkoutCountdownService.synchronize(purchase);
this.captureTransferCandidateReason(purchase);
if (purchase.status === 'paid') { if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId); this.navigateToPurchaseStatus(purchaseId);
@@ -562,6 +575,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.scheduleTransferPoll(runId); this.scheduleTransferPoll(runId);
} }
private captureTransferCandidateReason(purchase: PurchaseStatusResponse): void {
const reason = purchase.payment_verification?.primary?.reason;
if (reason) {
this.transferPrimaryCandidateReason.set(reason);
}
}
private stopTransferPolling(): void { private stopTransferPolling(): void {
this.transferPollingRunId += 1; this.transferPollingRunId += 1;

View File

@@ -53,6 +53,7 @@
[validationStatus]="transferValidationStatus()" [validationStatus]="transferValidationStatus()"
[paymentAmount]="qrPaymentAmount()" [paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()" [whatsappUrl]="whatsappUrl()"
[verificationErrorTitle]="transferVerificationErrorTitle()"
(copyTransferValue)="requestCopy($event.field, $event.value)" (copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)" (submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()" (completePurchase)="complete.emit()"

View File

@@ -33,6 +33,7 @@ export class CheckoutPaymentStepComponent {
readonly qrPaymentAmount = input<number>(0); readonly qrPaymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null); readonly whatsappUrl = input<string | null>(null);
readonly transferValidationStatus = input<TransferValidationStatus>('idle'); readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly transferVerificationErrorTitle = input<string | null>(null);
readonly paymentMethodChange = output<PaymentMethod>(); readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>(); readonly copyTransferValue = output<{ field: TransferField; value: string }>();

View File

@@ -3,6 +3,7 @@
<app-payment-verification-error <app-payment-verification-error
[paymentAmount]="paymentAmount()" [paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()" [whatsappUrl]="whatsappUrl()"
[title]="verificationErrorTitle()"
/> />
} @else { } @else {
<div class="dni-form-container"> <div class="dni-form-container">

View File

@@ -47,4 +47,23 @@ describe('CheckoutPaymentTransferComponent', () => {
expect(whatsapp).toBeDefined(); expect(whatsapp).toBeDefined();
expect(element.querySelector('.payment-verification')).toBeNull(); expect(element.querySelector('.payment-verification')).toBeNull();
}); });
it('shows a custom validation title for a near DNI candidate', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentTransferComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentTransferComponent);
fixture.componentRef.setInput('validationStatus', 'error');
fixture.componentRef.setInput(
'verificationErrorTitle',
'El DNI no corresponde con el de la transferencia',
);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'El DNI no corresponde con el de la transferencia',
);
expect(fixture.nativeElement.textContent).not.toContain('No pudimos verificar el pago de');
});
}); });

View File

@@ -41,6 +41,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
readonly validationStatus = input<TransferValidationStatus>('idle'); readonly validationStatus = input<TransferValidationStatus>('idle');
readonly paymentAmount = input<number>(0); readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null); readonly whatsappUrl = input<string | null>(null);
readonly verificationErrorTitle = input<string | null>(null);
readonly copyTransferValue = output<{ field: TransferField; value: string }>(); readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>(); readonly submitDni = output<string>();

View File

@@ -2,7 +2,7 @@
<div class="payment-timeout__icon" aria-hidden="true"> <div class="payment-timeout__icon" aria-hidden="true">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</div> </div>
<h4 class="payment-timeout__title">No pudimos verificar el pago de {{ formattedAmount() }}.</h4> <h4 class="payment-timeout__title">{{ displayTitle() }}</h4>
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p> <p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
@if (whatsappUrl()) { @if (whatsappUrl()) {
<app-button <app-button

View File

@@ -13,6 +13,7 @@ import { ButtonComponent } from '../../../../../../shared/components/button/butt
export class PaymentVerificationErrorComponent { export class PaymentVerificationErrorComponent {
readonly paymentAmount = input<number>(0); readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null); readonly whatsappUrl = input<string | null>(null);
readonly title = input<string | null>(null);
protected readonly formattedAmount = computed(() => protected readonly formattedAmount = computed(() =>
new Intl.NumberFormat('es-AR', { new Intl.NumberFormat('es-AR', {
@@ -21,6 +22,9 @@ export class PaymentVerificationErrorComponent {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(this.paymentAmount()), }).format(this.paymentAmount()),
); );
protected readonly displayTitle = computed(
() => this.title() ?? `No pudimos verificar el pago de ${this.formattedAmount()}.`,
);
protected openWhatsApp(): void { protected openWhatsApp(): void {
const url = this.whatsappUrl(); const url = this.whatsappUrl();

View File

@@ -17,7 +17,9 @@
<div class="status-content__section status-content__section--secondary"> <div class="status-content__section status-content__section--secondary">
@if (ticketsRoute()) { @if (ticketsRoute()) {
<p class="status-content__message">A continuación, vas a poder ver los tickets que debés presentar en el evento.</p> <p class="status-content__message">
A continuación, vas a poder ver los tickets que debés presentar en el evento.
</p>
<app-button <app-button
type="button" type="button"
@@ -29,7 +31,9 @@
<span>Mis tickets</span> <span>Mis tickets</span>
</app-button> </app-button>
} @else if (whatsappUrl()) { } @else if (whatsappUrl()) {
<p class="status-content__message">Comunicate con nosotros para coordinar el env&iacute;o.</p> <p class="status-content__message">
Comunicate con nosotros para coordinar el env&iacute;o.
</p>
<app-button <app-button
type="button" type="button"
@@ -51,13 +55,21 @@
<i class="fa-solid fa-clock"></i> <i class="fa-solid fa-clock"></i>
</div> </div>
<h2 class="status-content__title">ESTAMOS VERIFICANDO TU PAGO</h2> <h2 class="status-content__title">ESTAMOS VERIFICANDO TU PAGO</h2>
<p class="status-content__subtitle">Tu compra ya fue registrada y estamos esperando la confirmaci&oacute;n del pago.</p> <p class="status-content__subtitle">
Tu compra ya fue registrada y estamos esperando la confirmaci&oacute;n del pago.
</p>
</div> </div>
<hr class="status-content__divider" /> <hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary"> <div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Te avisaremos cuando el pago sea confirmado.</p> @if (paymentIssueMessage()) {
<p class="status-content__message">
{{ paymentIssueMessage() }} Estamos revisando el pago.
</p>
} @else {
<p class="status-content__message">Te avisaremos cuando el pago sea confirmado.</p>
}
</div> </div>
} @else if (status() === 'expired') { } @else if (status() === 'expired') {
<div class="status-content__section status-content__section--primary"> <div class="status-content__section status-content__section--primary">
@@ -65,13 +77,17 @@
<i class="fa-solid fa-clock"></i> <i class="fa-solid fa-clock"></i>
</div> </div>
<h2 class="status-content__title">LA COMPRA VENCI&Oacute;</h2> <h2 class="status-content__title">LA COMPRA VENCI&Oacute;</h2>
<p class="status-content__subtitle">El plazo de pago termin&oacute; y liberamos el stock reservado.</p> <p class="status-content__subtitle">
El plazo de pago termin&oacute; y liberamos el stock reservado.
</p>
</div> </div>
<hr class="status-content__divider" /> <hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary"> <div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Pod&eacute;s volver a la tienda e iniciar una nueva compra.</p> <p class="status-content__message">
Pod&eacute;s volver a la tienda e iniciar una nueva compra.
</p>
</div> </div>
} @else if (status() === 'rejected') { } @else if (status() === 'rejected') {
<div class="status-content__section status-content__section--primary"> <div class="status-content__section status-content__section--primary">
@@ -79,7 +95,9 @@
<i class="fa-solid fa-triangle-exclamation"></i> <i class="fa-solid fa-triangle-exclamation"></i>
</div> </div>
<h2 class="status-content__title">NO PUDIMOS CONFIRMAR EL PAGO</h2> <h2 class="status-content__title">NO PUDIMOS CONFIRMAR EL PAGO</h2>
<p class="status-content__subtitle">Revis&aacute; el medio de pago o comunicate con nosotros para continuar.</p> <p class="status-content__subtitle">
Revis&aacute; el medio de pago o comunicate con nosotros para continuar.
</p>
</div> </div>
<hr class="status-content__divider" /> <hr class="status-content__divider" />
@@ -114,7 +132,9 @@
<hr class="status-content__divider" /> <hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary"> <div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Volv&eacute; a ingresar m&aacute;s tarde. Si el problema sigue, comunicate con nosotros.</p> <p class="status-content__message">
Volv&eacute; a ingresar m&aacute;s tarde. Si el problema sigue, comunicate con nosotros.
</p>
@if (whatsappUrl()) { @if (whatsappUrl()) {
<div class="status-content__actions"> <div class="status-content__actions">

View File

@@ -67,9 +67,13 @@ describe('PurchaseStatusPageComponent', () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
async function render(hasGeneratedTickets: boolean, forcedStatus?: string) { async function render(
hasGeneratedTickets: boolean,
forcedStatus?: string,
purchaseResponse = purchase(hasGeneratedTickets),
) {
const checkoutService = { const checkoutService = {
getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)), getPurchase: vi.fn().mockResolvedValue(purchaseResponse),
withCustomLoading() { withCustomLoading() {
return this; return this;
}, },
@@ -91,9 +95,7 @@ describe('PurchaseStatusPageComponent', () => {
useValue: { useValue: {
snapshot: { snapshot: {
paramMap: convertToParamMap({ id: '42' }), paramMap: convertToParamMap({ id: '42' }),
queryParamMap: convertToParamMap( queryParamMap: convertToParamMap(forcedStatus ? { status: forcedStatus } : {}),
forcedStatus ? { status: forcedStatus } : {},
),
}, },
}, },
}, },
@@ -159,6 +161,30 @@ describe('PurchaseStatusPageComponent', () => {
expect(checkoutService.getPurchase).not.toHaveBeenCalled(); expect(checkoutService.getPurchase).not.toHaveBeenCalled();
}); });
it('shows the primary transfer candidate issue while the purchase is in review', async () => {
const { element } = await render(false, undefined, {
status: 'in_review',
payment_verification: {
status: 'candidate',
candidate_count: 2,
primary: {
reason: 'exact_dni_near_amount',
dni_distance: 0,
payment_amount: '49000.00',
purchase_amount: '50000.00',
amount_difference: '1000.00',
confidence: 'high',
detected_at: '2026-08-27T18:00:00-03:00',
},
reasons: ['exact_dni_near_amount', 'exact_amount_near_dni'],
},
} as PurchaseDetailResponse);
expect(element.textContent).toContain('Encontramos 2 transferencias posibles.');
expect(element.textContent).toMatch(/diferencia de \$\s*1\.000/);
expect(element.textContent).toContain('Estamos revisando el pago.');
});
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();

View File

@@ -13,6 +13,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { import {
CheckoutService, CheckoutService,
PurchasePaymentVerificationResponse,
PurchaseStatusResponse, PurchaseStatusResponse,
} from '../../../../core/services/checkout.service'; } from '../../../../core/services/checkout.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
@@ -48,6 +49,7 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
protected readonly isLoading = signal(true); protected readonly isLoading = signal(true);
protected readonly status = signal<PurchaseStatusView>('pending'); protected readonly status = signal<PurchaseStatusView>('pending');
protected readonly hasGeneratedTickets = signal(false); protected readonly hasGeneratedTickets = signal(false);
protected readonly paymentIssueMessage = signal<string | null>(null);
protected readonly ticketsRoute = computed(() => { protected readonly ticketsRoute = computed(() => {
if (!this.hasGeneratedTickets()) { if (!this.hasGeneratedTickets()) {
return null; return null;
@@ -105,6 +107,7 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
const status = this.resolveStatus(purchase); const status = this.resolveStatus(purchase);
this.status.set(status); this.status.set(status);
this.hasGeneratedTickets.set(purchase.has_generated_tickets === true); this.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
this.paymentIssueMessage.set(this.resolvePaymentIssueMessage(purchase.payment_verification));
if (status === 'approved') { if (status === 'approved') {
this.cartService.clearCart(); this.cartService.clearCart();
@@ -169,6 +172,43 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
return 'pending'; return 'pending';
} }
private resolvePaymentIssueMessage(
verification?: PurchasePaymentVerificationResponse,
): string | null {
const primary = verification?.primary;
if (!primary) {
return null;
}
const primaryMessage = (() => {
switch (primary.reason) {
case 'ambiguous_exact_match':
return 'Encontramos una transferencia que también coincide con otra compra.';
case 'exact_dni_near_amount':
return `El DNI coincide, pero el monto transferido tiene una diferencia de ${this.formatCurrency(primary.amount_difference)}.`;
case 'exact_amount_near_dni':
return primary.dni_distance === null
? 'El monto coincide, pero el DNI del pagador es diferente.'
: `El monto coincide, pero el DNI del pagador presenta ${primary.dni_distance} ${primary.dni_distance === 1 ? 'diferencia' : 'diferencias'} de escritura.`;
}
})();
if (verification.candidate_count > 1) {
return `Encontramos ${verification.candidate_count} transferencias posibles. ${primaryMessage}`;
}
return primaryMessage;
}
private formatCurrency(amount: string): string {
return new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: 2,
}).format(Number(amount));
}
private isPurchaseExpiredError(error: unknown): boolean { private isPurchaseExpiredError(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('error' in error)) { if (typeof error !== 'object' || error === null || !('error' in error)) {
return false; return false;

View File

@@ -212,7 +212,7 @@ export class SearchPageComponent {
this.toastService.danger(message); this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -224,7 +224,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.toastService.danger(error.error.message); this.toastService.danger(error.error.message);
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
return; return;

View File

@@ -330,7 +330,7 @@ export class CartComponent {
return; return;
} }
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -17,7 +17,7 @@
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 30px 20px; padding: 30px 20px;
overflow: hidden; overflow: visible;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 7px; border-radius: 7px;

View File

@@ -6,6 +6,15 @@
@import '../node_modules/bootstrap/scss/bootstrap'; @import '../node_modules/bootstrap/scss/bootstrap';
html,
body,
app-root {
width: 100%;
min-height: 100%;
margin: 0;
background: #f5f5f5;
}
:root, :root,
app-root { app-root {
--border-color: #dddddd; --border-color: #dddddd;