diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index e4815a02..f5c79d14 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -107,6 +107,7 @@ export function SubscriptionScreen({ paymentDispatch({ type: "PaymentReturned", orderId: result.data.orderId, + createdAt: result.data.createdAt, }); }; diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts index 6be7d7fe..e8ccf56e 100644 --- a/src/stores/payment/__tests__/payment-machine.test.ts +++ b/src/stores/payment/__tests__/payment-machine.test.ts @@ -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(); 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(); }); diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 7633ea65..a0d1bcf7 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -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" }; diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/payment-machine.helpers.ts index 8b82d860..2611452b 100644 --- a/src/stores/payment/payment-machine.helpers.ts +++ b/src/stores/payment/payment-machine.helpers.ts @@ -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 { currentOrderId: null, payParams: null, orderStatus: null, + orderPollingStartedAt: null, errorMessage: null, }; } diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index a95ceb78..60b1968c 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -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, })), }, diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index aa139327..b83f5cf7 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -16,6 +16,7 @@ export interface PaymentState { currentOrderId: string | null; payParams: Record | 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, };