fix(payment): fail order polling after timeout
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createActor, fromPromise, waitFor } from "xstate";
|
||||
|
||||
import {
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
PaymentPlansResponse,
|
||||
} from "@/data/dto/payment";
|
||||
import { paymentMachine } from "@/stores/payment/payment-machine";
|
||||
import {
|
||||
MAX_ORDER_POLLING_MS,
|
||||
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
||||
} from "@/stores/payment/payment-machine.helpers";
|
||||
|
||||
interface CreateOrderInput {
|
||||
planId: string;
|
||||
@@ -73,10 +77,13 @@ function createTestPaymentMachine(
|
||||
overrides: Partial<{
|
||||
createOrderSpy: CreateOrderSpy;
|
||||
orderStatus: "pending" | "paid" | "failed";
|
||||
orderStatuses: Array<"pending" | "paid" | "failed">;
|
||||
}> = {},
|
||||
) {
|
||||
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
|
||||
const orderStatus = overrides.orderStatus ?? "paid";
|
||||
const orderStatuses = overrides.orderStatuses ?? [];
|
||||
let pollCount = 0;
|
||||
|
||||
return paymentMachine.provide({
|
||||
actors: {
|
||||
@@ -101,19 +108,28 @@ function createTestPaymentMachine(
|
||||
},
|
||||
});
|
||||
}),
|
||||
pollOrderStatus: fromPromise(async () =>
|
||||
PaymentOrderStatusResponse.from({
|
||||
pollOrderStatus: fromPromise(async () => {
|
||||
const status =
|
||||
orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ??
|
||||
orderStatus;
|
||||
pollCount += 1;
|
||||
|
||||
return PaymentOrderStatusResponse.from({
|
||||
orderId: "pay_test_001",
|
||||
status: orderStatus,
|
||||
status,
|
||||
orderType: "vip_monthly",
|
||||
planId: "vip_monthly",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("paymentMachine", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads plans and selects the default monthly VIP plan", async () => {
|
||||
const actor = createActor(createTestPaymentMachine()).start();
|
||||
|
||||
@@ -218,6 +234,7 @@ describe("paymentMachine", () => {
|
||||
});
|
||||
expect(context.currentOrderId).toBe("pay_test_001");
|
||||
expect(context.orderStatus).toBe("paid");
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
expect(context.launchNonce).toBe(1);
|
||||
|
||||
actor.stop();
|
||||
@@ -236,6 +253,7 @@ describe("paymentMachine", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.currentOrderId).toBe("pay_test_001");
|
||||
expect(actor.getSnapshot().context.orderStatus).toBe("pending");
|
||||
expect(actor.getSnapshot().context.orderPollingStartedAt).not.toBeNull();
|
||||
|
||||
actor.send({ type: "PaymentReset" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
@@ -244,6 +262,7 @@ describe("paymentMachine", () => {
|
||||
expect(context.currentOrderId).toBeNull();
|
||||
expect(context.payParams).toBeNull();
|
||||
expect(context.orderStatus).toBeNull();
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
expect(context.errorMessage).toBeNull();
|
||||
|
||||
actor.stop();
|
||||
@@ -262,11 +281,94 @@ describe("paymentMachine", () => {
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.orderStatus).toBe("failed");
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
expect(context.errorMessage).toBe("Payment failed or was cancelled.");
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("moves to failed state when a pending order exceeds polling timeout", async () => {
|
||||
const now = new Date("2026-06-30T00:00:00.000Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ orderStatus: "pending" }),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PaymentInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
|
||||
|
||||
vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.orderStatus).toBe("failed");
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps paid response higher priority than polling timeout", async () => {
|
||||
const now = new Date("2026-06-30T00:00:00.000Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ orderStatuses: ["pending", "paid"] }),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PaymentInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
|
||||
|
||||
vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.orderStatus).toBe("paid");
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
expect(context.errorMessage).toBeNull();
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("uses restored order creation time when polling returned payment", async () => {
|
||||
const now = new Date("2026-06-30T00:00:00.000Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ orderStatus: "pending" }),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PaymentInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({
|
||||
type: "PaymentReturned",
|
||||
orderId: "pay_returned_001",
|
||||
createdAt: now.getTime() - MAX_ORDER_POLLING_MS,
|
||||
});
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.currentOrderId).toBe("pay_returned_001");
|
||||
expect(context.orderStatus).toBe("failed");
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears stale order data and disables auto renew for lifetime plans", async () => {
|
||||
const actor = createActor(createTestPaymentMachine()).start();
|
||||
|
||||
@@ -285,6 +387,7 @@ describe("paymentMachine", () => {
|
||||
expect(context.currentOrderId).toBeNull();
|
||||
expect(context.payParams).toBeNull();
|
||||
expect(context.orderStatus).toBeNull();
|
||||
expect(context.orderPollingStartedAt).toBeNull();
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export type PaymentEvent =
|
||||
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
|
||||
| { type: "PaymentAgreementChanged"; agreed: boolean }
|
||||
| { type: "PaymentCreateOrderSubmitted" }
|
||||
| { type: "PaymentReturned"; orderId: string }
|
||||
| { type: "PaymentReturned"; orderId: string; createdAt?: number }
|
||||
| { type: "PaymentLaunchFailed"; errorMessage: string }
|
||||
| { type: "PaymentErrorCleared" }
|
||||
| { type: "PaymentReset" };
|
||||
|
||||
@@ -2,10 +2,22 @@ import type { PaymentPlan } from "@/data/dto/payment";
|
||||
|
||||
import type { PaymentState } from "./payment-state";
|
||||
|
||||
export const MAX_ORDER_POLLING_MS = 5 * 60 * 1000;
|
||||
export const PAYMENT_TIMEOUT_ERROR_MESSAGE =
|
||||
"Payment timed out. Please try again.";
|
||||
|
||||
export function toPaymentErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function hasOrderPollingTimedOut(
|
||||
startedAt: number | null,
|
||||
now = Date.now(),
|
||||
maxDurationMs = MAX_ORDER_POLLING_MS,
|
||||
): boolean {
|
||||
return startedAt !== null && now - startedAt >= maxDurationMs;
|
||||
}
|
||||
|
||||
export function defaultAutoRenewForPlan(
|
||||
planId: string,
|
||||
plans: readonly PaymentPlan[] = [],
|
||||
@@ -34,6 +46,7 @@ export function resetOrderState(): Partial<PaymentState> {
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import {
|
||||
defaultAutoRenewForPlan,
|
||||
getDefaultPlanId,
|
||||
hasOrderPollingTimedOut,
|
||||
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
||||
resetOrderState,
|
||||
toPaymentErrorMessage,
|
||||
} from "./payment-machine.helpers";
|
||||
@@ -192,6 +194,7 @@ export const paymentMachine = setup({
|
||||
currentOrderId: event.output.orderId,
|
||||
payParams: event.output.payParams,
|
||||
orderStatus: "pending",
|
||||
orderPollingStartedAt: Date.now(),
|
||||
errorMessage: null,
|
||||
launchNonce: context.launchNonce + 1,
|
||||
})),
|
||||
@@ -217,6 +220,7 @@ export const paymentMachine = setup({
|
||||
target: "paid",
|
||||
actions: assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
})),
|
||||
},
|
||||
@@ -225,9 +229,20 @@ export const paymentMachine = setup({
|
||||
target: "failed",
|
||||
actions: assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: "Payment failed or was cancelled.",
|
||||
})),
|
||||
},
|
||||
{
|
||||
guard: ({ context }) =>
|
||||
hasOrderPollingTimedOut(context.orderPollingStartedAt),
|
||||
target: "failed",
|
||||
actions: assign({
|
||||
orderStatus: "failed",
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: "waitingForPayment",
|
||||
actions: assign(({ event }) => ({
|
||||
@@ -331,6 +346,7 @@ export const paymentMachine = setup({
|
||||
currentOrderId: event.orderId,
|
||||
payParams: null,
|
||||
orderStatus: "pending",
|
||||
orderPollingStartedAt: event.createdAt ?? Date.now(),
|
||||
errorMessage: null,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PaymentState {
|
||||
currentOrderId: string | null;
|
||||
payParams: Record<string, unknown> | null;
|
||||
orderStatus: PaymentOrderStatus | null;
|
||||
orderPollingStartedAt: number | null;
|
||||
errorMessage: string | null;
|
||||
launchNonce: number;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export const initialState: PaymentState = {
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
launchNonce: 0,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user