feat(checkout): show transfer candidate issue

This commit is contained in:
2026-08-27 15:38:23 -03:00
parent dbfadf417a
commit 4997665da6
4 changed files with 118 additions and 13 deletions

View File

@@ -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 {

View File

@@ -17,7 +17,9 @@
<div class="status-content__section status-content__section--secondary">
@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
type="button"
@@ -29,7 +31,9 @@
<span>Mis tickets</span>
</app-button>
} @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
type="button"
@@ -51,13 +55,21 @@
<i class="fa-solid fa-clock"></i>
</div>
<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>
<hr class="status-content__divider" />
<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>
} @else if (status() === 'expired') {
<div class="status-content__section status-content__section--primary">
@@ -65,13 +77,17 @@
<i class="fa-solid fa-clock"></i>
</div>
<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>
<hr class="status-content__divider" />
<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>
} @else if (status() === 'rejected') {
<div class="status-content__section status-content__section--primary">
@@ -79,7 +95,9 @@
<i class="fa-solid fa-triangle-exclamation"></i>
</div>
<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>
<hr class="status-content__divider" />
@@ -114,7 +132,9 @@
<hr class="status-content__divider" />
<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()) {
<div class="status-content__actions">

View File

@@ -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();

View File

@@ -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<PurchaseStatusView>('pending');
protected readonly hasGeneratedTickets = signal(false);
protected readonly paymentIssueMessage = signal<string | null>(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;