feat(checkout): update payment validation logic and enhance UI feedback for transfer payments
This commit is contained in:
@@ -21,7 +21,6 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
cancelPurchase: ReturnType<typeof vi.fn>;
|
cancelPurchase: ReturnType<typeof vi.fn>;
|
||||||
generatePaymentIntent: ReturnType<typeof vi.fn>;
|
generatePaymentIntent: ReturnType<typeof vi.fn>;
|
||||||
getPurchase: ReturnType<typeof vi.fn>;
|
getPurchase: ReturnType<typeof vi.fn>;
|
||||||
submitPurchaseForReview: ReturnType<typeof vi.fn>;
|
|
||||||
withCustomLoading: ReturnType<typeof vi.fn>;
|
withCustomLoading: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let cartServiceStub: {
|
let cartServiceStub: {
|
||||||
@@ -61,7 +60,6 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
qr_data: { qr_code: 'qr-value' },
|
qr_data: { qr_code: 'qr-value' },
|
||||||
}),
|
}),
|
||||||
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
|
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
|
||||||
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
|
|
||||||
withCustomLoading: vi.fn(),
|
withCustomLoading: vi.fn(),
|
||||||
};
|
};
|
||||||
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
|
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
|
||||||
@@ -196,38 +194,36 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks a transfer once and redirects to purchase status while pending', async () => {
|
it('checks the purchase detail once when the transfer was made', async () => {
|
||||||
|
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' });
|
||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
component.selectedPaymentMethod.set('transfer');
|
component.selectedPaymentMethod.set('transfer');
|
||||||
|
|
||||||
await component.onComplete();
|
await component.onComplete();
|
||||||
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
||||||
expect(component.transferValidationStatus()).toBe('pending');
|
expect(component.transferValidationStatus()).toBe('error');
|
||||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(30_000);
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await component.onComplete();
|
|
||||||
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates after a transfer is confirmed as paid', async () => {
|
it('navigates after a transfer is confirmed as paid', async () => {
|
||||||
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' });
|
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
|
||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
component.selectedPaymentMethod.set('transfer');
|
component.selectedPaymentMethod.set('transfer');
|
||||||
|
|
||||||
await component.onComplete();
|
await component.onComplete();
|
||||||
|
|
||||||
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
||||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a retryable state when transfer validation fails', async () => {
|
it('shows a retryable state when transfer validation fails', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
|
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
|
||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
component.selectedPaymentMethod.set('transfer');
|
component.selectedPaymentMethod.set('transfer');
|
||||||
|
|
||||||
|
|||||||
@@ -448,20 +448,16 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
const purchase = await this.checkoutService
|
const purchase = await this.checkoutService
|
||||||
.withCustomLoading()
|
.withCustomLoading()
|
||||||
.submitPurchaseForReview(tenant.codigo, purchaseId);
|
.getPurchase(tenant.codigo, purchaseId);
|
||||||
|
|
||||||
if (purchase.status === 'paid' || purchase.status === 'pending_payment') {
|
|
||||||
if (purchase.status === 'pending_payment') {
|
|
||||||
this.transferValidationStatus.set('pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (purchase.status === 'paid') {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.transferValidationStatus.set('error');
|
this.transferValidationStatus.set('error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit purchase for review:', error);
|
console.error('Failed to validate transfer payment:', error);
|
||||||
this.transferValidationStatus.set('error');
|
this.transferValidationStatus.set('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FormControl, FormGroup } from '@angular/forms';
|
|||||||
export type PaymentMethod = 'qr' | 'transfer';
|
export type PaymentMethod = 'qr' | 'transfer';
|
||||||
export type TransferField = 'cvu' | 'alias';
|
export type TransferField = 'cvu' | 'alias';
|
||||||
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
|
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
|
||||||
export type TransferValidationStatus = 'idle' | 'checking' | 'pending' | 'error';
|
export type TransferValidationStatus = 'idle' | 'checking' | 'error';
|
||||||
|
|
||||||
export interface PaymentMethodOption {
|
export interface PaymentMethodOption {
|
||||||
id: PaymentMethod;
|
id: PaymentMethod;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="payment-panel__card">
|
<div class="payment-panel__card">
|
||||||
@if (validationStatus() === 'pending' || validationStatus() === 'error') {
|
@if (validationStatus() === 'error') {
|
||||||
<app-payment-verification-error
|
<app-payment-verification-error
|
||||||
[paymentAmount]="paymentAmount()"
|
[paymentAmount]="paymentAmount()"
|
||||||
[whatsappUrl]="whatsappUrl()"
|
[whatsappUrl]="whatsappUrl()"
|
||||||
@@ -102,5 +102,12 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (validationStatus() === 'checking') {
|
||||||
|
<div class="payment-verification" role="status" aria-live="polite">
|
||||||
|
<span class="payment-verification__spinner" aria-hidden="true"></span>
|
||||||
|
<span class="payment-verification__message">Verificando pago</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.payment-panel__card {
|
.payment-panel__card {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
@@ -13,6 +14,40 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-verification {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 4px solid rgba(17, 17, 17, 0.2);
|
||||||
|
border-top-color: #111111;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: payment-verification-spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__message {
|
||||||
|
color: #111111;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes payment-verification-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.payment-panel__title {
|
.payment-panel__title {
|
||||||
max-width: 18rem;
|
max-width: 18rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { getTestBed, TestBed } from '@angular/core/testing';
|
||||||
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CheckoutPaymentTransferComponent } from './checkout-payment-transfer.component';
|
||||||
|
|
||||||
|
describe('CheckoutPaymentTransferComponent', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the payment verification overlay while checking the transfer', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CheckoutPaymentTransferComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(CheckoutPaymentTransferComponent);
|
||||||
|
fixture.componentRef.setInput('validationStatus', 'checking');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const overlay = fixture.nativeElement.querySelector('.payment-verification') as HTMLElement;
|
||||||
|
expect(overlay).not.toBeNull();
|
||||||
|
expect(overlay.textContent).toContain('Verificando pago');
|
||||||
|
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the QR payment error and WhatsApp action when validation fails', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CheckoutPaymentTransferComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(CheckoutPaymentTransferComponent);
|
||||||
|
fixture.componentRef.setInput('validationStatus', 'error');
|
||||||
|
fixture.componentRef.setInput('paymentAmount', 300000);
|
||||||
|
fixture.componentRef.setInput('whatsappUrl', 'https://wa.me/543412602222');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const whatsapp = Array.from(element.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('WhatsApp'),
|
||||||
|
);
|
||||||
|
expect(element.textContent).toMatch(/No pudimos verificar el pago de \$\s*300\.000\./);
|
||||||
|
expect(whatsapp).toBeDefined();
|
||||||
|
expect(element.querySelector('.payment-verification')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user