feat(checkout): show transfer candidate issue
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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ío.</p>
|
||||
<p class="status-content__message">
|
||||
Comunicate con nosotros para coordinar el enví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ón del pago.</p>
|
||||
<p class="status-content__subtitle">
|
||||
Tu compra ya fue registrada y estamos esperando la confirmació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Ó</h2>
|
||||
<p class="status-content__subtitle">El plazo de pago terminó y liberamos el stock reservado.</p>
|
||||
<p class="status-content__subtitle">
|
||||
El plazo de pago terminó 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és volver a la tienda e iniciar una nueva compra.</p>
|
||||
<p class="status-content__message">
|
||||
Podé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á el medio de pago o comunicate con nosotros para continuar.</p>
|
||||
<p class="status-content__subtitle">
|
||||
Revisá 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é a ingresar más tarde. Si el problema sigue, comunicate con nosotros.</p>
|
||||
<p class="status-content__message">
|
||||
Volvé a ingresar más tarde. Si el problema sigue, comunicate con nosotros.
|
||||
</p>
|
||||
|
||||
@if (whatsappUrl()) {
|
||||
<div class="status-content__actions">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user