From eb524c7d99bfe7677fa2ea48a3d0e46b5c4e8730 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 16:55:21 -0300 Subject: [PATCH] feat(checkout): show transfer candidate reason after polling --- .../checkout-page.component.html | 1 + .../checkout-page.component.spec.ts | 46 +++++++++++++++---- .../checkout-page/checkout-page.component.ts | 27 +++++++++-- .../checkout-payment-step.component.html | 1 + .../checkout-payment-step.component.ts | 1 + .../checkout-payment-transfer.component.html | 1 + ...heckout-payment-transfer.component.spec.ts | 19 ++++++++ .../checkout-payment-transfer.component.ts | 1 + .../payment-verification-error.component.html | 2 +- .../payment-verification-error.component.ts | 4 ++ 10 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.html b/src/app/features/store/pages/checkout-page/checkout-page.component.html index 336fc3d..27c6d57 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.html @@ -34,6 +34,7 @@ [qrPaymentAmount]="cartTotal()" [whatsappUrl]="whatsappUrl()" [transferValidationStatus]="transferValidationStatus()" + [transferVerificationErrorTitle]="transferVerificationErrorTitle()" (paymentMethodChange)="selectPaymentMethod($event)" (copyTransferValue)="copyTransferValue($event.field, $event.value)" (cancelStep)="onCancel()" diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts index c93fd86..ace59a0 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts @@ -225,7 +225,7 @@ describe('CheckoutPageComponent payment validation', () => { 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' }); const { component } = createComponent(); component.selectedPaymentMethod.set('transfer'); @@ -235,14 +235,12 @@ describe('CheckoutPageComponent payment validation', () => { expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25); expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); - for (let attempt = 1; attempt <= 3; attempt += 1) { - await vi.advanceTimersByTimeAsync(3_000); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(attempt); - expect(component.transferValidationStatus()).toBe('checking'); - } + await vi.advanceTimersByTimeAsync(57_000); + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(19); + expect(component.transferValidationStatus()).toBe('checking'); await vi.advanceTimersByTimeAsync(3_000); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(20); expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25); expect(component.transferValidationStatus()).toBe('error'); expect(routerStub.navigate).not.toHaveBeenCalled(); @@ -286,13 +284,43 @@ describe('CheckoutPageComponent payment validation', () => { component.selectedPaymentMethod.set('transfer'); 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(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 () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error')); diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index f580edd..bd8e320 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -16,8 +16,10 @@ import { firstValueFrom, startWith } from 'rxjs'; import { TenantService } from '../../../../core/services/tenant.service'; import { CheckoutService, + PurchasePaymentCandidateReason, PurchaseDetailItemResponse, PurchaseDetailResponse, + PurchaseStatusResponse, } from '../../../../core/services/checkout.service'; import { AuthService } from '../../../../core/services/auth/auth.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 qrPollingMaxAttempts = 120; private readonly transferPollingIntervalMs = 3_000; - private readonly transferPollingMaxAttempts = 209; + private readonly transferPollingMaxAttempts = 20; private qrPollingTimeoutId: ReturnType | null = null; private qrPollingAttempts = 0; @@ -102,8 +104,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { if ( !purchase || - (typeof purchase.expires_at !== 'string' && - typeof purchase.expires_in_seconds !== 'number') + (typeof purchase.expires_at !== 'string' && typeof purchase.expires_in_seconds !== 'number') ) { return null; } @@ -159,6 +160,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { protected readonly qrPaymentStatus = signal('idle'); protected readonly isCheckingQrPayment = signal(false); protected readonly transferValidationStatus = signal('idle'); + private readonly transferPrimaryCandidateReason = signal( + 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( () => this.tenantService @@ -410,6 +419,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.transferDni.set(dni); this.stopTransferPolling(); this.transferValidationStatus.set('idle'); + this.transferPrimaryCandidateReason.set(null); this.isGeneratingIntent.set(true); try { const response = await this.checkoutService @@ -466,6 +476,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.stopTransferPolling(); this.hasSubmittedTransfer.set(true); this.transferValidationStatus.set('checking'); + this.transferPrimaryCandidateReason.set(null); this.transferPollingAttempts = 0; const runId = this.transferPollingRunId; @@ -480,6 +491,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } this.checkoutCountdownService.synchronize(purchase); + this.captureTransferCandidateReason(purchase); if (purchase.status === 'paid') { this.navigateToPurchaseStatus(purchaseId); @@ -531,6 +543,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } this.checkoutCountdownService.synchronize(purchase); + this.captureTransferCandidateReason(purchase); if (purchase.status === 'paid') { this.navigateToPurchaseStatus(purchaseId); @@ -562,6 +575,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.scheduleTransferPoll(runId); } + private captureTransferCandidateReason(purchase: PurchaseStatusResponse): void { + const reason = purchase.payment_verification?.primary?.reason; + + if (reason) { + this.transferPrimaryCandidateReason.set(reason); + } + } + private stopTransferPolling(): void { this.transferPollingRunId += 1; diff --git a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html index 7970925..553115f 100644 --- a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html @@ -53,6 +53,7 @@ [validationStatus]="transferValidationStatus()" [paymentAmount]="qrPaymentAmount()" [whatsappUrl]="whatsappUrl()" + [verificationErrorTitle]="transferVerificationErrorTitle()" (copyTransferValue)="requestCopy($event.field, $event.value)" (submitDni)="generateTransferIntent.emit($event)" (completePurchase)="complete.emit()" diff --git a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts index e43cb1a..3b77621 100644 --- a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts @@ -33,6 +33,7 @@ export class CheckoutPaymentStepComponent { readonly qrPaymentAmount = input(0); readonly whatsappUrl = input(null); readonly transferValidationStatus = input('idle'); + readonly transferVerificationErrorTitle = input(null); readonly paymentMethodChange = output(); readonly copyTransferValue = output<{ field: TransferField; value: string }>(); diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html index 46167a6..3fdc97d 100644 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html +++ b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html @@ -3,6 +3,7 @@ } @else {
diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts index ec21f16..d8967f9 100644 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts @@ -47,4 +47,23 @@ describe('CheckoutPaymentTransferComponent', () => { expect(whatsapp).toBeDefined(); 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'); + }); }); diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.ts b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.ts index 29aed7b..5ebeb56 100644 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.ts +++ b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.ts @@ -41,6 +41,7 @@ export class CheckoutPaymentTransferComponent implements OnInit { readonly validationStatus = input('idle'); readonly paymentAmount = input(0); readonly whatsappUrl = input(null); + readonly verificationErrorTitle = input(null); readonly copyTransferValue = output<{ field: TransferField; value: string }>(); readonly submitDni = output(); diff --git a/src/app/features/store/pages/checkout-page/components/payment-verification-error/payment-verification-error.component.html b/src/app/features/store/pages/checkout-page/components/payment-verification-error/payment-verification-error.component.html index 64e0374..fcf033f 100644 --- a/src/app/features/store/pages/checkout-page/components/payment-verification-error/payment-verification-error.component.html +++ b/src/app/features/store/pages/checkout-page/components/payment-verification-error/payment-verification-error.component.html @@ -2,7 +2,7 @@ -

No pudimos verificar el pago de {{ formattedAmount() }}.

+

{{ displayTitle() }}

Por favor contactate con nosotros para resolverlo.

@if (whatsappUrl()) { (0); readonly whatsappUrl = input(null); + readonly title = input(null); protected readonly formattedAmount = computed(() => new Intl.NumberFormat('es-AR', { @@ -21,6 +22,9 @@ export class PaymentVerificationErrorComponent { maximumFractionDigits: 0, }).format(this.paymentAmount()), ); + protected readonly displayTitle = computed( + () => this.title() ?? `No pudimos verificar el pago de ${this.formattedAmount()}.`, + ); protected openWhatsApp(): void { const url = this.whatsappUrl();