Compare commits
5 Commits
develop
...
homo_exper
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9adc0494 | |||
| eb524c7d99 | |||
| 65e8436ae1 | |||
| 07500f699d | |||
| 4997665da6 |
@@ -74,6 +74,29 @@ 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_near_dni';
|
||||||
|
|
||||||
|
export interface PurchasePaymentCandidatePrimaryResponse {
|
||||||
|
reason: PurchasePaymentCandidateReason;
|
||||||
|
dni_distance: number | null;
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,30 @@ 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',
|
||||||
|
dni_distance: 0,
|
||||||
|
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_near_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,43 @@ 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_near_dni':
|
||||||
|
return primary.dni_distance === null
|
||||||
|
? 'El monto coincide, pero el DNI del pagador es diferente.'
|
||||||
|
: `El monto coincide, pero el DNI del pagador presenta ${primary.dni_distance} ${primary.dni_distance === 1 ? 'diferencia' : 'diferencias'} de escritura.`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
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