fix(payment): fail order polling after timeout

This commit is contained in:
2026-06-30 10:37:44 +08:00
parent c94c13d863
commit 01a17fbdc9
6 changed files with 142 additions and 7 deletions
@@ -107,6 +107,7 @@ export function SubscriptionScreen({
paymentDispatch({ paymentDispatch({
type: "PaymentReturned", type: "PaymentReturned",
orderId: result.data.orderId, orderId: result.data.orderId,
createdAt: result.data.createdAt,
}); });
}; };
@@ -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 { createActor, fromPromise, waitFor } from "xstate";
import { import {
@@ -8,6 +8,10 @@ import {
PaymentPlansResponse, PaymentPlansResponse,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
import { paymentMachine } from "@/stores/payment/payment-machine"; import { paymentMachine } from "@/stores/payment/payment-machine";
import {
MAX_ORDER_POLLING_MS,
PAYMENT_TIMEOUT_ERROR_MESSAGE,
} from "@/stores/payment/payment-machine.helpers";
interface CreateOrderInput { interface CreateOrderInput {
planId: string; planId: string;
@@ -73,10 +77,13 @@ function createTestPaymentMachine(
overrides: Partial<{ overrides: Partial<{
createOrderSpy: CreateOrderSpy; createOrderSpy: CreateOrderSpy;
orderStatus: "pending" | "paid" | "failed"; orderStatus: "pending" | "paid" | "failed";
orderStatuses: Array<"pending" | "paid" | "failed">;
}> = {}, }> = {},
) { ) {
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>(); const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
const orderStatus = overrides.orderStatus ?? "paid"; const orderStatus = overrides.orderStatus ?? "paid";
const orderStatuses = overrides.orderStatuses ?? [];
let pollCount = 0;
return paymentMachine.provide({ return paymentMachine.provide({
actors: { actors: {
@@ -101,19 +108,28 @@ function createTestPaymentMachine(
}, },
}); });
}), }),
pollOrderStatus: fromPromise(async () => pollOrderStatus: fromPromise(async () => {
PaymentOrderStatusResponse.from({ const status =
orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ??
orderStatus;
pollCount += 1;
return PaymentOrderStatusResponse.from({
orderId: "pay_test_001", orderId: "pay_test_001",
status: orderStatus, status,
orderType: "vip_monthly", orderType: "vip_monthly",
planId: "vip_monthly", planId: "vip_monthly",
});
}), }),
),
}, },
}); });
} }
describe("paymentMachine", () => { describe("paymentMachine", () => {
afterEach(() => {
vi.useRealTimers();
});
it("loads plans and selects the default monthly VIP plan", async () => { it("loads plans and selects the default monthly VIP plan", async () => {
const actor = createActor(createTestPaymentMachine()).start(); const actor = createActor(createTestPaymentMachine()).start();
@@ -218,6 +234,7 @@ describe("paymentMachine", () => {
}); });
expect(context.currentOrderId).toBe("pay_test_001"); expect(context.currentOrderId).toBe("pay_test_001");
expect(context.orderStatus).toBe("paid"); expect(context.orderStatus).toBe("paid");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.launchNonce).toBe(1); expect(context.launchNonce).toBe(1);
actor.stop(); actor.stop();
@@ -236,6 +253,7 @@ describe("paymentMachine", () => {
expect(actor.getSnapshot().context.currentOrderId).toBe("pay_test_001"); expect(actor.getSnapshot().context.currentOrderId).toBe("pay_test_001");
expect(actor.getSnapshot().context.orderStatus).toBe("pending"); expect(actor.getSnapshot().context.orderStatus).toBe("pending");
expect(actor.getSnapshot().context.orderPollingStartedAt).not.toBeNull();
actor.send({ type: "PaymentReset" }); actor.send({ type: "PaymentReset" });
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
@@ -244,6 +262,7 @@ describe("paymentMachine", () => {
expect(context.currentOrderId).toBeNull(); expect(context.currentOrderId).toBeNull();
expect(context.payParams).toBeNull(); expect(context.payParams).toBeNull();
expect(context.orderStatus).toBeNull(); expect(context.orderStatus).toBeNull();
expect(context.orderPollingStartedAt).toBeNull();
expect(context.errorMessage).toBeNull(); expect(context.errorMessage).toBeNull();
actor.stop(); actor.stop();
@@ -262,11 +281,94 @@ describe("paymentMachine", () => {
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.orderStatus).toBe("failed"); expect(context.orderStatus).toBe("failed");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.errorMessage).toBe("Payment failed or was cancelled."); expect(context.errorMessage).toBe("Payment failed or was cancelled.");
actor.stop(); 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 () => { it("clears stale order data and disables auto renew for lifetime plans", async () => {
const actor = createActor(createTestPaymentMachine()).start(); const actor = createActor(createTestPaymentMachine()).start();
@@ -285,6 +387,7 @@ describe("paymentMachine", () => {
expect(context.currentOrderId).toBeNull(); expect(context.currentOrderId).toBeNull();
expect(context.payParams).toBeNull(); expect(context.payParams).toBeNull();
expect(context.orderStatus).toBeNull(); expect(context.orderStatus).toBeNull();
expect(context.orderPollingStartedAt).toBeNull();
actor.stop(); actor.stop();
}); });
+1 -1
View File
@@ -10,7 +10,7 @@ export type PaymentEvent =
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean } | { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
| { type: "PaymentAgreementChanged"; agreed: boolean } | { type: "PaymentAgreementChanged"; agreed: boolean }
| { type: "PaymentCreateOrderSubmitted" } | { type: "PaymentCreateOrderSubmitted" }
| { type: "PaymentReturned"; orderId: string } | { type: "PaymentReturned"; orderId: string; createdAt?: number }
| { type: "PaymentLaunchFailed"; errorMessage: string } | { type: "PaymentLaunchFailed"; errorMessage: string }
| { type: "PaymentErrorCleared" } | { type: "PaymentErrorCleared" }
| { type: "PaymentReset" }; | { type: "PaymentReset" };
@@ -2,10 +2,22 @@ import type { PaymentPlan } from "@/data/dto/payment";
import type { PaymentState } from "./payment-state"; 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 { export function toPaymentErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); 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( export function defaultAutoRenewForPlan(
planId: string, planId: string,
plans: readonly PaymentPlan[] = [], plans: readonly PaymentPlan[] = [],
@@ -34,6 +46,7 @@ export function resetOrderState(): Partial<PaymentState> {
currentOrderId: null, currentOrderId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
}; };
} }
+16
View File
@@ -14,6 +14,8 @@ import {
import { import {
defaultAutoRenewForPlan, defaultAutoRenewForPlan,
getDefaultPlanId, getDefaultPlanId,
hasOrderPollingTimedOut,
PAYMENT_TIMEOUT_ERROR_MESSAGE,
resetOrderState, resetOrderState,
toPaymentErrorMessage, toPaymentErrorMessage,
} from "./payment-machine.helpers"; } from "./payment-machine.helpers";
@@ -192,6 +194,7 @@ export const paymentMachine = setup({
currentOrderId: event.output.orderId, currentOrderId: event.output.orderId,
payParams: event.output.payParams, payParams: event.output.payParams,
orderStatus: "pending", orderStatus: "pending",
orderPollingStartedAt: Date.now(),
errorMessage: null, errorMessage: null,
launchNonce: context.launchNonce + 1, launchNonce: context.launchNonce + 1,
})), })),
@@ -217,6 +220,7 @@ export const paymentMachine = setup({
target: "paid", target: "paid",
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
orderStatus: event.output.status, orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
})), })),
}, },
@@ -225,9 +229,20 @@ export const paymentMachine = setup({
target: "failed", target: "failed",
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
orderStatus: event.output.status, orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.", 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", target: "waitingForPayment",
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
@@ -331,6 +346,7 @@ export const paymentMachine = setup({
currentOrderId: event.orderId, currentOrderId: event.orderId,
payParams: null, payParams: null,
orderStatus: "pending", orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null, errorMessage: null,
})), })),
}, },
+2
View File
@@ -16,6 +16,7 @@ export interface PaymentState {
currentOrderId: string | null; currentOrderId: string | null;
payParams: Record<string, unknown> | null; payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null; orderStatus: PaymentOrderStatus | null;
orderPollingStartedAt: number | null;
errorMessage: string | null; errorMessage: string | null;
launchNonce: number; launchNonce: number;
} }
@@ -29,6 +30,7 @@ export const initialState: PaymentState = {
currentOrderId: null, currentOrderId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
orderPollingStartedAt: null,
errorMessage: null, errorMessage: null,
launchNonce: 0, launchNonce: 0,
}; };