feat(checkout): show transfer candidate issue
This commit is contained in:
@@ -74,6 +74,28 @@ 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_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 {
|
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
|
||||||
|
|||||||
@@ -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ío.</p>
|
<p class="status-content__message">
|
||||||
|
Comunicate con nosotros para coordinar el enví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ón del pago.</p>
|
<p class="status-content__subtitle">
|
||||||
|
Tu compra ya fue registrada y estamos esperando la confirmació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Ó</h2>
|
<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>
|
</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é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>
|
</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á 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>
|
</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é 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()) {
|
@if (whatsappUrl()) {
|
||||||
<div class="status-content__actions">
|
<div class="status-content__actions">
|
||||||
|
|||||||
@@ -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,29 @@ 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',
|
||||||
|
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 () => {
|
it('polls every five seconds while the payment is pending and shows the confirmation', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|||||||
@@ -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,41 @@ 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_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 {
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user