fix(payment): fail order polling after timeout
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user