From 4997665da605a36b6a7850a8801160f45114fdf3 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 15:38:23 -0300 Subject: [PATCH] feat(checkout): show transfer candidate issue --- src/app/core/services/checkout.service.ts | 22 +++++++++++ .../purchase-status-page.component.html | 36 ++++++++++++++---- .../purchase-status-page.component.spec.ts | 35 ++++++++++++++--- .../purchase-status-page.component.ts | 38 +++++++++++++++++++ 4 files changed, 118 insertions(+), 13 deletions(-) diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index ace291b..ef214b5 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -74,6 +74,28 @@ export interface PurchaseStatusResponse { expires_at: string | null; expires_in_seconds: number | null; server_time: string; + payment_verification?: PurchasePaymentVerificationResponse; +} + +export type PurchasePaymentCandidateReason = + | 'ambiguous_exact_match' + | 'exact_dni_near_amount' + | 'exact_amount_different_dni'; + +export interface PurchasePaymentCandidatePrimaryResponse { + reason: PurchasePaymentCandidateReason; + 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 { diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html index 9018c1f..0c3b067 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html @@ -17,7 +17,9 @@
@if (ticketsRoute()) { -

A continuación, vas a poder ver los tickets que debés presentar en el evento.

+

+ A continuación, vas a poder ver los tickets que debés presentar en el evento. +

Mis tickets } @else if (whatsappUrl()) { -

Comunicate con nosotros para coordinar el envío.

+

+ Comunicate con nosotros para coordinar el envío. +

ESTAMOS VERIFICANDO TU PAGO

-

Tu compra ya fue registrada y estamos esperando la confirmación del pago.

+

+ Tu compra ya fue registrada y estamos esperando la confirmación del pago. +


-

Te avisaremos cuando el pago sea confirmado.

+ @if (paymentIssueMessage()) { +

+ {{ paymentIssueMessage() }} Estamos revisando el pago. +

+ } @else { +

Te avisaremos cuando el pago sea confirmado.

+ }
} @else if (status() === 'expired') {
@@ -65,13 +77,17 @@

LA COMPRA VENCIÓ

-

El plazo de pago terminó y liberamos el stock reservado.

+

+ El plazo de pago terminó y liberamos el stock reservado. +


-

Podés volver a la tienda e iniciar una nueva compra.

+

+ Podés volver a la tienda e iniciar una nueva compra. +

} @else if (status() === 'rejected') {
@@ -79,7 +95,9 @@

NO PUDIMOS CONFIRMAR EL PAGO

-

Revisá el medio de pago o comunicate con nosotros para continuar.

+

+ Revisá el medio de pago o comunicate con nosotros para continuar. +


@@ -114,7 +132,9 @@
-

Volvé a ingresar más tarde. Si el problema sigue, comunicate con nosotros.

+

+ Volvé a ingresar más tarde. Si el problema sigue, comunicate con nosotros. +

@if (whatsappUrl()) {
diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts index 6dcf23c..1b8fcdc 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts @@ -67,9 +67,13 @@ describe('PurchaseStatusPageComponent', () => { TestBed.resetTestingModule(); }); - async function render(hasGeneratedTickets: boolean, forcedStatus?: string) { + async function render( + hasGeneratedTickets: boolean, + forcedStatus?: string, + purchaseResponse = purchase(hasGeneratedTickets), + ) { const checkoutService = { - getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)), + getPurchase: vi.fn().mockResolvedValue(purchaseResponse), withCustomLoading() { return this; }, @@ -91,9 +95,7 @@ describe('PurchaseStatusPageComponent', () => { useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }), - queryParamMap: convertToParamMap( - forcedStatus ? { status: forcedStatus } : {}, - ), + queryParamMap: convertToParamMap(forcedStatus ? { status: forcedStatus } : {}), }, }, }, @@ -159,6 +161,29 @@ describe('PurchaseStatusPageComponent', () => { 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', + 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_different_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 () => { vi.useFakeTimers(); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts index f04a492..154381a 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts @@ -13,6 +13,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { CheckoutService, + PurchasePaymentVerificationResponse, PurchaseStatusResponse, } from '../../../../core/services/checkout.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 status = signal('pending'); protected readonly hasGeneratedTickets = signal(false); + protected readonly paymentIssueMessage = signal(null); protected readonly ticketsRoute = computed(() => { if (!this.hasGeneratedTickets()) { return null; @@ -105,6 +107,7 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy { const status = this.resolveStatus(purchase); this.status.set(status); this.hasGeneratedTickets.set(purchase.has_generated_tickets === true); + this.paymentIssueMessage.set(this.resolvePaymentIssueMessage(purchase.payment_verification)); if (status === 'approved') { this.cartService.clearCart(); @@ -169,6 +172,41 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy { 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_different_dni': + return 'El monto coincide, pero el DNI del pagador es diferente.'; + } + })(); + + 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 { if (typeof error !== 'object' || error === null || !('error' in error)) { return false;