feat(checkout): implement transfer payment polling with cancellation
This commit is contained in:
@@ -213,19 +213,27 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks the purchase detail once when the transfer was made', async () => {
|
it('polls a transfer every three seconds up to four attempts', 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');
|
||||||
|
|
||||||
await component.onComplete();
|
component.onComplete();
|
||||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
expect(component.transferValidationStatus()).toBe('checking');
|
||||||
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000);
|
||||||
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(attempt);
|
||||||
|
expect(component.transferValidationStatus()).toBe('checking');
|
||||||
|
}
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000);
|
||||||
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
|
||||||
|
expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25);
|
||||||
expect(component.transferValidationStatus()).toBe('error');
|
expect(component.transferValidationStatus()).toBe('error');
|
||||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(30_000);
|
|
||||||
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates after a transfer is confirmed as paid', async () => {
|
it('navigates after a transfer is confirmed as paid', async () => {
|
||||||
@@ -233,26 +241,46 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
component.selectedPaymentMethod.set('transfer');
|
component.selectedPaymentMethod.set('transfer');
|
||||||
|
|
||||||
await component.onComplete();
|
component.onComplete();
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000);
|
||||||
|
|
||||||
expect(checkoutServiceStub.getPurchase).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('keeps polling after transfer validation requests fail', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
checkoutServiceStub.getPurchase.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');
|
||||||
|
|
||||||
await component.onComplete();
|
component.onComplete();
|
||||||
|
await vi.advanceTimersByTimeAsync(12_000);
|
||||||
|
|
||||||
|
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
|
||||||
expect(component.transferValidationStatus()).toBe('error');
|
expect(component.transferValidationStatus()).toBe('error');
|
||||||
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
|
||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels transfer polling when the payment method changes or the component is destroyed', async () => {
|
||||||
|
const first = createComponent();
|
||||||
|
first.component.selectedPaymentMethod.set('transfer');
|
||||||
|
first.component.onComplete();
|
||||||
|
checkoutServiceStub.generatePaymentIntent.mockResolvedValueOnce({});
|
||||||
|
await first.component.selectPaymentMethod('qr');
|
||||||
|
await vi.advanceTimersByTimeAsync(12_000);
|
||||||
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const second = createComponent();
|
||||||
|
second.component.selectedPaymentMethod.set('transfer');
|
||||||
|
second.component.onComplete();
|
||||||
|
second.fixture.destroy();
|
||||||
|
await vi.advanceTimersByTimeAsync(12_000);
|
||||||
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('loads purchase items but prefills customer data from the user', async () => {
|
it('loads purchase items but prefills customer data from the user', async () => {
|
||||||
const purchase = {
|
const purchase = {
|
||||||
id: 25,
|
id: 25,
|
||||||
|
|||||||
@@ -60,10 +60,15 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly qrPollingIntervalMs = 5_000;
|
private readonly qrPollingIntervalMs = 5_000;
|
||||||
private readonly qrPollingMaxAttempts = 9;
|
private readonly qrPollingMaxAttempts = 9;
|
||||||
|
private readonly transferPollingIntervalMs = 3_000;
|
||||||
|
private readonly transferPollingMaxAttempts = 4;
|
||||||
|
|
||||||
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
private qrPollingAttempts = 0;
|
private qrPollingAttempts = 0;
|
||||||
private qrPollingRunId = 0;
|
private qrPollingRunId = 0;
|
||||||
|
private transferPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private transferPollingAttempts = 0;
|
||||||
|
private transferPollingRunId = 0;
|
||||||
private paymentMethodRequestId = 0;
|
private paymentMethodRequestId = 0;
|
||||||
private navigationStarted = false;
|
private navigationStarted = false;
|
||||||
|
|
||||||
@@ -167,6 +172,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
|
this.stopTransferPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
|
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
|
||||||
@@ -211,6 +217,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.isEditingItems.set(true);
|
this.isEditingItems.set(true);
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
|
this.stopTransferPolling();
|
||||||
this.qrData.set(null);
|
this.qrData.set(null);
|
||||||
this.qrPaymentStatus.set('idle');
|
this.qrPaymentStatus.set('idle');
|
||||||
this.transferAccount.set(null);
|
this.transferAccount.set(null);
|
||||||
@@ -385,6 +392,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
public async canDeactivate(): Promise<boolean> {
|
public async canDeactivate(): Promise<boolean> {
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
|
this.stopTransferPolling();
|
||||||
|
|
||||||
if (this.navigationStarted) {
|
if (this.navigationStarted) {
|
||||||
return true;
|
return true;
|
||||||
@@ -421,6 +429,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
|
this.stopTransferPolling();
|
||||||
this.qrPaymentStatus.set('idle');
|
this.qrPaymentStatus.set('idle');
|
||||||
this.transferValidationStatus.set('idle');
|
this.transferValidationStatus.set('idle');
|
||||||
this.selectedPaymentMethod.set(method);
|
this.selectedPaymentMethod.set(method);
|
||||||
@@ -478,6 +487,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.transferDni.set(dni);
|
this.transferDni.set(dni);
|
||||||
|
this.stopTransferPolling();
|
||||||
this.transferValidationStatus.set('idle');
|
this.transferValidationStatus.set('idle');
|
||||||
this.isGeneratingIntent.set(true);
|
this.isGeneratingIntent.set(true);
|
||||||
try {
|
try {
|
||||||
@@ -518,7 +528,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async onComplete(): Promise<void> {
|
protected onComplete(): void {
|
||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
const tenant = this.tenantService.tenant();
|
const tenant = this.tenantService.tenant();
|
||||||
|
|
||||||
@@ -532,22 +542,72 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.stopTransferPolling();
|
||||||
this.transferValidationStatus.set('checking');
|
this.transferValidationStatus.set('checking');
|
||||||
|
this.transferPollingAttempts = 0;
|
||||||
|
|
||||||
|
const runId = this.transferPollingRunId;
|
||||||
|
this.scheduleTransferPoll(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleTransferPoll(runId: number): void {
|
||||||
|
this.transferPollingTimeoutId = setTimeout(() => {
|
||||||
|
this.transferPollingTimeoutId = null;
|
||||||
|
void this.checkTransferPayment(runId);
|
||||||
|
}, this.transferPollingIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkTransferPayment(runId: number): Promise<void> {
|
||||||
|
if (runId !== this.transferPollingRunId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseId = this.createdPurchaseId();
|
||||||
|
const tenant = this.tenantService.tenant();
|
||||||
|
|
||||||
|
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'transfer') {
|
||||||
|
this.stopTransferPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transferPollingAttempts += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const purchase = await this.checkoutService
|
const purchase = await this.checkoutService
|
||||||
.withCustomLoading()
|
.withCustomLoading()
|
||||||
.getPurchase(tenant.codigo, purchaseId);
|
.getPurchase(tenant.codigo, purchaseId);
|
||||||
|
|
||||||
|
if (runId !== this.transferPollingRunId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (purchase.status === 'paid') {
|
if (purchase.status === 'paid') {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.transferValidationStatus.set('error');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to validate transfer payment:', error);
|
console.error('Failed to validate transfer payment:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runId !== this.transferPollingRunId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.transferPollingAttempts >= this.transferPollingMaxAttempts) {
|
||||||
|
this.stopTransferPolling();
|
||||||
this.transferValidationStatus.set('error');
|
this.transferValidationStatus.set('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleTransferPoll(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTransferPolling(): void {
|
||||||
|
this.transferPollingRunId += 1;
|
||||||
|
|
||||||
|
if (this.transferPollingTimeoutId !== null) {
|
||||||
|
clearTimeout(this.transferPollingTimeoutId);
|
||||||
|
this.transferPollingTimeoutId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,6 +721,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.navigationStarted = true;
|
this.navigationStarted = true;
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
|
this.stopTransferPolling();
|
||||||
|
|
||||||
void this.router.navigate(['/checkout/status', purchaseId]);
|
void this.router.navigate(['/checkout/status', purchaseId]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user