feat(checkout): show transfer candidate reason after polling
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
[qrPaymentAmount]="cartTotal()"
|
[qrPaymentAmount]="cartTotal()"
|
||||||
[whatsappUrl]="whatsappUrl()"
|
[whatsappUrl]="whatsappUrl()"
|
||||||
[transferValidationStatus]="transferValidationStatus()"
|
[transferValidationStatus]="transferValidationStatus()"
|
||||||
|
[transferVerificationErrorTitle]="transferVerificationErrorTitle()"
|
||||||
(paymentMethodChange)="selectPaymentMethod($event)"
|
(paymentMethodChange)="selectPaymentMethod($event)"
|
||||||
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
|
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
|
||||||
(cancelStep)="onCancel()"
|
(cancelStep)="onCancel()"
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
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' });
|
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' });
|
||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
component.selectedPaymentMethod.set('transfer');
|
component.selectedPaymentMethod.set('transfer');
|
||||||
@@ -235,14 +235,12 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
|
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
|
||||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
await vi.advanceTimersByTimeAsync(57_000);
|
||||||
await vi.advanceTimersByTimeAsync(3_000);
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(19);
|
||||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(attempt);
|
expect(component.transferValidationStatus()).toBe('checking');
|
||||||
expect(component.transferValidationStatus()).toBe('checking');
|
|
||||||
}
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(3_000);
|
await vi.advanceTimersByTimeAsync(3_000);
|
||||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(20);
|
||||||
expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25);
|
expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25);
|
||||||
expect(component.transferValidationStatus()).toBe('error');
|
expect(component.transferValidationStatus()).toBe('error');
|
||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
@@ -286,13 +284,43 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
component.selectedPaymentMethod.set('transfer');
|
component.selectedPaymentMethod.set('transfer');
|
||||||
|
|
||||||
await component.onComplete();
|
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(component.transferValidationStatus()).toBe('error');
|
||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
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 () => {
|
it('does not poll when submitting a transfer for review fails', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
|
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { firstValueFrom, startWith } from 'rxjs';
|
|||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
import {
|
import {
|
||||||
CheckoutService,
|
CheckoutService,
|
||||||
|
PurchasePaymentCandidateReason,
|
||||||
PurchaseDetailItemResponse,
|
PurchaseDetailItemResponse,
|
||||||
PurchaseDetailResponse,
|
PurchaseDetailResponse,
|
||||||
|
PurchaseStatusResponse,
|
||||||
} from '../../../../core/services/checkout.service';
|
} from '../../../../core/services/checkout.service';
|
||||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.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 qrPollingIntervalMs = 5_000;
|
||||||
private readonly qrPollingMaxAttempts = 120;
|
private readonly qrPollingMaxAttempts = 120;
|
||||||
private readonly transferPollingIntervalMs = 3_000;
|
private readonly transferPollingIntervalMs = 3_000;
|
||||||
private readonly transferPollingMaxAttempts = 209;
|
private readonly transferPollingMaxAttempts = 20;
|
||||||
|
|
||||||
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
private qrPollingAttempts = 0;
|
private qrPollingAttempts = 0;
|
||||||
@@ -102,8 +104,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!purchase ||
|
!purchase ||
|
||||||
(typeof purchase.expires_at !== 'string' &&
|
(typeof purchase.expires_at !== 'string' && typeof purchase.expires_in_seconds !== 'number')
|
||||||
typeof purchase.expires_in_seconds !== 'number')
|
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -159,6 +160,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
|
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
|
||||||
protected readonly isCheckingQrPayment = signal(false);
|
protected readonly isCheckingQrPayment = signal(false);
|
||||||
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
|
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
|
||||||
|
private readonly transferPrimaryCandidateReason = signal<PurchasePaymentCandidateReason | null>(
|
||||||
|
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(
|
protected readonly whatsappUrl = computed(
|
||||||
() =>
|
() =>
|
||||||
this.tenantService
|
this.tenantService
|
||||||
@@ -410,6 +419,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
this.transferDni.set(dni);
|
this.transferDni.set(dni);
|
||||||
this.stopTransferPolling();
|
this.stopTransferPolling();
|
||||||
this.transferValidationStatus.set('idle');
|
this.transferValidationStatus.set('idle');
|
||||||
|
this.transferPrimaryCandidateReason.set(null);
|
||||||
this.isGeneratingIntent.set(true);
|
this.isGeneratingIntent.set(true);
|
||||||
try {
|
try {
|
||||||
const response = await this.checkoutService
|
const response = await this.checkoutService
|
||||||
@@ -466,6 +476,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
this.stopTransferPolling();
|
this.stopTransferPolling();
|
||||||
this.hasSubmittedTransfer.set(true);
|
this.hasSubmittedTransfer.set(true);
|
||||||
this.transferValidationStatus.set('checking');
|
this.transferValidationStatus.set('checking');
|
||||||
|
this.transferPrimaryCandidateReason.set(null);
|
||||||
this.transferPollingAttempts = 0;
|
this.transferPollingAttempts = 0;
|
||||||
|
|
||||||
const runId = this.transferPollingRunId;
|
const runId = this.transferPollingRunId;
|
||||||
@@ -480,6 +491,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.checkoutCountdownService.synchronize(purchase);
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
|
this.captureTransferCandidateReason(purchase);
|
||||||
|
|
||||||
if (purchase.status === 'paid') {
|
if (purchase.status === 'paid') {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
@@ -531,6 +543,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.checkoutCountdownService.synchronize(purchase);
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
|
this.captureTransferCandidateReason(purchase);
|
||||||
|
|
||||||
if (purchase.status === 'paid') {
|
if (purchase.status === 'paid') {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
@@ -562,6 +575,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
this.scheduleTransferPoll(runId);
|
this.scheduleTransferPoll(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private captureTransferCandidateReason(purchase: PurchaseStatusResponse): void {
|
||||||
|
const reason = purchase.payment_verification?.primary?.reason;
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
this.transferPrimaryCandidateReason.set(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private stopTransferPolling(): void {
|
private stopTransferPolling(): void {
|
||||||
this.transferPollingRunId += 1;
|
this.transferPollingRunId += 1;
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
[validationStatus]="transferValidationStatus()"
|
[validationStatus]="transferValidationStatus()"
|
||||||
[paymentAmount]="qrPaymentAmount()"
|
[paymentAmount]="qrPaymentAmount()"
|
||||||
[whatsappUrl]="whatsappUrl()"
|
[whatsappUrl]="whatsappUrl()"
|
||||||
|
[verificationErrorTitle]="transferVerificationErrorTitle()"
|
||||||
(copyTransferValue)="requestCopy($event.field, $event.value)"
|
(copyTransferValue)="requestCopy($event.field, $event.value)"
|
||||||
(submitDni)="generateTransferIntent.emit($event)"
|
(submitDni)="generateTransferIntent.emit($event)"
|
||||||
(completePurchase)="complete.emit()"
|
(completePurchase)="complete.emit()"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class CheckoutPaymentStepComponent {
|
|||||||
readonly qrPaymentAmount = input<number>(0);
|
readonly qrPaymentAmount = input<number>(0);
|
||||||
readonly whatsappUrl = input<string | null>(null);
|
readonly whatsappUrl = input<string | null>(null);
|
||||||
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
|
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
|
||||||
|
readonly transferVerificationErrorTitle = input<string | null>(null);
|
||||||
|
|
||||||
readonly paymentMethodChange = output<PaymentMethod>();
|
readonly paymentMethodChange = output<PaymentMethod>();
|
||||||
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<app-payment-verification-error
|
<app-payment-verification-error
|
||||||
[paymentAmount]="paymentAmount()"
|
[paymentAmount]="paymentAmount()"
|
||||||
[whatsappUrl]="whatsappUrl()"
|
[whatsappUrl]="whatsappUrl()"
|
||||||
|
[title]="verificationErrorTitle()"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="dni-form-container">
|
<div class="dni-form-container">
|
||||||
|
|||||||
@@ -47,4 +47,23 @@ describe('CheckoutPaymentTransferComponent', () => {
|
|||||||
expect(whatsapp).toBeDefined();
|
expect(whatsapp).toBeDefined();
|
||||||
expect(element.querySelector('.payment-verification')).toBeNull();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
|
|||||||
readonly validationStatus = input<TransferValidationStatus>('idle');
|
readonly validationStatus = input<TransferValidationStatus>('idle');
|
||||||
readonly paymentAmount = input<number>(0);
|
readonly paymentAmount = input<number>(0);
|
||||||
readonly whatsappUrl = input<string | null>(null);
|
readonly whatsappUrl = input<string | null>(null);
|
||||||
|
readonly verificationErrorTitle = input<string | null>(null);
|
||||||
|
|
||||||
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
|
||||||
readonly submitDni = output<string>();
|
readonly submitDni = output<string>();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="payment-timeout__icon" aria-hidden="true">
|
<div class="payment-timeout__icon" aria-hidden="true">
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="payment-timeout__title">No pudimos verificar el pago de {{ formattedAmount() }}.</h4>
|
<h4 class="payment-timeout__title">{{ displayTitle() }}</h4>
|
||||||
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
|
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
|
||||||
@if (whatsappUrl()) {
|
@if (whatsappUrl()) {
|
||||||
<app-button
|
<app-button
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ButtonComponent } from '../../../../../../shared/components/button/butt
|
|||||||
export class PaymentVerificationErrorComponent {
|
export class PaymentVerificationErrorComponent {
|
||||||
readonly paymentAmount = input<number>(0);
|
readonly paymentAmount = input<number>(0);
|
||||||
readonly whatsappUrl = input<string | null>(null);
|
readonly whatsappUrl = input<string | null>(null);
|
||||||
|
readonly title = input<string | null>(null);
|
||||||
|
|
||||||
protected readonly formattedAmount = computed(() =>
|
protected readonly formattedAmount = computed(() =>
|
||||||
new Intl.NumberFormat('es-AR', {
|
new Intl.NumberFormat('es-AR', {
|
||||||
@@ -21,6 +22,9 @@ export class PaymentVerificationErrorComponent {
|
|||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(this.paymentAmount()),
|
}).format(this.paymentAmount()),
|
||||||
);
|
);
|
||||||
|
protected readonly displayTitle = computed(
|
||||||
|
() => this.title() ?? `No pudimos verificar el pago de ${this.formattedAmount()}.`,
|
||||||
|
);
|
||||||
|
|
||||||
protected openWhatsApp(): void {
|
protected openWhatsApp(): void {
|
||||||
const url = this.whatsappUrl();
|
const url = this.whatsappUrl();
|
||||||
|
|||||||
Reference in New Issue
Block a user